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::sync::{Arc, Mutex, MutexGuard, PoisonError};
42use std::time::Duration;
43
44use anyhow::{Context, Result, bail};
45use jiff::Timestamp;
46use serde::{Deserialize, Serialize};
47
48use crate::agent::{self, Invocation, SeatState};
49use crate::config::Config;
50use crate::plan;
51use crate::queue::{self, Queue, Source, Task};
52
53/// On-disk format for a conversation. Bumped when a field's meaning changes.
54///
55/// The web UI is written against this shape by hand, so a field that changes
56/// meaning without a bump here is a front end that lies silently.
57pub const SCHEMA: u32 = 1;
58
59/// Wall-clock limit for one agent turn. See [`crate::config::Graph::timeout_chat`].
60///
61/// An hour by default, not the five minutes this used to be. The short
62/// timeout assumed an operator holding a phone with a spinner on it, who
63/// needed to be told a turn was wedged while they were still looking at the
64/// screen. That is no longer how this gets used: the operator starts another
65/// conversation while this one thinks and comes back to it later, so the
66/// time a turn takes is no longer time spent waiting - it is only a seat
67/// held, which is cheap. What still needs a bound is a genuinely wedged CLI,
68/// and an hour is generous enough to not be that.
69fn turn_timeout(cfg: &Config) -> Duration {
70    Duration::from_secs(cfg.graph.timeout_chat)
71}
72
73/// Seat name for the interviewing agent.
74///
75/// One seat per conversation, so the CLI-side conversation is scoped to this
76/// chat and nothing else - the same rule [`crate::agent`] applies to judges.
77const SEAT: &str = "plan";
78
79/// Prefix on an agent turn that magi wrote rather than an agent.
80///
81/// A failed turn has to be *visible*, and the transcript is the only surface
82/// the phone renders, so the failure goes in as an agent turn carrying this
83/// marker. Two turn authors is what the wire shape allows (`operator` /
84/// `agent`), and inventing a third would break every client written against
85/// it; a stable prefix the UI can key on costs nothing and loses no
86/// information.
87pub const MAGI_NOTE: &str = "magi: ";
88
89/// Who said something.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum Who {
93    /// The person magi is planning for.
94    Operator,
95    /// The interviewing agent - or magi itself, reporting that the agent
96    /// failed. See [`MAGI_NOTE`].
97    Agent,
98}
99
100/// One image the operator attached to a turn.
101///
102/// Never carries the bytes themselves: the picture lives on disk under
103/// [`Chats::attachments_dir`], named by `id` alone. `name` is the filename
104/// the operator's browser reported, kept only for display - it never
105/// contributes to a path, which is what keeps an upload from being able to
106/// traverse outside its own directory.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct Attachment {
110    /// Server-minted id; also the file's stem under `attachments_dir`.
111    pub id: String,
112    /// The operator's own filename, for display only.
113    pub name: String,
114    /// Validated by `web` at upload time against a closed whitelist:
115    /// `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
116    pub mime: String,
117    /// Size in bytes, so the phone can show it without a second request.
118    pub bytes: u64,
119}
120
121/// One message in the conversation.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct Turn {
125    /// Who wrote it.
126    pub who: Who,
127    /// What they said.
128    pub body: String,
129    /// When it was said.
130    pub at: Timestamp,
131    /// Images attached to this turn. `#[serde(default)]` so a conversation
132    /// recorded before attachments existed still reads - see
133    /// `a_chat_recorded_without_a_from_field_still_reads`'s sibling test for
134    /// this field.
135    #[serde(default)]
136    pub attachments: Vec<Attachment>,
137}
138
139/// Where a conversation is in its life.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "lowercase")]
142pub enum ChatStatus {
143    /// Still being talked through.
144    Open,
145    /// A task was filed from its draft.
146    Filed,
147    /// Given up on. Kept on disk, because an abandoned interview is still the
148    /// record of a decision the operator made.
149    Abandoned,
150}
151
152impl ChatStatus {
153    /// Is this conversation still live?
154    pub fn open(self) -> bool {
155        matches!(self, Self::Open)
156    }
157
158    /// Wire form, for the phone and for logs.
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::Open => "open",
162            Self::Filed => "filed",
163            Self::Abandoned => "abandoned",
164        }
165    }
166}
167
168/// One planning conversation.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct Chat {
172    /// On-disk format version.
173    pub schema: u32,
174    /// Conversation id, e.g. `20260903-014455-ab12`.
175    pub id: String,
176    /// Repository the task will be filed against.
177    pub repo: PathBuf,
178    /// The chat this one was derived from, when it began as a fork into a
179    /// different repository. See [`derived_background`]. `#[serde(default)]`
180    /// so a conversation recorded before this field existed still reads.
181    #[serde(default)]
182    pub from: Option<String>,
183    /// Roster agent id doing the interviewing.
184    pub agent: String,
185    /// Current state.
186    pub status: ChatStatus,
187    /// Everything said, oldest first.
188    pub turns: Vec<Turn>,
189    /// The task file, once the agent has written one.
190    pub draft: Option<String>,
191    /// Queue task id, once filed.
192    pub task: Option<String>,
193    /// When the conversation was opened.
194    pub created_at: Timestamp,
195    /// Last change to this file.
196    pub updated_at: Timestamp,
197    /// The CLI-side conversation, which is what makes turn N+1 cost one
198    /// sentence instead of the whole transcript.
199    ///
200    /// Not `pub`: it is magi's bookkeeping, not part of the interview, and a
201    /// caller that edited it would silently detach the record from the
202    /// conversation the model is actually holding. It is still serialized,
203    /// because a chat that survives a restart without its session id resumes
204    /// nothing.
205    seat: SeatState,
206}
207
208impl Chat {
209    /// Short form used in lists and notifications, matching a run's short id.
210    pub fn short(&self) -> &str {
211        short(&self.id)
212    }
213
214    /// How many turns the interviewing agent has actually taken.
215    ///
216    /// Read off the seat rather than counted from [`Chat::turns`], because a
217    /// failed turn appends a [`MAGI_NOTE`] message that no agent wrote. The
218    /// number names artifacts, so it has to match what was invoked.
219    pub fn agent_turns(&self) -> usize {
220        self.seat.turns
221    }
222}
223
224/// A conversation store on disk.
225#[derive(Debug, Clone)]
226pub struct Chats {
227    root: PathBuf,
228    /// Serializes the read-modify-write cycle that reads a chat, decides
229    /// something from its `status`, and writes the whole record back.
230    /// [`abandon`], [`file_draft`] and the tail of [`turn`] all take this
231    /// before that cycle rather than after just the read: a re-read narrows
232    /// the window another writer can land in, but does not close it, since
233    /// nothing stopped that other writer's own put from landing between this
234    /// call's re-read and its own put. Shared across every clone, since every
235    /// clone is a handle onto the same files - see [`crate::talk::Talks`],
236    /// which this mirrors.
237    lock: Arc<Mutex<()>>,
238}
239
240impl Chats {
241    /// The operator's conversations, `<home>/chats`.
242    pub fn open() -> Self {
243        Self::at(crate::run::home().join("chats"))
244    }
245
246    /// A store at an explicit root. Tests use this, which is why none of them
247    /// need the operator's real home.
248    pub fn at(root: PathBuf) -> Self {
249        Self {
250            root,
251            lock: Arc::new(Mutex::new(())),
252        }
253    }
254
255    /// Claim the right to read-modify-write a chat's `status`. See
256    /// [`crate::talk::Talks::guard`], which this mirrors, including recovering
257    /// from poisoning rather than propagating it: one panicking caller must
258    /// not wedge every chat in the store.
259    ///
260    /// This is an in-process `Mutex` - it serializes callers inside one
261    /// `magi web`, but is invisible to `magi plan --abandon` running as its
262    /// own process with its own `Arc`. [`Chats::claim`] is what closes that
263    /// gap; the two are meant to be taken together, this one first.
264    fn guard(&self) -> MutexGuard<'_, ()> {
265        self.lock.lock().unwrap_or_else(PoisonError::into_inner)
266    }
267
268    /// Path of the claim lock for a conversation. One definition, so
269    /// [`Chats::claim`] cannot end up naming a different file than whatever
270    /// else might go looking for it.
271    fn lock_path(&self, id: &str) -> PathBuf {
272        self.root.join(format!("{id}.lock"))
273    }
274
275    /// Take cross-process exclusive ownership of one chat's record for the
276    /// read-modify-write cycle that decides its `status`.
277    ///
278    /// [`Chats::guard`] only ever sees other callers inside the same process;
279    /// `magi plan --abandon` is a separate CLI invocation with its own
280    /// `Arc<Mutex<()>>`, wired to nothing a running `magi web` holds. This is
281    /// a `create_new` file next to the record instead - atomic on every
282    /// platform magi targets, and invisible to no process that asks - the
283    /// same primitive [`crate::queue::Queue::claim`] uses to keep a CLI edit
284    /// and a running daemon off the same task file at once. The returned
285    /// guard releases on drop, including on panic.
286    fn claim(&self, id: &str) -> Result<ChatClaim> {
287        std::fs::create_dir_all(&self.root)
288            .with_context(|| format!("create {}", self.root.display()))?;
289        let path = self.lock_path(id);
290        match std::fs::OpenOptions::new()
291            .write(true)
292            .create_new(true)
293            .open(&path)
294        {
295            Ok(mut f) => {
296                use std::io::Write as _;
297                // Best effort: the pid is for the human looking at a stale lock.
298                let _ = writeln!(f, "{}", std::process::id());
299                Ok(ChatClaim { path })
300            }
301            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
302                bail!("chat {id} is claimed by another process right now")
303            }
304            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
305        }
306    }
307
308    /// Directory holding the conversation files.
309    pub fn root(&self) -> &Path {
310        &self.root
311    }
312
313    /// Path for one conversation id.
314    pub fn path_of(&self, id: &str) -> PathBuf {
315        self.root.join(format!("{id}.json"))
316    }
317
318    /// Where one conversation's prompts and CLI output are kept.
319    ///
320    /// Beside the record rather than inside it, with the same stem convention a
321    /// run's nodes use, so a conversation that went wrong can be read back
322    /// turn by turn - which is the only way to tell "the agent said nothing"
323    /// apart from "magi never asked it".
324    pub fn artifacts_of(&self, id: &str) -> PathBuf {
325        self.root.join(format!("{id}.artifacts"))
326    }
327
328    /// Where this conversation's attached images live: a subdirectory of
329    /// `artifacts_of`, so deleting the conversation deletes its attachments
330    /// too and nothing here needs its own cleanup path.
331    pub fn attachments_dir(&self, id: &str) -> PathBuf {
332        self.artifacts_of(id).join("attachments")
333    }
334
335    /// Persist one already-validated attachment and return its metadata.
336    ///
337    /// `web::chat_attachment_post` is the only caller: it has already
338    /// checked `mime` against the whitelist and sniffed the bytes, so an
339    /// unrecognised mime reaching here is a bug in that caller, not
340    /// something an operator did. The id is minted here and never taken
341    /// from the client; `name` is stored for display only and never used to
342    /// build a path.
343    pub fn put_attachment(
344        &self,
345        id: &str,
346        mime: &str,
347        name: &str,
348        data: &[u8],
349    ) -> Result<Attachment> {
350        let dir = self.attachments_dir(id);
351        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
352        let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
353        let att = Attachment {
354            id: new_attachment_id(),
355            name: name.to_owned(),
356            mime: mime.to_owned(),
357            bytes: data.len() as u64,
358        };
359        std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
360            .with_context(|| format!("write attachment {}", att.id))?;
361        std::fs::write(
362            dir.join(format!("{}.json", att.id)),
363            serde_json::to_string(&att).context("serialize attachment")?,
364        )
365        .with_context(|| format!("write attachment metadata {}", att.id))?;
366        Ok(att)
367    }
368
369    /// Just the metadata, without reading the image bytes back off disk -
370    /// what `web::chat_say` uses to turn an id the operator referenced into
371    /// an [`Attachment`] before appending a [`Turn`], where the bytes
372    /// themselves are of no interest. `None` for an id this conversation
373    /// never stored - including one that merely looks plausible:
374    /// [`valid_attachment_id`] is checked here too, not only by the caller,
375    /// the same defence-in-depth `Questions::panel_asset` uses for its own
376    /// asset ids.
377    pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
378        if !valid_attachment_id(att_id) {
379            return Ok(None);
380        }
381        let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
382        if !meta_path.is_file() {
383            return Ok(None);
384        }
385        let att = serde_json::from_str(
386            &std::fs::read_to_string(&meta_path)
387                .with_context(|| format!("read {}", meta_path.display()))?,
388        )
389        .with_context(|| format!("parse {}", meta_path.display()))?;
390        Ok(Some(att))
391    }
392
393    /// A stored attachment's metadata and its bytes together, for serving it
394    /// back on `GET`. `None` under the same conditions as
395    /// [`Chats::attachment_meta`], which this is built on.
396    pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
397        let Some(att) = self.attachment_meta(id, att_id)? else {
398            return Ok(None);
399        };
400        let ext = attachment_ext(&att.mime).with_context(|| {
401            format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
402        })?;
403        let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
404        let data =
405            std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
406        Ok(Some((att, data)))
407    }
408
409    /// Absolute path of one attachment's bytes, for the prompt note [`turn`]
410    /// appends and for [`Invocation::attachments`]. `None` only for a mime
411    /// [`put_attachment`] could never have written, which means the
412    /// attachment did not come from this store.
413    ///
414    /// `self.root` (and so `attachments_dir`) is not guaranteed absolute on
415    /// its own - `run::home()` returns a bare relative `PathBuf` verbatim
416    /// when the operator sets `MAGI_HOME` to a relative path, and nothing
417    /// canonicalizes it on the way in. That is harmless for every other use
418    /// of this store, since its own I/O runs in this process against this
419    /// process's cwd - but this path is handed to a CLI invoked with `cwd:
420    /// &chat.repo`, a different directory, so a relative path here would
421    /// resolve against the wrong place once it reached the prompt.
422    /// `std::path::absolute` fixes it against *this* process's cwd before
423    /// that happens; see `disk::free_bytes_by_os` for the same function used
424    /// the same way elsewhere in this codebase.
425    fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
426        let ext = attachment_ext(&att.mime)?;
427        let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
428        std::path::absolute(&path).ok()
429    }
430
431    /// Write a conversation, atomically, so a process killed mid-write leaves
432    /// the previous state readable rather than a truncated file that would lose
433    /// the whole interview.
434    pub fn put(&self, c: &mut Chat) -> Result<()> {
435        std::fs::create_dir_all(&self.root)
436            .with_context(|| format!("create {}", self.root.display()))?;
437        c.updated_at = Timestamp::now();
438        let body = serde_json::to_string_pretty(c).context("serialize chat")?;
439        let path = self.path_of(&c.id);
440        let tmp = path.with_extension("json.tmp");
441        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
442        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
443        Ok(())
444    }
445
446    /// Load a conversation by id or unambiguous id prefix.
447    pub fn get(&self, id: &str) -> Result<Chat> {
448        let resolved = self.resolve_id(id)?;
449        read_path(&self.path_of(&resolved))
450    }
451
452    /// Every conversation on disk: open first, then newest first.
453    ///
454    /// Open first because that ordering is the product - the list exists to
455    /// show the operator what is still being talked through, and a filed
456    /// interview is history underneath it. Unreadable files are skipped rather
457    /// than fatal: one corrupt record must not take the web UI down, and must
458    /// certainly not hide the open conversation the operator came back for.
459    pub fn list(&self) -> Vec<Chat> {
460        let mut all: Vec<Chat> = std::fs::read_dir(&self.root)
461            .into_iter()
462            .flatten()
463            .flatten()
464            .map(|e| e.path())
465            .filter(|p| p.extension().is_some_and(|x| x == "json"))
466            .filter_map(|p| read_path(&p).ok())
467            .collect();
468        all.sort_unstable_by(|a, b| {
469            let rank = |c: &Chat| u8::from(!c.status.open());
470            rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
471        });
472        all
473    }
474
475    /// Expand an id prefix to exactly one conversation id. The short id the
476    /// phone shows is a suffix, so that is accepted too.
477    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
478        if self.path_of(prefix).is_file() {
479            return Ok(prefix.to_owned());
480        }
481        let hits: Vec<String> = self
482            .list()
483            .into_iter()
484            .map(|c| c.id)
485            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
486            .collect();
487        match hits.len() {
488            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
489            0 => bail!("no chat matches `{prefix}`"),
490            _ => bail!(
491                "`{prefix}` matches {} chats: {}",
492                hits.len(),
493                hits.join(", ")
494            ),
495        }
496    }
497
498    /// Newest modification time in the store, in milliseconds, for change
499    /// detection. The web UI compares this instead of re-reading every
500    /// conversation, so an idle phone on a slow link costs one `stat` per file.
501    pub fn revision(&self) -> u64 {
502        std::fs::read_dir(&self.root)
503            .into_iter()
504            .flatten()
505            .flatten()
506            .filter_map(|e| e.metadata().ok())
507            .filter_map(|m| m.modified().ok())
508            .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
509            .map(|d| d.as_millis() as u64)
510            .max()
511            .unwrap_or(0)
512    }
513
514    /// How many conversations are still open. The badge on the phone.
515    pub fn count_open(&self) -> usize {
516        self.list().iter().filter(|c| c.status.open()).count()
517    }
518}
519
520/// Cross-process exclusive ownership of one chat's record, released on drop.
521/// See [`Chats::claim`], which this is returned by, and
522/// [`crate::queue::Claim`], which it mirrors.
523#[derive(Debug)]
524struct ChatClaim {
525    path: PathBuf,
526}
527
528impl Drop for ChatClaim {
529    fn drop(&mut self) {
530        let _ = std::fs::remove_file(&self.path);
531    }
532}
533
534/// Construct a conversation record in memory, without writing it anywhere.
535///
536/// Split out of [`open`] so a caller can claim [`crate::web::Ui::begin_turn`]
537/// on `chat.id` *before* the record is ever written to disk - `chat_post`
538/// does exactly that, because [`Chats::put`] is what makes the id visible to
539/// every other request (`GET /api/chats`, `POST /api/chats/{id}/say`), and a
540/// gap between "the file exists" and "the turn is claimed" is a window for
541/// `chat_say` to claim and record into an interview whose first turn never
542/// ran - see `chat_post`'s doc for the failure that produces.
543///
544/// `agent` is resolved by [`plan::pick`], the same policy `magi plan` uses: an
545/// explicit id wins and is an error rather than a fallback when it is not
546/// runnable, otherwise a `claude` seat, otherwise the first runnable agent in
547/// roster order. Called rather than copied, because two copies of a preference
548/// order drift and the copy that drifts is the one nobody reads.
549///
550/// `from` is the conversation this one was derived from, when the operator
551/// asked to continue an existing interview in a different repository (see
552/// [`derived_background`]). It is read, never written: the source chat's
553/// `status`, `turns` and `draft` are left exactly as they were.
554pub fn build(
555    cfg: &Config,
556    repo: PathBuf,
557    idea: &str,
558    agent: Option<&str>,
559    from: Option<&Chat>,
560) -> Result<Chat> {
561    let idea = idea.trim();
562    if idea.is_empty() {
563        bail!("an interview needs something to start from: say what you want to change");
564    }
565    // Absolute, because the daemon that eventually runs the filed task has its
566    // own working directory and a relative path would mean the wrong
567    // repository.
568    let repo = repo.canonicalize().unwrap_or(repo);
569    // The API's `agent` beats the config, the config beats the built-in order.
570    // On a phone there is no flag to pass, so `[roles] chatter` (falling back
571    // to `planner`, so an operator who never set `chatter` sees no change) is
572    // the only way an operator states who answers this conversation - kept
573    // separate from `planner` so the resident chat does not compete with a
574    // judge seat for the same account by default.
575    let want = agent
576        .or(cfg.roles.chatter.as_deref())
577        .or(cfg.roles.planner.as_deref());
578    let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
579
580    let now = Timestamp::now();
581    let id = new_id();
582    Ok(Chat {
583        schema: SCHEMA,
584        id,
585        repo,
586        from: from.map(|c| c.id.clone()),
587        agent: spec.id.clone(),
588        status: ChatStatus::Open,
589        turns: vec![Turn {
590            who: Who::Operator,
591            body: idea.to_owned(),
592            at: now,
593            // The idea box that opens an interview has no attachment path of
594            // its own - only the ongoing `chat-say` composer does, once a
595            // conversation (and therefore an `artifacts_of` id to hold
596            // uploads under) exists.
597            attachments: Vec::new(),
598        }],
599        draft: None,
600        task: None,
601        created_at: now,
602        updated_at: now,
603        seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
604    })
605}
606
607/// [`build`], then persist. No first agent turn is taken.
608///
609/// Convenient when there is nothing racing the write - [`start`] is the only
610/// caller - but `POST /api/chats` cannot use it: see [`build`]'s doc for why
611/// the claim has to land between construction and this function's own
612/// [`Chats::put`].
613pub fn open(
614    store: &Chats,
615    cfg: &Config,
616    repo: PathBuf,
617    idea: &str,
618    agent: Option<&str>,
619    from: Option<&Chat>,
620) -> Result<Chat> {
621    let mut chat = build(cfg, repo, idea, agent, from)?;
622    store.put(&mut chat)?;
623    Ok(chat)
624}
625
626/// Take the first agent turn of a conversation created by [`open`].
627///
628/// `from`, when given, must be the same source conversation `open` was called
629/// with - it is read again here rather than stashed on `chat` because the
630/// persisted record carries only the source's id, and the full record is
631/// what [`derived_background`] needs.
632pub async fn first_turn(
633    chat: &mut Chat,
634    store: &Chats,
635    cfg: &Config,
636    from: Option<&Chat>,
637) -> Result<()> {
638    let idea = chat
639        .turns
640        .first()
641        .map(|t| t.body.as_str())
642        .unwrap_or_default();
643    let mut prompt = briefing(idea, &chat.repo);
644    // The source's own attachments, resolved against *its* id - `derived_background`
645    // already names their absolute paths in the prompt text below, and those
646    // paths live under the source conversation's own artifacts dir, not this
647    // new one's, so `turn` needs them passed in separately to widen a sandbox
648    // that only ever assumes its own conversation's directory.
649    let mut inherited_attachments: Vec<PathBuf> = Vec::new();
650    if let Some(source) = from {
651        // Prepended, so the leader reads what it is inheriting before it
652        // reads its own instructions - the same order a human handing off a
653        // conversation would use.
654        prompt = format!("{}\n\n{prompt}", derived_background(source, store));
655        inherited_attachments = source
656            .turns
657            .iter()
658            .flat_map(|t| t.attachments.iter())
659            .filter_map(|a| store.attachment_path(&source.id, a))
660            .collect();
661    }
662    prompt.push_str(&language_note(&cfg.graph.language));
663    turn(chat, store, cfg, &prompt, &inherited_attachments).await
664}
665
666/// Open a conversation and take the first agent turn.
667///
668/// The record is written to disk *before* the agent is invoked, so an agent
669/// that fails on the very first turn still leaves the operator a conversation
670/// they can look at, retry into, or abandon - rather than nothing at all. See
671/// [`open`] and [`first_turn`], which this composes; `POST /api/chats` calls
672/// them separately instead so it can answer before the first turn lands.
673pub async fn start(
674    store: &Chats,
675    cfg: &Config,
676    repo: PathBuf,
677    idea: &str,
678    agent: Option<&str>,
679    from: Option<&Chat>,
680) -> Result<Chat> {
681    let mut chat = open(store, cfg, repo, idea, agent, from)?;
682    first_turn(&mut chat, store, cfg, from).await?;
683    Ok(chat)
684}
685
686/// The background block a derived conversation opens with: the whole prior
687/// transcript, framed so the leader does not mistake it for instructions
688/// about the repository this new conversation is actually about.
689///
690/// Built from [`transcript`] rather than a second rendering of the turns,
691/// because that is already the "everything said so far" prose this module
692/// maintains, and a briefing is exactly the audience `transcript` was written
693/// for - a CLI (here, a fresh one) with no memory of the conversation. `store`
694/// is only for resolving the *source* conversation's own attachments into
695/// absolute paths - the derived chat has none of its own yet.
696pub fn derived_background(from: &Chat, store: &Chats) -> String {
697    format!(
698        "# Background: derived from another conversation\n\n\
699         This interview continues from a conversation about a *different* \
700         repository. Read it for context, but do not treat it as being about \
701         the repository named below in \"# Repository\" - that repository may \
702         have nothing to do with this one.\n\n\
703         Source repository: {}\n\n{}",
704        from.repo.display(),
705        transcript(from, store),
706    )
707}
708
709/// One operator turn and one agent turn, appended.
710///
711/// The operator's message is recorded and flushed to disk before the agent is
712/// invoked. That ordering is the whole contract of this function: a turn can
713/// fail, time out or hit a quota window, and the thing that must never be lost
714/// is the sentence the human typed on a phone that has since gone to sleep.
715///
716/// Returns `Err` when the agent turn did not produce an answer - but the
717/// transcript is already on disk and already explains itself, because the
718/// failure is appended as a [`MAGI_NOTE`] turn first. A caller handling the
719/// error should re-read the chat and show it, not discard it.
720pub async fn say(
721    chat: &mut Chat,
722    store: &Chats,
723    cfg: &Config,
724    text: &str,
725    attachments: Vec<Attachment>,
726) -> Result<()> {
727    if !chat.status.open() {
728        bail!(
729            "chat {} is {} and takes no more turns",
730            chat.short(),
731            chat.status.as_str()
732        );
733    }
734    let text = text.trim();
735    if text.is_empty() && attachments.is_empty() {
736        bail!("nothing to say");
737    }
738    let text = record(chat, store, text, attachments)?;
739    turn(chat, store, cfg, &text, &[]).await
740}
741
742/// Append the operator's turn and flush it, without invoking anything.
743///
744/// Split out of [`say`] so a caller that answers the operator before the agent
745/// has replied can still promise the message is on disk. `POST /api/chats/{id}/say`
746/// does exactly that: holding an HTTP connection for the 23-to-90 seconds a
747/// real turn takes is a coin flip on a phone, and the browser reporting
748/// "Failed to fetch" while the server quietly finished the turn is the worst
749/// of both answers.
750///
751/// Returns the trimmed text, so the caller and the agent see the same string.
752///
753/// Re-reads the record under [`Chats::guard`] and [`Chats::claim`] rather
754/// than trusting the caller's copy of `chat`'s `status`: `web::chat_say`
755/// reads the chat, then awaits config discovery before calling this - a gap
756/// a concurrent `POST /api/chats/{id}/abandon`, or a `magi plan --abandon`
757/// running as its own process, can land in. Re-reading without both would
758/// only narrow that window, not close it - see [`crate::talk::record`],
759/// which this mirrors for the in-process half.
760///
761/// `attachments` may be non-empty while `text` is empty - a turn that is
762/// only images is a normal thing to send - but not both empty, the same rule
763/// this always enforced for text alone.
764pub fn record(
765    chat: &mut Chat,
766    store: &Chats,
767    text: &str,
768    attachments: Vec<Attachment>,
769) -> Result<String> {
770    let _guard = store.guard();
771    let _claim = store.claim(&chat.id)?;
772    let fresh = store
773        .get(&chat.id)
774        .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
775    chat.status = fresh.status;
776    if !chat.status.open() {
777        bail!(
778            "chat {} is {} and takes no more turns",
779            chat.short(),
780            chat.status.as_str()
781        );
782    }
783    let text = text.trim();
784    if text.is_empty() && attachments.is_empty() {
785        bail!("nothing to say");
786    }
787    chat.turns.push(Turn {
788        who: Who::Operator,
789        body: text.to_owned(),
790        at: Timestamp::now(),
791        attachments,
792    });
793    store.put(chat)?;
794    Ok(text.to_owned())
795}
796
797/// The agent's half of a turn: invoke, append, flush.
798///
799/// Pairs with [`record`]. `text` is the operator's message that this reply
800/// answers - the same string `record` returned, so the transcript and the
801/// prompt cannot disagree.
802pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
803    turn(chat, store, cfg, text, &[]).await
804}
805
806/// Invoke the interviewing agent once and append what it said.
807///
808/// `prompt` is only the new material. Whether that is enough depends on the
809/// CLI: [`agent::has_session`] answers honestly - it is `false` when sessions
810/// are switched off, before the first turn, or for a CLI that never reported an
811/// id back - and only then is the transcript prepended, because a model with no
812/// memory of the interview would otherwise answer the last sentence in a
813/// vacuum. When the CLI *can* resume, magi sends nothing extra: paying for the
814/// whole conversation on every message is the cost this design exists to avoid,
815/// and a magi-authored replay of history is also a second, divergent version of
816/// it.
817async fn turn(
818    chat: &mut Chat,
819    store: &Chats,
820    cfg: &Config,
821    prompt: &str,
822    inherited_attachments: &[PathBuf],
823) -> Result<()> {
824    let spec = cfg
825        .agents
826        .iter()
827        .find(|a| a.id == chat.agent)
828        .with_context(|| {
829            format!(
830                "chat {} was interviewed by agent `{}`, which is no longer in \
831                 the roster; restore it in magi.toml or start a new chat",
832                chat.short(),
833                chat.agent
834            )
835        })?;
836
837    let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
838    // The newest turn is always the operator message this call is answering
839    // - `record` (or `start`, for the very first turn) appended it before
840    // `turn` was ever called - so its own attachments are what belong at the
841    // end of *this* prompt, resuming or not.
842    let last_note = attachment_note(
843        store,
844        &chat.id,
845        chat.turns
846            .last()
847            .map_or(&[][..], |t| t.attachments.as_slice()),
848    );
849    let body = if resuming {
850        format!("{prompt}{last_note}")
851    } else {
852        format!("{}\n\n{prompt}{last_note}", transcript(chat, store))
853    };
854
855    // Every attachment this conversation has ever held, not only this
856    // turn's: a resumed session gets a fresh process every turn, so a CLI
857    // whose sandbox needs `--add-dir` (see `agent::build_command`) needs the
858    // grant again to open an image from three turns ago, even when nothing
859    // new was attached just now. Plus whatever the caller inherited from a
860    // *different* conversation - `first_turn` passes the source's own
861    // attachments here, since `derived_background` already named their paths
862    // in the prompt and a sandbox that only widens for `chat.id`'s own
863    // directory would leave those unreadable.
864    let attachment_paths: Vec<PathBuf> = chat
865        .turns
866        .iter()
867        .flat_map(|t| t.attachments.iter())
868        .filter_map(|a| store.attachment_path(&chat.id, a))
869        .chain(inherited_attachments.iter().cloned())
870        .collect();
871
872    let artifacts = store.artifacts_of(&chat.id);
873    let stem = format!("turn-{}", chat.seat.turns + 1);
874    let cache_dir = cfg.cache_dir();
875    let inv = Invocation {
876        cwd: &chat.repo,
877        prompt: &body,
878        timeout: turn_timeout(cfg),
879        // The interviewer writes a task file into its reply, never into the
880        // repository: the competing agents do the implementation, and a
881        // repository the planner has already edited makes their diffs
882        // unjudgeable.
883        allow_write: false,
884        sessions: cfg.graph.sessions,
885        artifacts: &artifacts,
886        stem: &stem,
887        run: &chat.id,
888        node: "chat",
889        cache_dir: cache_dir.as_deref(),
890        attachments: &attachment_paths,
891    };
892
893    let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
894    let note = |why: String| Turn {
895        who: Who::Agent,
896        body: format!("{MAGI_NOTE}{why}"),
897        at: Timestamp::now(),
898        attachments: Vec::new(),
899    };
900    let (reply, failure) = match outcome {
901        Err(e) => (
902            note(format!("could not run agent `{}`: {e}", chat.agent)),
903            Some(format!("could not run agent `{}`: {e}", chat.agent)),
904        ),
905        Ok(out) if out.quota_exhausted() => {
906            let reset = out
907                .quota
908                .as_ref()
909                .and_then(|q| q.reset.clone())
910                .map_or_else(String::new, |r| format!(" (resets {r})"));
911            let why = format!(
912                "agent `{}` is out of quota{reset}; your message is saved, so \
913                 say it again when the window reopens",
914                chat.agent
915            );
916            (note(why.clone()), Some(why))
917        }
918        Ok(out) if out.timed_out => {
919            let why = format!(
920                "agent `{}` did not answer within {}s; your message is saved",
921                chat.agent,
922                turn_timeout(cfg).as_secs()
923            );
924            (note(why.clone()), Some(why))
925        }
926        Ok(out) if !out.usable() => {
927            let why = format!(
928                "agent `{}` produced no answer (exit {}); your message is saved",
929                chat.agent,
930                out.exit_code
931                    .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
932            );
933            (note(why.clone()), Some(why))
934        }
935        Ok(out) => (
936            Turn {
937                who: Who::Agent,
938                body: out.text.trim().to_owned(),
939                at: Timestamp::now(),
940                attachments: Vec::new(),
941            },
942            None,
943        ),
944    };
945
946    // A reply carrying no fenced draft leaves the existing one alone. The agent
947    // asking one more follow-up question must not erase the task file it
948    // already wrote, which the operator may well be reading at that moment.
949    if let Some(draft) = extract_draft(&reply.body) {
950        chat.draft = Some(draft);
951    }
952
953    // A concurrent `abandon` or `file_draft` can have landed on disk while
954    // this turn - possibly minutes long - was in flight, from inside this
955    // same `magi web` or from a separate `magi plan --abandon` process. Read
956    // `status` and `task` back here, under the same guard and claim those two
957    // take, rather than trust the snapshot this call started with: finishing
958    // the turn on that stale snapshot would silently undo whichever of them
959    // got there first. See [`crate::talk::turn`]'s tail, which this mirrors
960    // for the in-process half.
961    let _guard = store.guard();
962    let _claim = store.claim(&chat.id)?;
963    let fresh = store
964        .get(&chat.id)
965        .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
966    chat.status = fresh.status;
967    chat.task = fresh.task;
968    chat.turns.push(reply);
969    store.put(chat)?;
970
971    match failure {
972        Some(why) => bail!("{why}"),
973        None => Ok(()),
974    }
975}
976
977/// Everything said so far, as prose, for a CLI that cannot resume.
978///
979/// Only reached when [`agent::has_session`] says the conversation cannot be
980/// continued on the CLI's side. It is a fallback and not the design: it re-pays
981/// for the history on every turn and it is magi's rendering of the
982/// conversation rather than the model's own.
983fn transcript(chat: &Chat, store: &Chats) -> String {
984    let mut out = String::from(
985        "You are mid-interview. This CLI cannot resume its own conversation, \
986         so here is everything said so far; answer only the last message.\n",
987    );
988    for t in &chat.turns {
989        let who = match t.who {
990            Who::Operator => "operator",
991            Who::Agent => "you",
992        };
993        out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
994        out.push_str(&attachment_note(store, &chat.id, &t.attachments));
995    }
996    out
997}
998
999/// The section named at the end of a turn's body, listing every attachment's
1000/// absolute path and mime so the agent knows exactly what to open - see the
1001/// module doc on `Invocation::attachments`. Empty when `attachments` is,
1002/// which is every turn but the rare one carrying an image, so a turn with
1003/// none changes nothing about the prompt.
1004fn attachment_note(store: &Chats, chat_id: &str, attachments: &[Attachment]) -> String {
1005    if attachments.is_empty() {
1006        return String::new();
1007    }
1008    let mut out = String::from(
1009        "\n\nThe operator attached the image(s) below to this message. Open \
1010         and look at each one before you answer.\n",
1011    );
1012    for att in attachments {
1013        if let Some(path) = store.attachment_path(chat_id, att) {
1014            out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
1015        }
1016    }
1017    out.push('\n');
1018    out
1019}
1020
1021/// Validate the draft with [`plan::review_draft`] and queue it.
1022///
1023/// Returns the queued task's id. The conversation is left on disk either way:
1024/// a refused draft is a conversation to continue, not an error to recover
1025/// from, and the operator's next message can ask for the missing section.
1026///
1027/// Re-reads the record under [`Chats::guard`] and [`Chats::claim`] rather
1028/// than trusting the caller's copy of `chat`, and writes that fresh copy back
1029/// rather than the one passed in - the same reason [`abandon`] does, and for
1030/// the same concurrent-writer risk: without the shared guard *and* claim, an
1031/// `abandon` landing between this call's own read and its `put` - whether
1032/// from this same `magi web` or from a separate `magi plan --abandon`
1033/// process, which the in-process guard alone cannot see - would either be
1034/// clobbered by this call filing over it, or - the other order - have this
1035/// call's queued task silently orphaned from the record when `abandon`
1036/// writes over it. Refuses a conversation that is no longer `Open` by the
1037/// time this runs, in the same style [`say`] and [`record`] already use.
1038pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
1039    let _guard = store.guard();
1040    let _claim = store.claim(&chat.id)?;
1041    let mut fresh = store
1042        .get(&chat.id)
1043        .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
1044    if !fresh.status.open() {
1045        bail!(
1046            "chat {} is {} and takes no more turns",
1047            fresh.short(),
1048            fresh.status.as_str()
1049        );
1050    }
1051    if let Err(problems) = draft_problems(&fresh) {
1052        bail!(
1053            "this draft is not fileable yet:\n- {}",
1054            problems.join("\n- ")
1055        );
1056    }
1057    let body = fresh
1058        .draft
1059        .clone()
1060        .expect("draft_problems accepted a chat with a draft");
1061
1062    // `title_from` rather than a title the agent was asked to supply
1063    // separately: the task file's first line already is the title, and asking
1064    // for it twice is how the two come to disagree.
1065    let title = queue::title_from(&body, 72);
1066    // `Human`, not `Agent`: the agent conducted the interview, but the change
1067    // being asked for is the operator's, and "who asked for this" is the
1068    // question `source` exists to answer.
1069    let mut task = Task::new(title, body, fresh.repo.clone(), Source::Human);
1070    task.priority = priority;
1071    queue.put(&mut task)?;
1072
1073    fresh.task = Some(task.id.clone());
1074    fresh.status = ChatStatus::Filed;
1075    store.put(&mut fresh)?;
1076    *chat = fresh;
1077    Ok(task.id)
1078}
1079
1080/// Give up on a conversation. Idempotent: abandoning an already-abandoned
1081/// conversation is not an error, since the operator's intent - "I don't want
1082/// this anymore" - is already satisfied. Refuses a `Filed` conversation: that
1083/// status means a task was already produced from this interview, and abandon
1084/// must not roll back the terminal state that produced it.
1085///
1086/// Re-reads the record under [`Chats::guard`] rather than trusting the
1087/// caller's copy of `chat`, and writes that fresh copy back rather than the
1088/// one passed in - the same reason [`crate::talk::close`] does: a concurrent
1089/// [`say`] or [`file_draft`] must not have its result overwritten by a
1090/// decision made against a stale snapshot's idea of what `status` was, and the
1091/// guard is what stops that snapshot from being made stale *again* between
1092/// this call's own re-read and its `put`.
1093///
1094/// Also takes [`Chats::claim`], because unlike [`say`] and [`file_draft`] this
1095/// function is reachable from `magi plan --abandon` - a separate CLI process,
1096/// with its own `Arc<Mutex<()>>` that `store.guard()` shares with nothing
1097/// `magi web` holds. The claim is a `create_new` file next to the record
1098/// instead, which every process asking for it sees, so a chat cannot be
1099/// abandoned here at the exact moment `magi web` is filing or answering it.
1100pub fn abandon(chat: &mut Chat, store: &Chats) -> Result<()> {
1101    let _guard = store.guard();
1102    let _claim = store.claim(&chat.id)?;
1103    let mut fresh = store
1104        .get(&chat.id)
1105        .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
1106    match fresh.status {
1107        ChatStatus::Open => {
1108            fresh.status = ChatStatus::Abandoned;
1109            store.put(&mut fresh)?;
1110        }
1111        ChatStatus::Abandoned => {}
1112        ChatStatus::Filed => bail!(
1113            "chat {} is {} and takes no more turns",
1114            fresh.short(),
1115            fresh.status.as_str()
1116        ),
1117    }
1118    *chat = fresh;
1119    Ok(())
1120}
1121
1122/// Is this conversation's draft fileable, and if not, what is wrong with it?
1123///
1124/// Every problem is returned, not the first: an operator about to ask the agent
1125/// for a fix wants the whole list, and a validator that reveals one defect per
1126/// round turns one follow-up message into three.
1127///
1128/// [`plan::SHORT_DRAFT`] alone does not refuse. Length is a smell, not a
1129/// defect, and a genuinely small change deserves a small task file - which is
1130/// exactly the judgement `magi plan` makes, so the browser path makes it too.
1131/// It is still reported, because a two-line draft is usually an interview that
1132/// ended early.
1133pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
1134    let Some(body) = chat.draft.as_deref() else {
1135        return Err(vec![
1136            "this chat has no draft yet: the agent has not written a task file".to_owned(),
1137        ]);
1138    };
1139    match plan::review_draft(body) {
1140        Ok(()) => Ok(()),
1141        Err(problems) => {
1142            if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
1143                Ok(())
1144            } else {
1145                Err(problems)
1146            }
1147        }
1148    }
1149}
1150
1151/// The briefing the agent is opened with.
1152///
1153/// Pure, so the one property that matters can be asserted without an
1154/// interview: it carries [`plan::TASK_FILE_SPEC`] verbatim. The spec and
1155/// [`plan::review_draft`] are checked against each other by `plan`'s own tests,
1156/// so including it here is what keeps this path from asking for a shape the
1157/// validator will refuse - a twenty-message interview rejected for a reason the
1158/// operator was never told is the worst outcome this module has.
1159///
1160/// The output contract is the other half. `magi plan` tells the agent to write
1161/// a file, which works because that agent has a terminal and a filesystem the
1162/// operator is watching. Here the reply *is* the channel: the task file comes
1163/// back inside a fenced block tagged `task`, and [`extract_draft`] is the only
1164/// thing that reads it.
1165pub fn briefing(idea: &str, repo: &Path) -> String {
1166    format!(
1167        "You are the planning leader for magi, which runs a blind \
1168         multi-agent implementation competition: several agents will implement \
1169         the task file you write, in isolated worktrees, unaware of each other, \
1170         and judges will rank the results without knowing who wrote what.\n\n\
1171         Your job is not to implement anything. It is to interview the operator \
1172         until the change is pinned down, and then write one task file.\n\n\
1173         The operator is on a phone. Every message you send is read on a small \
1174         screen, so keep it short: no preamble, no restating what they just \
1175         said.\n\n\
1176         # Repository\n\n{repo}\n\n\
1177         Read it before you start asking. Questions the code already answers \
1178         spend the operator's patience for nothing. Do not modify it: the \
1179         competing agents do the implementation, and a repository you have \
1180         already edited makes their diffs unjudgeable.\n\n\
1181         # The idea\n\n{idea}\n\n\
1182         # How to run the interview\n\n\
1183         - Ask about what you cannot determine yourself: intent, scope, which \
1184         of several defensible designs the operator wants, what must not \
1185         change.\n\
1186         - Ask about ONE thing per message and wait for the answer. This is a \
1187         phone, not a form: a message with five questions in it gets one of \
1188         them answered.\n\
1189         - Do not produce the task file after one exchange.\n\
1190         - Disagree when you have grounds. A leader that agrees with everything \
1191         adds nothing to what the operator already typed.\n\
1192         - Confirm the plan in your own words and get an explicit yes before \
1193         writing.\n\n\
1194         # How to deliver the task file\n\n\
1195         When the operator agrees the plan is right, put the whole task file in \
1196         your reply inside a fenced block tagged `task`, like this:\n\n\
1197         ```task\n\
1198         # <the task file>\n\
1199         ```\n\n\
1200         Nothing else goes in that block, and there is exactly one of them per \
1201         message. magi extracts it and files it; a task file written to a file \
1202         on disk, or pasted without the fence, is one magi cannot see. You may \
1203         send a revised version later in the same conversation - the newest \
1204         `task` block wins - and while you are still asking questions, send no \
1205         `task` block at all.\n\n\
1206         magi will refuse a task file with no completion criteria, so those are \
1207         not optional.\n\n\
1208         # Task file specification\n\n{spec}",
1209        repo = repo.display(),
1210        spec = plan::TASK_FILE_SPEC,
1211    )
1212}
1213
1214/// The interview is the operator talking, so their language matters more here
1215/// than in any prompt the graph sends: an agent that answers a Japanese
1216/// question in English makes the conversation slower for exactly the person
1217/// magi is trying to help.
1218fn language_note(language: &str) -> String {
1219    if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
1220        String::new()
1221    } else {
1222        format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
1223    }
1224}
1225
1226/// Pull the task draft out of an agent reply, if it wrote one.
1227///
1228/// The *last* fenced `task` block, not the first. A conversation revises: an
1229/// agent that rewrites the task file after one more answer sends both versions
1230/// over the course of the interview, and within one message it may quote what
1231/// it had before changing it. The newest block is the one the operator has been
1232/// reading and the one they are about to approve.
1233///
1234/// Blocks tagged anything else - ```` ```rust ````, ```` ```json ```` - are
1235/// ignored, so an agent illustrating its plan with code does not overwrite the
1236/// draft with a snippet. An unterminated block is still taken: a reply cut off
1237/// mid-draft is worth showing the operator, who can then just ask for it again.
1238pub fn extract_draft(reply: &str) -> Option<String> {
1239    let mut last: Option<String> = None;
1240    let mut open: Option<(usize, Vec<&str>)> = None;
1241    for line in reply.lines() {
1242        let trimmed = line.trim_start();
1243        // Backticks are one byte each, so the count is also the byte offset of
1244        // the info string.
1245        let ticks = trimmed.chars().take_while(|c| *c == '`').count();
1246        match &mut open {
1247            Some((width, body)) => {
1248                if ticks >= *width && trimmed[ticks..].trim().is_empty() {
1249                    last = Some(joined(body));
1250                    open = None;
1251                } else {
1252                    body.push(line);
1253                }
1254            }
1255            None => {
1256                if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
1257                    open = Some((ticks, Vec::new()));
1258                }
1259            }
1260        }
1261    }
1262    if let Some((_, body)) = open {
1263        last = Some(joined(&body));
1264    }
1265    last.filter(|s| !s.trim().is_empty())
1266}
1267
1268/// A fenced block's lines as one document, newline-terminated the way a file
1269/// would be, because [`plan::review_draft`] reads it as a task file.
1270fn joined(lines: &[&str]) -> String {
1271    if lines.is_empty() {
1272        return String::new();
1273    }
1274    let mut out = lines.join("\n");
1275    out.push('\n');
1276    out
1277}
1278
1279fn read_path(path: &Path) -> Result<Chat> {
1280    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1281    serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
1282}
1283
1284fn short(id: &str) -> &str {
1285    id.split('-').next_back().unwrap_or(id)
1286}
1287
1288fn new_id() -> String {
1289    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
1290    let seed = crate::rng::entropy();
1291    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
1292}
1293
1294/// Extension an attachment's bytes are stored under, from its (already
1295/// validated) mime. The one place this mapping exists on the write side;
1296/// `web`'s own whitelist is what actually decides which mimes are accepted
1297/// in the first place.
1298fn attachment_ext(mime: &str) -> Option<&'static str> {
1299    match mime {
1300        "image/png" => Some("png"),
1301        "image/jpeg" => Some("jpg"),
1302        "image/gif" => Some("gif"),
1303        "image/webp" => Some("webp"),
1304        _ => None,
1305    }
1306}
1307
1308/// Is `id` a shape [`put_attachment`](Chats::put_attachment) could have
1309/// produced? 32 lowercase hex digits and nothing else, checked before an id
1310/// that came from the client is ever allowed to build a path - so `..` and a
1311/// path separator are never even possible.
1312pub fn valid_attachment_id(id: &str) -> bool {
1313    id.len() == 32
1314        && id
1315            .bytes()
1316            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1317}
1318
1319/// A fresh attachment id: 128 bits of process entropy as lowercase hex - the
1320/// same "mint it, never take it from the client" rule [`new_id`] follows for
1321/// conversation ids.
1322fn new_attachment_id() -> String {
1323    let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
1324    format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use std::collections::BTreeMap;
1330
1331    use crate::config::{AgentKind, AgentSpec, Graph};
1332
1333    use super::*;
1334
1335    /// A store of its own, with no process-global state - which is the point of
1336    /// [`Chats::at`], and why these can run in parallel.
1337    fn store() -> (tempfile::TempDir, Chats) {
1338        let tmp = tempfile::tempdir().expect("tempdir");
1339        let chats = Chats::at(tmp.path().join("chats"));
1340        (tmp, chats)
1341    }
1342
1343    /// A task file of the shape [`plan::TASK_FILE_SPEC`] describes, long enough
1344    /// that length is not one of the problems under test.
1345    fn good_draft() -> String {
1346        "# Report per-node durations in `magi show`\n\
1347         \n\
1348         ## Context\n\
1349         \n\
1350         `magi show` prints a run's nodes but not how long any of them took, so \
1351         the operator cannot see which seat is expensive. The data is already \
1352         in `run.events`.\n\
1353         \n\
1354         ## Change\n\
1355         \n\
1356         Add a duration column to the node table in `src/report.rs`.\n\
1357         \n\
1358         ## Constraints\n\
1359         \n\
1360         Do not change the JSON shape of a run record.\n\
1361         \n\
1362         ## Completion criteria\n\
1363         \n\
1364         - [ ] `magi show <run>` prints a duration for every completed node.\n\
1365         - [ ] A node with no end event prints nothing rather than zero.\n\
1366         \n\
1367         ## Out of scope\n\
1368         \n\
1369         The TUI's detail pane.\n"
1370            .to_owned()
1371    }
1372
1373    /// A `kind = "command"` agent whose whole behaviour is a POSIX shell
1374    /// script. No test in this module may spawn a real agent CLI: they are the
1375    /// operator's paid subscriptions, they reach the network, and they are not
1376    /// installed on CI.
1377    fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
1378        let path = dir.join("mock-chat-agent.sh");
1379        std::fs::write(&path, script).expect("write mock");
1380        AgentSpec {
1381            id: "mock".to_owned(),
1382            kind: AgentKind::Command,
1383            model: None,
1384            command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1385            extra_args: Vec::new(),
1386            env,
1387            prompt_delivery: None,
1388        }
1389    }
1390
1391    /// A config whose only agent is `spec`, with the graph left at its
1392    /// defaults except for the language, so `language_note` stays out of the
1393    /// prompt assertions.
1394    fn config(spec: AgentSpec) -> Config {
1395        Config {
1396            agents: vec![spec],
1397            graph: Graph {
1398                language: "en".to_owned(),
1399                ..Graph::default()
1400            },
1401            ..Config::default()
1402        }
1403    }
1404
1405    /// Echo a canned reply, ignoring the prompt on stdin.
1406    const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1407
1408    /// Say nothing and fail, the way a CLI that cannot start does.
1409    const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1410
1411    /// Reply with the prompt it was given, so a test can inspect exactly what
1412    /// the leader received on stdin.
1413    const ECHO: &str = "#!/bin/sh\ncat\n";
1414
1415    fn env(reply: &str) -> BTreeMap<String, String> {
1416        BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
1417    }
1418
1419    #[test]
1420    fn the_frozen_json_field_names_round_trip_through_disk() {
1421        let (tmp, chats) = store();
1422        let mut chat = Chat {
1423            schema: SCHEMA,
1424            id: "20260903-014455-ab12".to_owned(),
1425            repo: tmp.path().to_owned(),
1426            from: None,
1427            agent: "sonnet".to_owned(),
1428            status: ChatStatus::Open,
1429            turns: vec![Turn {
1430                who: Who::Operator,
1431                body: "rework the config loader".to_owned(),
1432                at: Timestamp::now(),
1433                attachments: Vec::new(),
1434            }],
1435            draft: None,
1436            task: None,
1437            created_at: Timestamp::now(),
1438            updated_at: Timestamp::now(),
1439            seat: SeatState::new(SEAT, "sonnet", 7),
1440        };
1441        chats.put(&mut chat).expect("put");
1442
1443        // Asserted literally, against the text on disk. The web UI is written
1444        // against these names by hand, so a rename that only round-trips
1445        // through serde would break the phone silently.
1446        let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
1447        let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1448        for field in [
1449            "schema",
1450            "id",
1451            "repo",
1452            "from",
1453            "agent",
1454            "status",
1455            "turns",
1456            "draft",
1457            "task",
1458            "created_at",
1459            "updated_at",
1460        ] {
1461            assert!(v.get(field).is_some(), "missing field `{field}`");
1462        }
1463        assert_eq!(v["schema"], 1);
1464        assert_eq!(v["status"], "open");
1465        assert_eq!(v["turns"][0]["who"], "operator");
1466        assert_eq!(v["turns"][0]["body"], "rework the config loader");
1467        assert!(v["turns"][0].get("at").is_some());
1468        assert!(v["turns"][0].get("attachments").is_some());
1469        assert!(v["draft"].is_null());
1470        assert!(v["task"].is_null());
1471        assert!(v["from"].is_null());
1472
1473        let back = chats.get(&chat.id).expect("get");
1474        assert_eq!(back.id, chat.id);
1475        assert_eq!(back.turns, chat.turns);
1476        assert_eq!(back.status, ChatStatus::Open);
1477        assert_eq!(back.from, None);
1478    }
1479
1480    /// A conversation recorded before `from` existed must still read: the
1481    /// `#[serde(deny_unknown_fields)]` on [`Chat`] would otherwise make this
1482    /// field's addition a breaking change for every chat already on disk.
1483    #[test]
1484    fn a_chat_recorded_without_a_from_field_still_reads() {
1485        let (tmp, chats) = store();
1486        let path = chats.path_of("20260903-014455-ab12");
1487        std::fs::create_dir_all(chats.root()).expect("chats dir");
1488        std::fs::write(
1489            &path,
1490            serde_json::json!({
1491                "schema": SCHEMA,
1492                "id": "20260903-014455-ab12",
1493                "repo": tmp.path(),
1494                "agent": "sonnet",
1495                "status": "open",
1496                "turns": [],
1497                "draft": null,
1498                "task": null,
1499                "created_at": Timestamp::now().to_string(),
1500                "updated_at": Timestamp::now().to_string(),
1501                "seat": SeatState::new(SEAT, "sonnet", 7),
1502            })
1503            .to_string(),
1504        )
1505        .expect("write pre-`from` chat");
1506
1507        let chat = chats.get("20260903-014455-ab12").expect("must still read");
1508        assert_eq!(chat.from, None);
1509    }
1510
1511    /// A conversation recorded before attachments existed - schema 1, no
1512    /// `attachments` key on any turn - must still read, the same guarantee
1513    /// `a_chat_recorded_without_a_from_field_still_reads` gives `from`.
1514    #[test]
1515    fn a_chat_recorded_without_attachments_still_reads() {
1516        let (tmp, chats) = store();
1517        let path = chats.path_of("20260903-014455-ab12");
1518        std::fs::create_dir_all(chats.root()).expect("chats dir");
1519        std::fs::write(
1520            &path,
1521            serde_json::json!({
1522                "schema": 1,
1523                "id": "20260903-014455-ab12",
1524                "repo": tmp.path(),
1525                "agent": "sonnet",
1526                "status": "open",
1527                "turns": [
1528                    { "who": "operator", "body": "rework the config loader",
1529                      "at": Timestamp::now().to_string() },
1530                ],
1531                "draft": null,
1532                "task": null,
1533                "created_at": Timestamp::now().to_string(),
1534                "updated_at": Timestamp::now().to_string(),
1535                "seat": SeatState::new(SEAT, "sonnet", 7),
1536            })
1537            .to_string(),
1538        )
1539        .expect("write pre-attachments chat");
1540
1541        let chat = chats.get("20260903-014455-ab12").expect("must still read");
1542        assert!(chat.turns[0].attachments.is_empty());
1543    }
1544
1545    #[test]
1546    fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1547        let (_tmp, chats) = store();
1548        let chat = Chat {
1549            schema: SCHEMA,
1550            id: "20260903-014455-ab12".to_owned(),
1551            repo: PathBuf::from("/repo/other"),
1552            from: None,
1553            agent: "sonnet".to_owned(),
1554            status: ChatStatus::Open,
1555            turns: vec![
1556                Turn {
1557                    who: Who::Operator,
1558                    body: "rework the queue drain".to_owned(),
1559                    at: Timestamp::now(),
1560                    attachments: Vec::new(),
1561                },
1562                Turn {
1563                    who: Who::Agent,
1564                    body: "which part of the drain?".to_owned(),
1565                    at: Timestamp::now(),
1566                    attachments: Vec::new(),
1567                },
1568            ],
1569            draft: None,
1570            task: None,
1571            created_at: Timestamp::now(),
1572            updated_at: Timestamp::now(),
1573            seat: SeatState::new(SEAT, "sonnet", 7),
1574        };
1575        let background = derived_background(&chat, &chats);
1576        assert!(background.contains("/repo/other"));
1577        assert!(background.contains("rework the queue drain"));
1578        assert!(background.contains("which part of the drain?"));
1579        assert!(background.contains("different"));
1580    }
1581
1582    #[tokio::test]
1583    async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1584        let (tmp, chats) = store();
1585        let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1586        let source_cfg = config(source_spec);
1587        let source = start(
1588            &chats,
1589            &source_cfg,
1590            tmp.path().to_owned(),
1591            "rework the queue drain",
1592            None,
1593            None,
1594        )
1595        .await
1596        .expect("start source");
1597        let before = source.clone();
1598
1599        let other_repo = tmp.path().join("other-repo");
1600        std::fs::create_dir_all(&other_repo).expect("other repo dir");
1601        // Overwrites the script `source_spec` pointed at: the source's own
1602        // turn already ran, so only the derived chat's invocation sees this.
1603        let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1604        let derived_cfg = config(echo_spec);
1605        let derived = start(
1606            &chats,
1607            &derived_cfg,
1608            other_repo,
1609            "same idea, different repository",
1610            None,
1611            Some(&source),
1612        )
1613        .await
1614        .expect("start derived");
1615
1616        assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1617
1618        let prompt = &derived.turns.last().expect("agent reply").body;
1619        assert!(prompt.contains("Background: derived from another conversation"));
1620        assert!(prompt.contains(&source.repo.display().to_string()));
1621        assert!(prompt.contains("rework the queue drain"));
1622        assert!(prompt.contains("same idea, different repository"));
1623
1624        // Deriving a chat must not touch the one it came from.
1625        let reread = chats.get(&source.id).expect("source still on disk");
1626        assert_eq!(reread.status, before.status);
1627        assert_eq!(reread.turns, before.turns);
1628        assert_eq!(reread.draft, before.draft);
1629    }
1630
1631    /// [`build`] must not write the record anywhere: `chat_post` claims
1632    /// [`crate::web::Ui::begin_turn`] on `chat.id` between calling this and
1633    /// persisting it, and that ordering only closes the race it exists for
1634    /// (see `chat_post`'s doc) if nothing observable exists yet for anyone
1635    /// else to resolve, claim or record into ahead of the claim.
1636    #[test]
1637    fn build_constructs_the_record_without_writing_it_anywhere() {
1638        let (tmp, chats) = store();
1639        let spec = mock_agent(tmp.path(), REPLY, BTreeMap::new());
1640        let cfg = config(spec);
1641
1642        let chat = build(
1643            &cfg,
1644            tmp.path().to_owned(),
1645            "rework the config loader",
1646            None,
1647            None,
1648        )
1649        .expect("build");
1650
1651        assert!(
1652            !chats.path_of(&chat.id).is_file(),
1653            "build must not touch the filesystem"
1654        );
1655        assert!(
1656            chats.list().is_empty(),
1657            "no record must be resolvable until something calls `Chats::put`"
1658        );
1659    }
1660
1661    /// `roles.chatter`, not `roles.planner`, decides who answers this
1662    /// conversation - and when `chatter` is unset, `planner` still does, so
1663    /// an operator who only ever named a planner sees no change. This is the
1664    /// distinction the resident chat's timeout under a triple-booked `opus`
1665    /// (planner, chatter, and a judge seat all at once) turned up.
1666    #[tokio::test]
1667    async fn a_chat_prefers_the_chatter_role_over_the_planner_role() {
1668        let (tmp, chats) = store();
1669        let planner_spec = mock_agent(tmp.path(), REPLY, env("from the planner"));
1670        let mut chatter_spec = mock_agent(tmp.path(), REPLY, env("from the chatter"));
1671        chatter_spec.id = "chatter-mock".to_owned();
1672
1673        let mut cfg = Config {
1674            agents: vec![planner_spec.clone(), chatter_spec.clone()],
1675            graph: Graph {
1676                language: "en".to_owned(),
1677                ..Graph::default()
1678            },
1679            ..Config::default()
1680        };
1681        cfg.roles.planner = Some(planner_spec.id.clone());
1682        cfg.roles.chatter = Some(chatter_spec.id.clone());
1683
1684        let chat = start(
1685            &chats,
1686            &cfg,
1687            tmp.path().to_owned(),
1688            "rework the drain",
1689            None,
1690            None,
1691        )
1692        .await
1693        .expect("start with chatter set");
1694        assert_eq!(chat.agent, chatter_spec.id, "chatter must win over planner");
1695
1696        cfg.roles.chatter = None;
1697        let fallback = start(
1698            &chats,
1699            &cfg,
1700            tmp.path().to_owned(),
1701            "rework the drain again",
1702            None,
1703            None,
1704        )
1705        .await
1706        .expect("start with chatter unset");
1707        assert_eq!(
1708            fallback.agent, planner_spec.id,
1709            "unset chatter must fall back to planner, unchanged from before this role existed"
1710        );
1711    }
1712
1713    #[test]
1714    fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1715        let reply = "here is a sketch\n\
1716                     \n\
1717                     ```rust\n\
1718                     fn not_the_draft() {}\n\
1719                     ```\n\
1720                     \n\
1721                     ```task\n\
1722                     # first version\n\
1723                     ```\n\
1724                     \n\
1725                     ```json\n\
1726                     {\"also\": \"not it\"}\n\
1727                     ```\n\
1728                     \n\
1729                     revised:\n\
1730                     \n\
1731                     ```task\n\
1732                     # second version\n\
1733                     ## Completion criteria\n\
1734                     ```\n";
1735        assert_eq!(
1736            extract_draft(reply).as_deref(),
1737            Some("# second version\n## Completion criteria\n")
1738        );
1739    }
1740
1741    #[test]
1742    fn extract_draft_returns_none_when_there_is_no_task_block() {
1743        assert_eq!(extract_draft("which storage backend do you want?"), None);
1744        assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1745        // An empty block is not a draft: filing it would produce a task with
1746        // nothing in it.
1747        assert_eq!(extract_draft("```task\n```\n"), None);
1748    }
1749
1750    #[tokio::test]
1751    async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1752        let (tmp, chats) = store();
1753        let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1754        let cfg = config(spec);
1755        let mut chat = start(
1756            &chats,
1757            &cfg,
1758            tmp.path().to_owned(),
1759            "add durations",
1760            None,
1761            None,
1762        )
1763        .await
1764        .expect("start");
1765        chat.draft = Some(good_draft());
1766        chats.put(&mut chat).expect("put");
1767
1768        say(&mut chat, &chats, &cfg, "the report module", Vec::new())
1769            .await
1770            .expect("say");
1771
1772        assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1773        assert_eq!(
1774            chats.get(&chat.id).expect("get").draft.as_deref(),
1775            Some(good_draft().as_str())
1776        );
1777    }
1778
1779    #[test]
1780    fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1781        let brief = briefing("rework the config loader", Path::new("/repo"));
1782        // The spec verbatim, so the shape asked for cannot drift from the shape
1783        // `plan::review_draft` enforces.
1784        assert!(brief.contains(plan::TASK_FILE_SPEC));
1785        assert!(brief.contains("```task"));
1786        assert!(brief.contains("rework the config loader"));
1787        assert!(brief.contains("/repo"));
1788        assert!(brief.contains("completion criteria"));
1789    }
1790
1791    #[test]
1792    fn file_draft_refuses_a_bad_draft_with_every_problem() {
1793        let (tmp, chats) = store();
1794        let queue = Queue::at(tmp.path().join("queue"));
1795        let mut chat = Chat {
1796            schema: SCHEMA,
1797            id: "20260903-014455-ab12".to_owned(),
1798            repo: tmp.path().to_owned(),
1799            from: None,
1800            agent: "mock".to_owned(),
1801            status: ChatStatus::Open,
1802            turns: Vec::new(),
1803            // Short *and* missing completion criteria: both must be reported,
1804            // or the operator asks for one fix and gets refused again.
1805            draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1806            task: None,
1807            created_at: Timestamp::now(),
1808            updated_at: Timestamp::now(),
1809            seat: SeatState::new(SEAT, "mock", 7),
1810        };
1811        // `file_draft` re-reads its record from disk (see its own doc), so a
1812        // chat that only ever exists in memory in this test would fail with
1813        // "no chat matches" rather than the draft error under test.
1814        chats.put(&mut chat).expect("put");
1815
1816        let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1817        assert!(
1818            problems.len() >= 2,
1819            "expected every problem, got {problems:?}"
1820        );
1821        assert!(problems.iter().any(|p| p.contains("completion criteria")));
1822        assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1823
1824        let err = file_draft(&mut chat, &chats, &queue, 0)
1825            .expect_err("file_draft must refuse it too")
1826            .to_string();
1827        for p in &problems {
1828            assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1829        }
1830        assert_eq!(chat.status, ChatStatus::Open);
1831        assert!(chat.task.is_none());
1832        assert!(queue.list().is_empty());
1833    }
1834
1835    #[test]
1836    fn file_draft_queues_a_good_draft_and_records_the_task() {
1837        let (tmp, chats) = store();
1838        let queue = Queue::at(tmp.path().join("queue"));
1839        let mut chat = Chat {
1840            schema: SCHEMA,
1841            id: "20260903-014455-cd34".to_owned(),
1842            repo: tmp.path().to_owned(),
1843            from: None,
1844            agent: "mock".to_owned(),
1845            status: ChatStatus::Open,
1846            turns: Vec::new(),
1847            draft: Some(good_draft()),
1848            task: None,
1849            created_at: Timestamp::now(),
1850            updated_at: Timestamp::now(),
1851            seat: SeatState::new(SEAT, "mock", 7),
1852        };
1853        // See the sibling test above for why this has to be on disk before
1854        // `file_draft` (which re-reads it) is called.
1855        chats.put(&mut chat).expect("put");
1856
1857        let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1858
1859        assert_eq!(chat.status, ChatStatus::Filed);
1860        assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1861        assert_eq!(
1862            chats.get(&chat.id).expect("get").task.as_deref(),
1863            Some(id.as_str()),
1864            "the task id must survive on disk, or the phone shows an unfiled chat"
1865        );
1866
1867        let task = queue.get(&id).expect("queued task");
1868        assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1869        assert_eq!(task.instruction, good_draft());
1870        assert_eq!(task.priority, 5);
1871        assert_eq!(task.source, Source::Human);
1872    }
1873
1874    #[test]
1875    fn abandon_moves_an_open_chat_to_abandoned() {
1876        let (tmp, chats) = store();
1877        let mut chat = Chat {
1878            schema: SCHEMA,
1879            id: "20260903-014455-ab12".to_owned(),
1880            repo: tmp.path().to_owned(),
1881            from: None,
1882            agent: "mock".to_owned(),
1883            status: ChatStatus::Open,
1884            turns: Vec::new(),
1885            draft: None,
1886            task: None,
1887            created_at: Timestamp::now(),
1888            updated_at: Timestamp::now(),
1889            seat: SeatState::new(SEAT, "mock", 7),
1890        };
1891        chats.put(&mut chat).expect("put");
1892
1893        abandon(&mut chat, &chats).expect("abandon");
1894
1895        assert_eq!(chat.status, ChatStatus::Abandoned);
1896        assert_eq!(
1897            chats.get(&chat.id).expect("get").status,
1898            ChatStatus::Abandoned
1899        );
1900    }
1901
1902    #[test]
1903    fn abandoning_an_already_abandoned_chat_is_not_an_error() {
1904        let (tmp, chats) = store();
1905        let mut chat = Chat {
1906            schema: SCHEMA,
1907            id: "20260903-014455-ab13".to_owned(),
1908            repo: tmp.path().to_owned(),
1909            from: None,
1910            agent: "mock".to_owned(),
1911            status: ChatStatus::Abandoned,
1912            turns: Vec::new(),
1913            draft: None,
1914            task: None,
1915            created_at: Timestamp::now(),
1916            updated_at: Timestamp::now(),
1917            seat: SeatState::new(SEAT, "mock", 7),
1918        };
1919        chats.put(&mut chat).expect("put");
1920
1921        abandon(&mut chat, &chats).expect("abandoning twice is not an error");
1922
1923        assert_eq!(chat.status, ChatStatus::Abandoned);
1924        assert_eq!(
1925            chats.get(&chat.id).expect("get").status,
1926            ChatStatus::Abandoned
1927        );
1928    }
1929
1930    #[test]
1931    fn abandon_refuses_a_filed_chat_and_leaves_it_filed() {
1932        let (tmp, chats) = store();
1933        let mut chat = Chat {
1934            schema: SCHEMA,
1935            id: "20260903-014455-ab14".to_owned(),
1936            repo: tmp.path().to_owned(),
1937            from: None,
1938            agent: "mock".to_owned(),
1939            status: ChatStatus::Filed,
1940            turns: Vec::new(),
1941            draft: None,
1942            task: Some("some-task-id".to_owned()),
1943            created_at: Timestamp::now(),
1944            updated_at: Timestamp::now(),
1945            seat: SeatState::new(SEAT, "mock", 7),
1946        };
1947        chats.put(&mut chat).expect("put");
1948
1949        let err = abandon(&mut chat, &chats).expect_err("a filed chat refuses abandon");
1950        assert!(err.to_string().contains("filed"), "{err}");
1951
1952        assert_eq!(
1953            chats.get(&chat.id).expect("get").status,
1954            ChatStatus::Filed,
1955            "a refused abandon must not touch the on-disk status"
1956        );
1957    }
1958
1959    #[tokio::test]
1960    async fn an_abandon_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1961        let (tmp, chats) = store();
1962        let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1963        let cfg = config(spec);
1964        // The in-flight turn's own handle: loaded once, the way a spawned
1965        // background task in `web::chat_say` holds one for the whole turn.
1966        let mut in_flight = open(
1967            &chats,
1968            &cfg,
1969            tmp.path().to_owned(),
1970            "add durations",
1971            None,
1972            None,
1973        )
1974        .expect("open");
1975
1976        // The operator abandons the conversation through a *different* handle
1977        // while the turn above is still running - exactly what an abandon
1978        // typed on the phone while an agent is mid-answer looks like.
1979        let mut abandoned_elsewhere = chats.get(&in_flight.id).expect("reread");
1980        abandon(&mut abandoned_elsewhere, &chats).expect("abandon");
1981        assert_eq!(
1982            chats.get(&in_flight.id).expect("reread").status,
1983            ChatStatus::Abandoned,
1984            "the abandon landed on disk before the turn finished"
1985        );
1986
1987        // The turn's own handle still says `open` - it was loaded before the
1988        // abandon - and finishing it must not resurrect the conversation the
1989        // operator already ended.
1990        assert_eq!(in_flight.status, ChatStatus::Open);
1991        first_turn(&mut in_flight, &chats, &cfg, None)
1992            .await
1993            .expect("the turn itself still completes");
1994
1995        let on_disk = chats.get(&in_flight.id).expect("reread");
1996        assert_eq!(
1997            on_disk.status,
1998            ChatStatus::Abandoned,
1999            "an abandon must stick even when a turn that started before it finishes after it"
2000        );
2001        // The reply is not lost either: a turn already in flight when the
2002        // operator abandoned still gets its answer recorded.
2003        assert!(
2004            on_disk.turns.iter().any(|t| t.body == "here you go"),
2005            "the in-flight turn's own reply is still recorded: {:?}",
2006            on_disk.turns
2007        );
2008    }
2009
2010    #[test]
2011    fn abandon_blocks_on_the_shared_guard_rather_than_interleaving_with_a_racing_writer() {
2012        let (tmp, chats) = store();
2013        let queue = Queue::at(tmp.path().join("queue"));
2014        let mut chat = Chat {
2015            schema: SCHEMA,
2016            id: "20260903-014455-ee15".to_owned(),
2017            repo: tmp.path().to_owned(),
2018            from: None,
2019            agent: "mock".to_owned(),
2020            status: ChatStatus::Open,
2021            turns: Vec::new(),
2022            draft: Some(good_draft()),
2023            task: None,
2024            created_at: Timestamp::now(),
2025            updated_at: Timestamp::now(),
2026            seat: SeatState::new(SEAT, "mock", 7),
2027        };
2028        chats.put(&mut chat).expect("put");
2029
2030        // Hold the same guard `file_draft`'s read-modify-write section holds
2031        // for the whole of its own read-then-write, standing in for
2032        // `file_draft` being paused between its read and its `put`.
2033        let held = chats.guard();
2034
2035        let chats2 = chats.clone();
2036        let id = chat.id.clone();
2037        let abandoning = std::thread::spawn(move || {
2038            let mut chat = chats2.get(&id).expect("get");
2039            abandon(&mut chat, &chats2).expect("abandon");
2040        });
2041
2042        std::thread::sleep(Duration::from_millis(50));
2043        assert!(
2044            !abandoning.is_finished(),
2045            "abandon must wait for the guard, not read and write while it is held - \
2046             a re-read alone narrows this window without closing it"
2047        );
2048
2049        drop(held);
2050        abandoning.join().expect("abandon thread panicked");
2051
2052        assert_eq!(
2053            chats.get(&chat.id).expect("reread").status,
2054            ChatStatus::Abandoned,
2055            "once the guard is free, abandon still lands"
2056        );
2057        assert!(queue.list().is_empty(), "file_draft never ran in this test");
2058    }
2059
2060    /// The gap the in-process guard cannot see: `magi plan --abandon` opens
2061    /// its own `Chats`, with its own `Arc<Mutex<()>>` wired to nothing this
2062    /// process holds. Holding a claim directly - rather than the guard - is
2063    /// what stands in for that separate process here, since `Chats::claim` is
2064    /// a `create_new` file on disk, indistinguishable to `abandon` from a
2065    /// second `magi web` or a second `magi plan --abandon` already inside its
2066    /// own read-modify-write section.
2067    #[test]
2068    fn abandon_is_refused_while_another_process_holds_the_chats_claim() {
2069        let (tmp, chats) = store();
2070        let mut chat = Chat {
2071            schema: SCHEMA,
2072            id: "20260903-014455-ee16".to_owned(),
2073            repo: tmp.path().to_owned(),
2074            from: None,
2075            agent: "mock".to_owned(),
2076            status: ChatStatus::Open,
2077            turns: Vec::new(),
2078            draft: None,
2079            task: None,
2080            created_at: Timestamp::now(),
2081            updated_at: Timestamp::now(),
2082            seat: SeatState::new(SEAT, "mock", 7),
2083        };
2084        chats.put(&mut chat).expect("put");
2085
2086        let held = chats.claim(&chat.id).expect("claim");
2087        let err = abandon(&mut chat, &chats).expect_err("a claimed chat refuses abandon");
2088        assert!(err.to_string().contains("claimed"), "{err}");
2089        assert_eq!(
2090            chats.get(&chat.id).expect("reread").status,
2091            ChatStatus::Open,
2092            "a refused abandon must not touch the on-disk status"
2093        );
2094
2095        drop(held);
2096        abandon(&mut chat, &chats).expect("abandon succeeds once the claim is released");
2097        assert_eq!(chat.status, ChatStatus::Abandoned);
2098    }
2099
2100    #[tokio::test]
2101    async fn say_appends_the_operator_turn_then_the_agent_turn() {
2102        let (tmp, chats) = store();
2103        let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
2104        let cfg = config(spec);
2105        let mut chat = start(
2106            &chats,
2107            &cfg,
2108            tmp.path().to_owned(),
2109            "add durations",
2110            None,
2111            None,
2112        )
2113        .await
2114        .expect("start");
2115        // start is one operator turn (the idea) plus one agent turn.
2116        assert_eq!(chat.turns.len(), 2);
2117        assert_eq!(chat.turns[0].who, Who::Operator);
2118        assert_eq!(chat.turns[1].who, Who::Agent);
2119
2120        say(&mut chat, &chats, &cfg, "the report module", Vec::new())
2121            .await
2122            .expect("say");
2123
2124        assert_eq!(chat.turns.len(), 4);
2125        assert_eq!(chat.turns[2].who, Who::Operator);
2126        assert_eq!(chat.turns[2].body, "the report module");
2127        assert_eq!(chat.turns[3].who, Who::Agent);
2128        assert_eq!(chat.turns[3].body, "which module?");
2129        assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
2130    }
2131
2132    #[tokio::test]
2133    async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
2134        let (tmp, chats) = store();
2135        let good = mock_agent(tmp.path(), REPLY, env("which module?"));
2136        let cfg = config(good);
2137        let mut chat = start(
2138            &chats,
2139            &cfg,
2140            tmp.path().to_owned(),
2141            "add durations",
2142            None,
2143            None,
2144        )
2145        .await
2146        .expect("start");
2147
2148        // The chat is bound to roster agent `mock`, so break what `mock`
2149        // actually runs: `mock_agent` rewrites the same script path, which is
2150        // what it looks like when that CLI stops working mid-interview.
2151        mock_agent(tmp.path(), BROKEN, BTreeMap::new());
2152        let err = say(&mut chat, &chats, &cfg, "the report module", Vec::new())
2153            .await
2154            .expect_err("a turn with no answer is an error");
2155        assert!(err.to_string().contains("no answer"), "{err}");
2156
2157        let on_disk = chats.get(&chat.id).expect("get");
2158        assert_eq!(on_disk.turns.len(), 4);
2159        assert_eq!(
2160            on_disk.turns[2].body, "the report module",
2161            "the operator's message must survive the failure"
2162        );
2163        let note = &on_disk.turns[3];
2164        assert_eq!(note.who, Who::Agent);
2165        assert!(
2166            note.body.starts_with(MAGI_NOTE),
2167            "the failure must be visible in the transcript: {}",
2168            note.body
2169        );
2170        assert!(note.body.contains("your message is saved"));
2171    }
2172
2173    /// An attachment lets the operator send an otherwise-empty message, and
2174    /// its absolute path (never the id or the operator's own filename alone)
2175    /// is what actually reaches the agent's prompt - the whole point of
2176    /// `Invocation::attachments` and the note `turn` appends.
2177    #[tokio::test]
2178    async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
2179        let (tmp, chats) = store();
2180        let spec = mock_agent(tmp.path(), REPLY, env("first reply"));
2181        let cfg = config(spec);
2182        let mut chat = start(
2183            &chats,
2184            &cfg,
2185            tmp.path().to_owned(),
2186            "add durations",
2187            None,
2188            None,
2189        )
2190        .await
2191        .expect("start");
2192
2193        let att = chats
2194            .put_attachment(
2195                &chat.id,
2196                "image/png",
2197                "screenshot.png",
2198                b"pretend-png-bytes",
2199            )
2200            .expect("put attachment");
2201
2202        // Overwrites the script `mock_agent` above pointed at, the same trick
2203        // `starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched`
2204        // uses: the reply becomes whatever the agent received on stdin.
2205        mock_agent(tmp.path(), ECHO, BTreeMap::new());
2206        say(&mut chat, &chats, &cfg, "", vec![att.clone()])
2207            .await
2208            .expect("an empty body with an attachment is still a turn");
2209
2210        let operator_turn = &chat.turns[chat.turns.len() - 2];
2211        assert_eq!(operator_turn.who, Who::Operator);
2212        assert_eq!(operator_turn.body, "");
2213        assert_eq!(operator_turn.attachments, vec![att.clone()]);
2214
2215        let prompt = &chat.turns.last().expect("agent reply").body;
2216        let expected_path = chats
2217            .attachments_dir(&chat.id)
2218            .join(format!("{}.png", att.id));
2219        assert!(
2220            prompt.contains(&expected_path.display().to_string()),
2221            "the agent must be told the attachment's absolute path: {prompt}"
2222        );
2223        assert!(prompt.contains("image/png"), "and its mime: {prompt}");
2224    }
2225
2226    /// `run::home()` returns a bare relative `PathBuf` verbatim when the
2227    /// operator sets `MAGI_HOME` to a relative path - nothing canonicalizes
2228    /// it - so a `Chats` store built on top of it has a relative `root` too.
2229    /// That is harmless for this store's own reads and writes, which run in
2230    /// this same process against this process's cwd, but `attachment_path`
2231    /// hands its result to a *different* process invoked with `cwd:
2232    /// &chat.repo`: an uncorrected relative path would resolve against the
2233    /// repository instead of wherever the attachment actually landed, and
2234    /// the CLI would find nothing there.
2235    #[test]
2236    fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
2237        let chats = Chats::at(PathBuf::from("relative-chats-root-for-this-test"));
2238        let att = Attachment {
2239            id: "0".repeat(32),
2240            name: "shot.png".to_owned(),
2241            mime: "image/png".to_owned(),
2242            bytes: 3,
2243        };
2244        let path = chats
2245            .attachment_path("some-chat-id", &att)
2246            .expect("a supported mime always yields a path");
2247        assert!(
2248            path.is_absolute(),
2249            "must be absolute even off a relative store root: {}",
2250            path.display()
2251        );
2252    }
2253
2254    #[tokio::test]
2255    async fn a_turn_past_the_configured_chat_timeout_is_reported_with_that_timeout() {
2256        // `[graph] timeout_chat` must be the number this module actually
2257        // waits, not a leftover hardcoded five minutes - so the mock sleeps
2258        // past a deliberately tiny override and the failure note is checked
2259        // against that same override, not the old default.
2260        let (tmp, chats) = store();
2261        let slow = mock_agent(
2262            tmp.path(),
2263            "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
2264            BTreeMap::new(),
2265        );
2266        let mut cfg = config(slow);
2267        cfg.graph.timeout_chat = 1;
2268
2269        let err = start(
2270            &chats,
2271            &cfg,
2272            tmp.path().to_owned(),
2273            "add durations",
2274            None,
2275            None,
2276        )
2277        .await
2278        .expect_err("a turn that never answers is an error");
2279        assert!(
2280            err.to_string().contains("did not answer within 1s"),
2281            "{err}"
2282        );
2283
2284        let on_disk = chats.list();
2285        let chat = &on_disk[0];
2286        let note = chat.turns.last().expect("a note turn was recorded");
2287        assert!(
2288            note.body.contains("did not answer within 1s"),
2289            "the transcript must show the configured timeout: {}",
2290            note.body
2291        );
2292    }
2293
2294    #[test]
2295    fn list_puts_open_chats_before_filed_ones() {
2296        let (tmp, chats) = store();
2297        let make = |id: &str, status: ChatStatus| {
2298            let mut c = Chat {
2299                schema: SCHEMA,
2300                id: id.to_owned(),
2301                repo: tmp.path().to_owned(),
2302                from: None,
2303                agent: "mock".to_owned(),
2304                status,
2305                turns: Vec::new(),
2306                draft: None,
2307                task: None,
2308                created_at: Timestamp::now(),
2309                updated_at: Timestamp::now(),
2310                seat: SeatState::new(SEAT, "mock", 7),
2311            };
2312            chats.put(&mut c).expect("put");
2313        };
2314        // The filed one is newest, so ordering by id alone would put it first.
2315        make("20260901-000000-0001", ChatStatus::Open);
2316        make("20260902-000000-0002", ChatStatus::Open);
2317        make("20260903-000000-0003", ChatStatus::Filed);
2318
2319        let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
2320        assert_eq!(
2321            ids,
2322            [
2323                "20260902-000000-0002",
2324                "20260901-000000-0001",
2325                "20260903-000000-0003"
2326            ]
2327        );
2328        assert_eq!(chats.count_open(), 2);
2329    }
2330}