Skip to main content

magi/
talk.rs

1//! The standing conversation: a place to think out loud with an agent between
2//! tasks, reachable from a phone.
3//!
4//! This is a conversation that stays open. Ask a question, have the agent
5//! read a file or run a command to check something, talk through an idea,
6//! and when it is time to act, tell it to file the work rather than do it
7//! here. The conversation does not end; it is what the operator opens the
8//! next time something comes up.
9//!
10//! # Talking is not implementing
11//!
12//! Every turn here runs with `allow_write: false` by default, for a reason
13//! that is not security, but attribution. An agent that edits a checkout
14//! mid-conversation leaves a diff that belongs to no run and passed no
15//! review, and on a repository entered into magi's blind competition that
16//! makes every candidate's diff unjudgeable. That is why the default holds
17//! regardless of what a repository's own `magi.toml` says about anything
18//! else. When the operator wants a change made, the agent is told to run
19//! `magi task add --solo` ([`briefing`]) rather than reach for an editor: the
20//! change goes through magi's own queue, on the repository's own terms, and
21//! the operator can watch it happen instead of trusting that it did.
22//!
23//! `[talk] allow_write` ([`crate::config::Talk::allow_write`]) lets a
24//! specific repository opt out of that default - a dotfiles or personal
25//! config checkout that is never entered into a competition and never
26//! reviewed has nothing for the restriction to protect, and filing a task for
27//! a one-line edit there is pure overhead. Turning it on does not turn this
28//! conversation into an implementer: [`briefing`] still sends everything
29//! bigger than a small, operator-named edit to the queue, and still tells the
30//! agent to say what it changed.
31//!
32//! `--solo` rather than a plain `magi task add` is the point of pairing this
33//! module with [`crate::queue::Task::solo`]. A task that came out of a
34//! conversation the operator just had is a decision already made, not a
35//! design question worth three independent takes - so it runs through one
36//! implementer and straight into review, the way [`crate::graph::Runner`]
37//! already degrades a single-candidate run.
38//!
39//! # Shape
40//!
41//! The same split [`crate::queue`] uses: [`Talk`] is data plus pure helpers,
42//! [`Talks`] owns the I/O and is constructed with its root, so every test
43//! here drives a real store in a temp directory rather than the operator's
44//! own home.
45
46use std::path::{Path, PathBuf};
47use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
48use std::time::Duration;
49
50use anyhow::{Context, Result, bail};
51use jiff::Timestamp;
52use serde::{Deserialize, Serialize};
53
54use crate::agent::{self, Invocation, SeatState};
55use crate::config::Config;
56use crate::queue::{Queue, Source, Task};
57
58/// On-disk format for a conversation. Bumped when a field's meaning changes.
59pub const SCHEMA: u32 = 1;
60
61/// Wall-clock limit for one agent turn. See [`crate::config::Graph::timeout_talk`].
62///
63/// An hour by default. This turn is expected to run several shell commands
64/// and read their output before answering one - "what does this function
65/// do", "is this still true", "run the tests and tell me" - which argues for
66/// an hour rather than the five minutes a short budget once assumed, because
67/// the thing that made a short budget matter - an operator watching a
68/// spinner - is not how this conversation gets used: the operator moves on
69/// to something else while a turn runs and checks back later, so a long turn
70/// spends a held seat, not anyone's attention.
71fn turn_timeout(cfg: &Config) -> Duration {
72    Duration::from_secs(cfg.graph.timeout_talk)
73}
74
75/// Seat name for the conversation's agent, scoping its CLI-side session away
76/// from every other seat magi ever opens.
77const SEAT: &str = "talk";
78
79/// Prefix on a turn magi wrote rather than an agent.
80const MAGI_NOTE: &str = "magi: ";
81
82/// Who said something.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Who {
86    /// The operator.
87    Operator,
88    /// The conversation's agent - or magi itself, reporting that a turn
89    /// failed. See [`MAGI_NOTE`].
90    Agent,
91}
92
93/// One image the operator attached to a turn.
94///
95/// Never carries the bytes themselves: the picture lives on disk under
96/// [`Talks::attachments_dir`], named by `id` alone. `name` is the filename
97/// the operator's browser reported, kept only for display - it never
98/// contributes to a path, which is what keeps an upload from being able to
99/// traverse outside its own directory.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct Attachment {
103    /// Server-minted id; also the file's stem under `attachments_dir`.
104    pub id: String,
105    /// The operator's own filename, for display only.
106    pub name: String,
107    /// Validated by `web` at upload time against a closed whitelist:
108    /// `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
109    pub mime: String,
110    /// Size in bytes, so the phone can show it without a second request.
111    pub bytes: u64,
112}
113
114/// One message in the conversation.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct Turn {
118    /// Who wrote it.
119    pub who: Who,
120    /// What they said.
121    pub body: String,
122    /// When it was said.
123    pub at: Timestamp,
124    /// Images attached to this turn. `#[serde(default)]` so a conversation
125    /// recorded before attachments existed still reads.
126    #[serde(default)]
127    pub attachments: Vec<Attachment>,
128}
129
130/// Where a conversation is in its life: this conversation can file any
131/// number of tasks without ending, so it only ever moves once, from open to
132/// closed.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum TalkStatus {
136    /// Still open; the operator may say more, and may have already filed work
137    /// out of it.
138    Open,
139    /// Closed by hand. Kept on disk as a record.
140    Closed,
141}
142
143impl TalkStatus {
144    /// Is this conversation still live?
145    pub fn open(self) -> bool {
146        matches!(self, Self::Open)
147    }
148
149    /// Wire form, for the phone and for logs.
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Self::Open => "open",
153            Self::Closed => "closed",
154        }
155    }
156}
157
158/// One standing conversation.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct Talk {
162    /// On-disk format version.
163    pub schema: u32,
164    /// Conversation id, e.g. `20260904-014455-ab12`.
165    pub id: String,
166    /// Repository this conversation is about.
167    pub repo: PathBuf,
168    /// Roster agent id holding the conversation.
169    pub agent: String,
170    /// Current state.
171    pub status: TalkStatus,
172    /// Everything said, oldest first.
173    pub turns: Vec<Turn>,
174    /// When the conversation was opened.
175    pub created_at: Timestamp,
176    /// Last change to this file.
177    pub updated_at: Timestamp,
178    /// The CLI-side conversation, so a turn after the first costs one
179    /// sentence instead of the whole transcript. Not `pub`: it is magi's
180    /// bookkeeping, and a caller that edited it would detach the record from
181    /// the conversation the model actually holds.
182    seat: SeatState,
183}
184
185impl Talk {
186    /// Short form used in lists and notifications, matching a run's short id.
187    pub fn short(&self) -> &str {
188        short(&self.id)
189    }
190}
191
192/// A conversation store on disk.
193#[derive(Debug, Clone)]
194pub struct Talks {
195    root: PathBuf,
196    /// Serializes the read-modify-write cycle that reads a talk, decides
197    /// something from its `status`, and writes the whole record back.
198    /// [`close`], [`record`] and the tail of [`turn`] all take this before
199    /// that cycle rather than after just the read: a re-read narrows the
200    /// window another writer can land in, but does not close it, since
201    /// nothing stopped that other writer's own put from landing between this
202    /// call's re-read and its own put. Shared across every clone, since every
203    /// clone is a handle onto the same files.
204    lock: Arc<Mutex<()>>,
205}
206
207impl Talks {
208    /// The operator's conversations, `<home>/talks`.
209    pub fn open() -> Self {
210        Self::at(crate::run::home().join("talks"))
211    }
212
213    /// A store at an explicit root. Tests use this, which is why none of them
214    /// need the operator's real home.
215    pub fn at(root: PathBuf) -> Self {
216        Self {
217            root,
218            lock: Arc::new(Mutex::new(())),
219        }
220    }
221
222    /// Claim the right to read-modify-write a talk's `status`. A plain
223    /// `std::sync::Mutex`, not an async one: every caller holds it across a
224    /// handful of small file operations and never across an `.await`, so
225    /// blocking the thread briefly is the right tool, not a reason to reach
226    /// for `tokio::sync::Mutex`. Poisoning recovers rather than propagates -
227    /// one panicking caller must not wedge every talk in the store the way it
228    /// would wedge the loop's own lock; see [`crate::web`]'s `lock_or_recover`,
229    /// which this mirrors.
230    fn guard(&self) -> MutexGuard<'_, ()> {
231        self.lock.lock().unwrap_or_else(PoisonError::into_inner)
232    }
233
234    /// Directory holding the conversation files.
235    pub fn root(&self) -> &Path {
236        &self.root
237    }
238
239    /// Path for one conversation id.
240    pub fn path_of(&self, id: &str) -> PathBuf {
241        self.root.join(format!("{id}.json"))
242    }
243
244    /// Where one conversation's prompts and CLI output are kept, beside the
245    /// record rather than inside it.
246    pub fn artifacts_of(&self, id: &str) -> PathBuf {
247        self.root.join(format!("{id}.artifacts"))
248    }
249
250    /// Where this conversation's attached images live: a subdirectory of
251    /// `artifacts_of`, so deleting the conversation deletes its attachments
252    /// too and nothing here needs its own cleanup path.
253    pub fn attachments_dir(&self, id: &str) -> PathBuf {
254        self.artifacts_of(id).join("attachments")
255    }
256
257    /// Persist one already-validated attachment and return its metadata.
258    ///
259    /// `web::talk_attachment_post` is the only caller: it has already
260    /// checked `mime` against the whitelist and sniffed the bytes, so an
261    /// unrecognised mime reaching here is a bug in that caller, not
262    /// something an operator did. The id is minted here and never taken
263    /// from the client; `name` is stored for display only and never used to
264    /// build a path.
265    pub fn put_attachment(
266        &self,
267        id: &str,
268        mime: &str,
269        name: &str,
270        data: &[u8],
271    ) -> Result<Attachment> {
272        let dir = self.attachments_dir(id);
273        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
274        let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
275        let att = Attachment {
276            id: new_attachment_id(),
277            name: name.to_owned(),
278            mime: mime.to_owned(),
279            bytes: data.len() as u64,
280        };
281        std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
282            .with_context(|| format!("write attachment {}", att.id))?;
283        std::fs::write(
284            dir.join(format!("{}.json", att.id)),
285            serde_json::to_string(&att).context("serialize attachment")?,
286        )
287        .with_context(|| format!("write attachment metadata {}", att.id))?;
288        Ok(att)
289    }
290
291    /// Just the metadata, without reading the image bytes back off disk -
292    /// what `web::talk_say` uses to turn an id the operator referenced into
293    /// an [`Attachment`] before appending a [`Turn`], where the bytes
294    /// themselves are of no interest. `None` for an id this conversation
295    /// never stored - including one that merely looks plausible:
296    /// [`valid_attachment_id`] is checked here too, not only by the caller,
297    /// the same defence-in-depth `Questions::panel_asset` uses for its own
298    /// asset ids.
299    pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
300        if !valid_attachment_id(att_id) {
301            return Ok(None);
302        }
303        let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
304        if !meta_path.is_file() {
305            return Ok(None);
306        }
307        let att = serde_json::from_str(
308            &std::fs::read_to_string(&meta_path)
309                .with_context(|| format!("read {}", meta_path.display()))?,
310        )
311        .with_context(|| format!("parse {}", meta_path.display()))?;
312        Ok(Some(att))
313    }
314
315    /// A stored attachment's metadata and its bytes together, for serving it
316    /// back on `GET`. `None` under the same conditions as
317    /// [`Talks::attachment_meta`], which this is built on.
318    pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
319        let Some(att) = self.attachment_meta(id, att_id)? else {
320            return Ok(None);
321        };
322        let ext = attachment_ext(&att.mime).with_context(|| {
323            format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
324        })?;
325        let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
326        let data =
327            std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
328        Ok(Some((att, data)))
329    }
330
331    /// Absolute path of one attachment's bytes, for the prompt note [`turn`]
332    /// appends and for [`Invocation::attachments`]. `None` only for a mime
333    /// [`put_attachment`] could never have written, which means the
334    /// attachment did not come from this store.
335    ///
336    /// `self.root` (and so `attachments_dir`) is not guaranteed absolute on
337    /// its own - `run::home()` returns a bare relative `PathBuf` verbatim
338    /// when the operator sets `MAGI_HOME` to a relative path, and nothing
339    /// canonicalizes it on the way in. That is harmless for every other use
340    /// of this store, since its own I/O runs in this process against this
341    /// process's cwd - but this path is handed to a CLI invoked with `cwd:
342    /// &talk.repo`, a different directory, so a relative path here would
343    /// resolve against the wrong place once it reached the prompt.
344    /// `std::path::absolute` fixes it against *this* process's cwd before
345    /// that happens; see `disk::free_bytes_by_os` for the same function used
346    /// the same way elsewhere in this codebase.
347    fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
348        let ext = attachment_ext(&att.mime)?;
349        let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
350        std::path::absolute(&path).ok()
351    }
352
353    /// Write a conversation, atomically, so a process killed mid-write leaves
354    /// the previous state readable rather than a truncated file.
355    pub fn put(&self, t: &mut Talk) -> Result<()> {
356        std::fs::create_dir_all(&self.root)
357            .with_context(|| format!("create {}", self.root.display()))?;
358        t.updated_at = Timestamp::now();
359        let body = serde_json::to_string_pretty(t).context("serialize talk")?;
360        let path = self.path_of(&t.id);
361        let tmp = path.with_extension("json.tmp");
362        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
363        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
364        Ok(())
365    }
366
367    /// Load a conversation by id or unambiguous id prefix.
368    pub fn get(&self, id: &str) -> Result<Talk> {
369        let resolved = self.resolve_id(id)?;
370        read_path(&self.path_of(&resolved))
371    }
372
373    /// Every conversation on disk: open first, then newest first, so what the
374    /// operator is still using belongs above what they are done with.
375    pub fn list(&self) -> Vec<Talk> {
376        let mut all: Vec<Talk> = std::fs::read_dir(&self.root)
377            .into_iter()
378            .flatten()
379            .flatten()
380            .map(|e| e.path())
381            .filter(|p| p.extension().is_some_and(|x| x == "json"))
382            .filter_map(|p| read_path(&p).ok())
383            .collect();
384        all.sort_unstable_by(|a, b| {
385            let rank = |t: &Talk| u8::from(!t.status.open());
386            rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
387        });
388        all
389    }
390
391    /// Expand an id prefix to exactly one conversation id.
392    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
393        if self.path_of(prefix).is_file() {
394            return Ok(prefix.to_owned());
395        }
396        let hits: Vec<String> = self
397            .list()
398            .into_iter()
399            .map(|t| t.id)
400            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
401            .collect();
402        match hits.len() {
403            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
404            0 => bail!("no talk matches `{prefix}`"),
405            _ => bail!(
406                "`{prefix}` matches {} talks: {}",
407                hits.len(),
408                hits.join(", ")
409            ),
410        }
411    }
412
413    /// Change detection token: the newest modification time in the store, in
414    /// milliseconds.
415    pub fn revision(&self) -> u64 {
416        std::fs::read_dir(&self.root)
417            .into_iter()
418            .flatten()
419            .flatten()
420            .filter_map(|e| e.metadata().ok())
421            .filter_map(|m| m.modified().ok())
422            .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
423            .map(|d| d.as_millis() as u64)
424            .max()
425            .unwrap_or(0)
426    }
427
428    /// How many conversations are still open.
429    pub fn count_open(&self) -> usize {
430        self.list().iter().filter(|t| t.status.open()).count()
431    }
432
433    /// Remove a conversation from disk, record and artifacts both. The
434    /// operator's way of saying "not just done, gone" - [`close`] alone
435    /// leaves the record as history.
436    ///
437    /// Takes [`Talks::guard`] for the same reason [`close`] does: a delete
438    /// racing a [`record`] or the tail of [`turn`] must not land between
439    /// their own read and write, or the file removed here would look, to
440    /// them, like a record that simply has not been written yet. The other
441    /// half of that story is on their side - both check under this same
442    /// guard that the record they are about to write is still there, and
443    /// give up without writing if it is not, which is what stops their `put`
444    /// from resurrecting a conversation this call already removed.
445    pub fn remove(&self, id: &str) -> Result<()> {
446        let _guard = self.guard();
447        let resolved = self.resolve_id(id)?;
448        let path = self.path_of(&resolved);
449        std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
450        let artifacts = self.artifacts_of(&resolved);
451        if artifacts.is_dir() {
452            std::fs::remove_dir_all(&artifacts)
453                .with_context(|| format!("remove {}", artifacts.display()))?;
454        }
455        Ok(())
456    }
457}
458
459/// Open a conversation. Takes no agent turn: there is no idea to answer yet,
460/// and a conversation the operator has not said anything into yet is a
461/// normal, valid thing to have sitting on the phone.
462///
463/// `agent` beats `[roles] chatter`, which beats [`agent::pick`]'s own default
464/// order (a claude seat, else the first runnable agent in roster order) when
465/// nothing names a seat at all - see `[roles] chatter`'s own doc in
466/// [`crate::config`] for why a dedicated field exists rather than reusing a
467/// judge seat.
468pub fn begin(store: &Talks, cfg: &Config, repo: PathBuf, agent: Option<&str>) -> Result<Talk> {
469    // Absolute: a relative path means the wrong repository once anything
470    // other than this process reads it back.
471    let repo = repo.canonicalize().unwrap_or(repo);
472    let want = agent.or(cfg.roles.chatter.as_deref());
473    let spec = agent::pick(&cfg.agents, want, &agent::installed)?;
474
475    let now = Timestamp::now();
476    let mut talk = Talk {
477        schema: SCHEMA,
478        id: new_id(),
479        repo,
480        agent: spec.id.clone(),
481        status: TalkStatus::Open,
482        turns: Vec::new(),
483        created_at: now,
484        updated_at: now,
485        seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
486    };
487    store.put(&mut talk)?;
488    Ok(talk)
489}
490
491/// Append the operator's turn and flush it, without invoking anything.
492///
493/// Split out of [`say`] so `POST /api/talks/{id}/say` can answer once the
494/// message is safely on disk, and run the agent's half in the background -
495/// holding the connection for a turn that can run fifteen minutes is the
496/// wrong shape for a phone.
497pub fn record(
498    talk: &mut Talk,
499    store: &Talks,
500    text: &str,
501    attachments: Vec<Attachment>,
502) -> Result<String> {
503    // `web::talk_say` reads the talk, then awaits config discovery before
504    // calling this - a gap a concurrent `POST /api/talks/{id}/close` can land
505    // in. The guard held for the rest of this function is what actually closes
506    // that gap: re-reading status without it only shrinks the window a
507    // concurrent `close` could land in between this call's own read and its
508    // `put`, it does not remove it. See [`Talks::guard`] and the matching
509    // guard in `turn`, which this mirrors.
510    let _guard = store.guard();
511    // A concurrent `Talks::remove` can have landed in that same gap. `put`
512    // writes unconditionally, so trusting the stale `talk` here would recreate
513    // the file a delete just removed - the record must still be there for a
514    // turn to have anywhere to append to.
515    let Ok(fresh) = store.get(&talk.id) else {
516        bail!("talk {} was deleted", talk.short());
517    };
518    talk.status = fresh.status;
519    if !talk.status.open() {
520        bail!(
521            "talk {} is {} and takes no more turns",
522            talk.short(),
523            talk.status.as_str()
524        );
525    }
526    let text = text.trim();
527    if text.is_empty() && attachments.is_empty() {
528        bail!("nothing to say");
529    }
530    talk.turns.push(Turn {
531        who: Who::Operator,
532        body: text.to_owned(),
533        at: Timestamp::now(),
534        attachments,
535    });
536    store.put(talk)?;
537    Ok(text.to_owned())
538}
539
540/// One operator turn and one agent turn, appended - the synchronous form, used
541/// by tests and by anything that is fine waiting out the turn itself.
542pub async fn say(
543    talk: &mut Talk,
544    store: &Talks,
545    cfg: &Config,
546    text: &str,
547    attachments: Vec<Attachment>,
548) -> Result<()> {
549    let text = record(talk, store, text, attachments)?;
550    turn(talk, store, cfg, &text).await
551}
552
553/// The agent's half of a turn: invoke, append, flush. Pairs with [`record`].
554pub async fn respond(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
555    turn(talk, store, cfg, text).await
556}
557
558/// Close a conversation. Idempotent: closing an already-closed conversation is
559/// not an error, since the operator's intent - "I am done with this" - is
560/// already satisfied.
561///
562/// Re-reads the record under [`Talks::guard`] rather than trusting the
563/// caller's copy of `talk`, and writes that fresh copy back rather than the
564/// one passed in. `web::talk_close` loads `talk` and calls this right after
565/// with no gap of its own, but without the guard that load can still land
566/// between a `record` or `turn` elsewhere reading the file and writing it
567/// back - and a close built on the older snapshot would put it right back,
568/// silently dropping whatever turn the other call had just appended.
569///
570/// If the re-read fails, this errors rather than falling back to the
571/// caller's stale copy: `talk::begin` always `put`s the record before handing
572/// out a `Talk`, so the only way a re-read can fail is a concurrent
573/// [`Talks::remove`] having deleted it, and writing the stale copy back would
574/// resurrect exactly what that delete removed.
575pub fn close(talk: &mut Talk, store: &Talks) -> Result<()> {
576    let _guard = store.guard();
577    let mut fresh = store
578        .get(&talk.id)
579        .with_context(|| format!("talk {} was deleted", talk.short()))?;
580    fresh.status = TalkStatus::Closed;
581    store.put(&mut fresh)?;
582    *talk = fresh;
583    Ok(())
584}
585
586/// Reopen a closed conversation. Idempotent for the same reason [`close`] is:
587/// reopening an already-open conversation is not an error, since the
588/// operator's intent - "I want to keep talking about this" - is already
589/// satisfied.
590///
591/// Written symmetrically with [`close`]: re-reads the record under
592/// [`Talks::guard`] rather than trusting the caller's copy of `talk`, writes
593/// that fresh copy back rather than the one passed in, and errors rather than
594/// falling back to the stale copy if the re-read fails, for the same reasons
595/// `close`'s doc gives.
596pub fn reopen(talk: &mut Talk, store: &Talks) -> Result<()> {
597    let _guard = store.guard();
598    let mut fresh = store
599        .get(&talk.id)
600        .with_context(|| format!("talk {} was deleted", talk.short()))?;
601    fresh.status = TalkStatus::Open;
602    store.put(&mut fresh)?;
603    *talk = fresh;
604    Ok(())
605}
606
607/// Invoke the conversation's agent once and append what it said.
608///
609/// The first turn ever taken carries the full [`briefing`], because nothing
610/// else has told the agent what this conversation is or what it may do.
611/// Every turn after that resends nothing when the CLI can resume its own
612/// session, and falls back to [`transcript`] only when it cannot.
613async fn turn(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
614    let spec = cfg
615        .agents
616        .iter()
617        .find(|a| a.id == talk.agent)
618        .with_context(|| {
619            format!(
620                "talk {} was opened with agent `{}`, which is no longer in \
621                 the roster; restore it in magi.toml or start a new \
622                 conversation",
623                talk.short(),
624                talk.agent
625            )
626        })?;
627
628    let resuming = agent::has_session(spec.kind, &talk.seat, cfg.graph.sessions);
629    // The newest turn is always the operator message this call is answering
630    // - `record` appended it before `turn` was ever called - so its own
631    // attachments are what belong at the end of *this* prompt.
632    let last_note = attachment_note(
633        store,
634        &talk.id,
635        talk.turns
636            .last()
637            .map_or(&[][..], |t| t.attachments.as_slice()),
638    );
639    let body = if talk.seat.turns == 0 {
640        format!(
641            "{}\n\n# Operator\n\n{text}{last_note}",
642            briefing(&talk.repo, &cfg.graph.language, cfg.talk.allow_write)
643        )
644    } else if resuming {
645        format!("{text}{last_note}")
646    } else {
647        format!("{}\n\n{text}{last_note}", transcript(talk, store))
648    };
649
650    // Every attachment this conversation has ever held, not only this
651    // turn's: a resumed session gets a fresh process every turn, so a CLI
652    // whose sandbox needs `--add-dir` (see `agent::build_command`) needs the
653    // grant again to open an image from an earlier turn, even when nothing
654    // new was attached just now.
655    let attachment_paths: Vec<PathBuf> = talk
656        .turns
657        .iter()
658        .flat_map(|t| t.attachments.iter())
659        .filter_map(|a| store.attachment_path(&talk.id, a))
660        .collect();
661
662    let artifacts = store.artifacts_of(&talk.id);
663    let stem = format!("turn-{}", talk.seat.turns + 1);
664    // The chat's build cache is the same shared one the graph's seats get, so
665    // a conversation that compiles does not mint another multi-GB target dir.
666    let cache_dir = cfg.cache_dir();
667    let inv = Invocation {
668        cwd: &talk.repo,
669        prompt: &body,
670        timeout: turn_timeout(cfg),
671        // Off unless this repository's own config opts in - see
672        // `crate::config::Talk::allow_write` and this module's doc for why
673        // the default keeps a conversational edit from landing in a checkout
674        // no run or review can claim.
675        allow_write: cfg.talk.allow_write,
676        sessions: cfg.graph.sessions,
677        artifacts: &artifacts,
678        stem: &stem,
679        // The conversation's own id, so `magi task add` run from inside it is
680        // attributed to this conversation - see `Source::Agent`.
681        run: &talk.id,
682        node: "chat",
683        cache_dir: cache_dir.as_deref(),
684        attachments: &attachment_paths,
685    };
686
687    let outcome = agent::invoke(spec, &mut talk.seat, &inv).await;
688    let note = |why: String| Turn {
689        who: Who::Agent,
690        body: format!("{MAGI_NOTE}{why}"),
691        at: Timestamp::now(),
692        attachments: Vec::new(),
693    };
694    let (reply, failure) = match outcome {
695        Err(e) => (
696            note(format!("could not run agent `{}`: {e}", talk.agent)),
697            Some(format!("could not run agent `{}`: {e}", talk.agent)),
698        ),
699        Ok(out) if out.quota_exhausted() => {
700            let reset = out
701                .quota
702                .as_ref()
703                .and_then(|q| q.reset.clone())
704                .map_or_else(String::new, |r| format!(" (resets {r})"));
705            let why = format!(
706                "agent `{}` is out of quota{reset}; your message is saved, so \
707                 say it again when the window reopens",
708                talk.agent
709            );
710            (note(why.clone()), Some(why))
711        }
712        Ok(out) if out.timed_out => {
713            let why = format!(
714                "agent `{}` did not answer within {}s; your message is saved",
715                talk.agent,
716                turn_timeout(cfg).as_secs()
717            );
718            (note(why.clone()), Some(why))
719        }
720        Ok(out) if !out.usable() => {
721            let why = format!(
722                "agent `{}` produced no answer (exit {}); your message is saved",
723                talk.agent,
724                out.exit_code
725                    .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
726            );
727            (note(why.clone()), Some(why))
728        }
729        Ok(out) => (
730            Turn {
731                who: Who::Agent,
732                body: out.text.trim().to_owned(),
733                at: Timestamp::now(),
734                attachments: Vec::new(),
735            },
736            None,
737        ),
738    };
739
740    // A close landed on disk while this turn was in flight is read back here
741    // rather than trusted from the snapshot this call started with. `store`
742    // holds nothing else this function does not itself own - the turn guard
743    // in `web::Ui::begin_talk_turn` keeps `turns` and `seat` this call's
744    // alone to mutate - but `status` is not behind that guard, and an
745    // operator's close must stick: the whole point of ending a conversation
746    // is that an agent's answer to the last message before the close cannot
747    // silently reopen it. The guard is what makes that read-then-write
748    // section atomic with `close`'s own - taken only for this tail and not
749    // for the whole invocation above, so one talk's fifteen-minute turn does
750    // not block another talk's close from proceeding.
751    let _guard = store.guard();
752    // A delete is the more final version of that same race: `put` writes
753    // unconditionally, so a talk removed while this turn was in flight must
754    // stay removed rather than being written back with this turn's reply
755    // appended to it. The reply is simply given up on - there is no
756    // conversation left for it to belong to.
757    let Ok(fresh) = store.get(&talk.id) else {
758        return Ok(());
759    };
760    talk.status = fresh.status;
761    talk.turns.push(reply);
762    store.put(talk)?;
763
764    match failure {
765        Some(why) => bail!("{why}"),
766        None => Ok(()),
767    }
768}
769
770/// Everything said so far, as prose, for a CLI that cannot resume its own
771/// conversation.
772fn transcript(talk: &Talk, store: &Talks) -> String {
773    let mut out = String::from(
774        "This conversation cannot resume on the CLI's side, so here is \
775         everything said so far; answer only the last message.\n",
776    );
777    for t in &talk.turns {
778        let who = match t.who {
779            Who::Operator => "operator",
780            Who::Agent => "you",
781        };
782        out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
783        out.push_str(&attachment_note(store, &talk.id, &t.attachments));
784    }
785    out
786}
787
788/// The section named at the end of a turn's body, listing every attachment's
789/// absolute path and mime so the agent knows exactly what to open. Empty
790/// when `attachments` is, which is every turn but the rare one carrying an
791/// image, so a turn with none changes nothing about the prompt.
792fn attachment_note(store: &Talks, talk_id: &str, attachments: &[Attachment]) -> String {
793    if attachments.is_empty() {
794        return String::new();
795    }
796    let mut out = String::from(
797        "\n\nThe operator attached the image(s) below to this message. Open \
798         and look at each one before you answer.\n",
799    );
800    for att in attachments {
801        if let Some(path) = store.attachment_path(talk_id, att) {
802            out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
803        }
804    }
805    out.push('\n');
806    out
807}
808
809/// The briefing the agent opens with, sent once as part of its first turn.
810///
811/// Pure, so the properties that matter can be asserted without an interview:
812/// it names `magi task add --solo` (the route this conversation always has to
813/// changing anything) and it never tells the agent to write a task *file* of
814/// its own - that would compete with filing through the queue.
815/// `allow_write` only ever adds an extra permission on top of that; it never
816/// removes the queue as an option, which is why both branches keep the same
817/// `# When the operator wants something done` section - `write_policy` is
818/// the only part that changes.
819pub fn briefing(repo: &Path, language: &str, allow_write: bool) -> String {
820    let write_policy = if allow_write {
821        "This repository has set `[talk] allow_write = true`, so you may \
822         write files here - but only a small, already-decided edit the \
823         operator names outright in this conversation, not an \
824         implementation. Once you have made it, say plainly what you \
825         edited. Anything bigger, or anything still open-ended, still goes \
826         through the queue below rather than being done here."
827    } else {
828        "Do not write files. Implementing a change is not this \
829         conversation's job; a separate, blind competition of agents does \
830         that, and a repository this conversation has already edited would \
831         make their diffs unjudgeable."
832    };
833    let mut out = format!(
834        "You are magi's standing conversation partner for its operator, who \
835         usually has this open on a phone. Keep replies short: no preamble, \
836         no restating what they just said.\n\n\
837         # Repository\n\n{repo}\n\n\
838         You may look around: read files, run shell commands, search history, \
839         run tests - whatever answers the question. {write_policy}\n\n\
840         # When the operator wants something done\n\n\
841         Run:\n\n\
842         magi task add --solo --repo {repo} <instruction>\n\n\
843         and tell the operator the task id it prints, so they can follow it \
844         from the Queue. Write <instruction> so that an implementer who has \
845         never seen this conversation can act on it alone - it is everything \
846         they get. Use --solo: it runs the task through one implementer \
847         straight into review instead of the usual multi-agent competition, \
848         which is the right shape for a change this conversation has already \
849         settled, rather than one still worth several independent takes.\n",
850        repo = repo.display(),
851    );
852    out.push_str(&language_note(language));
853    out
854}
855
856/// The operator is talking, so their language matters here more than in most
857/// prompts magi sends.
858fn language_note(language: &str) -> String {
859    if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
860        String::new()
861    } else {
862        format!("\nHold this conversation in {language}.\n")
863    }
864}
865
866/// Queue tasks this conversation has filed, oldest first.
867///
868/// A task is this conversation's when its [`Source::Agent`] names this
869/// conversation's id as `run` - which is exactly what happens when
870/// `magi task add` is run from inside a turn, because [`turn`] passes the
871/// conversation's own id as [`Invocation::run`].
872pub fn tasks_of(queue: &Queue, talk_id: &str) -> Vec<Task> {
873    let mut tasks: Vec<Task> = queue
874        .list()
875        .into_iter()
876        .filter(|t| matches!(&t.source, Source::Agent { run, .. } if run == talk_id))
877        .collect();
878    tasks.sort_unstable_by(|a, b| a.id.cmp(&b.id));
879    tasks
880}
881
882fn read_path(path: &Path) -> Result<Talk> {
883    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
884    serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
885}
886
887fn short(id: &str) -> &str {
888    id.split('-').next_back().unwrap_or(id)
889}
890
891fn new_id() -> String {
892    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
893    let seed = crate::rng::entropy();
894    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
895}
896
897/// Extension an attachment's bytes are stored under, from its (already
898/// validated) mime. The one place this mapping exists on the write side;
899/// `web`'s own whitelist is what actually decides which mimes are accepted
900/// in the first place.
901fn attachment_ext(mime: &str) -> Option<&'static str> {
902    match mime {
903        "image/png" => Some("png"),
904        "image/jpeg" => Some("jpg"),
905        "image/gif" => Some("gif"),
906        "image/webp" => Some("webp"),
907        _ => None,
908    }
909}
910
911/// Is `id` a shape [`put_attachment`](Talks::put_attachment) could have
912/// produced? 32 lowercase hex digits and nothing else, checked before an id
913/// that came from the client is ever allowed to build a path - so `..` and a
914/// path separator are never even possible.
915pub fn valid_attachment_id(id: &str) -> bool {
916    id.len() == 32
917        && id
918            .bytes()
919            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
920}
921
922/// A fresh attachment id: 128 bits of process entropy as lowercase hex - the
923/// same "mint it, never take it from the client" rule [`new_id`] follows for
924/// conversation ids.
925fn new_attachment_id() -> String {
926    let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
927    format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
928}
929
930#[cfg(test)]
931mod tests {
932    use std::collections::BTreeMap;
933
934    use crate::config::{AgentKind, AgentSpec, Graph};
935    use crate::queue::{Queue, Source, Task};
936
937    use super::*;
938
939    /// A store of its own, with no process-global state.
940    fn store() -> (tempfile::TempDir, Talks) {
941        let tmp = tempfile::tempdir().expect("tempdir");
942        let talks = Talks::at(tmp.path().join("talks"));
943        (tmp, talks)
944    }
945
946    /// A `kind = "command"` agent whose whole behaviour is a POSIX shell
947    /// script - see `chat`'s tests for why no test here may spawn a real
948    /// agent CLI.
949    fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
950        let path = dir.join("mock-talk-agent.sh");
951        std::fs::write(&path, script).expect("write mock");
952        AgentSpec {
953            id: "mock".to_owned(),
954            kind: AgentKind::Command,
955            model: None,
956            command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
957            extra_args: Vec::new(),
958            env,
959            prompt_delivery: None,
960        }
961    }
962
963    fn config(spec: AgentSpec) -> Config {
964        Config {
965            agents: vec![spec],
966            graph: Graph {
967                language: "en".to_owned(),
968                ..Graph::default()
969            },
970            ..Config::default()
971        }
972    }
973
974    /// Echo a canned reply, ignoring the prompt on stdin.
975    const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
976
977    /// Say nothing and fail, the way a CLI that cannot start does.
978    const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
979
980    /// Reply with the prompt it was given, so a test can inspect exactly what
981    /// the agent received on stdin.
982    const ECHO: &str = "#!/bin/sh\ncat\n";
983
984    fn env(reply: &str) -> BTreeMap<String, String> {
985        BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
986    }
987
988    #[test]
989    fn the_frozen_json_field_names_round_trip_through_disk() {
990        let (tmp, talks) = store();
991        let mut talk = Talk {
992            schema: SCHEMA,
993            id: "20260904-014455-ab12".to_owned(),
994            repo: tmp.path().to_owned(),
995            agent: "sonnet".to_owned(),
996            status: TalkStatus::Open,
997            turns: Vec::new(),
998            created_at: Timestamp::now(),
999            updated_at: Timestamp::now(),
1000            seat: SeatState::new(SEAT, "sonnet", 7),
1001        };
1002        talks.put(&mut talk).expect("put");
1003
1004        let raw = std::fs::read_to_string(talks.path_of(&talk.id)).expect("read back");
1005        let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1006        for field in [
1007            "schema",
1008            "id",
1009            "repo",
1010            "agent",
1011            "status",
1012            "turns",
1013            "created_at",
1014            "updated_at",
1015        ] {
1016            assert!(v.get(field).is_some(), "missing field `{field}`");
1017        }
1018        assert_eq!(v["schema"], 1);
1019        assert_eq!(v["status"], "open");
1020
1021        let back = talks.get(&talk.id).expect("get");
1022        assert_eq!(back.id, talk.id);
1023        assert_eq!(back.status, TalkStatus::Open);
1024    }
1025
1026    #[test]
1027    fn opening_a_talk_takes_no_agent_turn() {
1028        let (tmp, talks) = store();
1029        // A script that would fail loudly if it were ever run: `begin` must
1030        // not invoke anything, since there is nothing yet for an agent to
1031        // answer.
1032        let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1033        let cfg = config(spec);
1034
1035        let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1036        assert_eq!(talk.status, TalkStatus::Open);
1037        assert!(talk.turns.is_empty(), "nothing has been said yet");
1038
1039        let on_disk = talks.get(&talk.id).expect("get");
1040        assert_eq!(on_disk.turns.len(), 0);
1041    }
1042
1043    /// `[roles] chatter`, when set, decides who holds this conversation; unset,
1044    /// it falls back to [`agent::pick`]'s own default order (a claude seat,
1045    /// else the first runnable agent in roster order) rather than to any
1046    /// other role - see `[roles] chatter`'s own doc in [`crate::config`] for
1047    /// why a dedicated field exists at all: opening this against the same
1048    /// seat as a judge is what produced the `agent ... did not answer within
1049    /// 300s` timeout that led to it.
1050    #[test]
1051    fn chatter_wins_when_set_and_falls_back_to_pick_s_default_order_otherwise() {
1052        let (tmp, talks) = store();
1053        let first_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1054        let mut chatter_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1055        chatter_spec.id = "chatter-mock".to_owned();
1056
1057        let mut cfg = Config {
1058            agents: vec![first_spec.clone(), chatter_spec.clone()],
1059            graph: Graph {
1060                language: "en".to_owned(),
1061                ..Graph::default()
1062            },
1063            ..Config::default()
1064        };
1065        cfg.roles.chatter = Some(chatter_spec.id.clone());
1066
1067        let talk =
1068            begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter set");
1069        assert_eq!(talk.agent, chatter_spec.id, "an explicit chatter must win");
1070
1071        cfg.roles.chatter = None;
1072        let fallback =
1073            begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter unset");
1074        assert_eq!(
1075            fallback.agent, first_spec.id,
1076            "unset chatter must fall back to agent::pick's own default order"
1077        );
1078    }
1079
1080    /// A conversation recorded before attachments existed - schema 1, no
1081    /// `attachments` key on any turn - must still read.
1082    #[test]
1083    fn a_talk_recorded_without_attachments_still_reads() {
1084        let (tmp, talks) = store();
1085        let path = talks.path_of("20260904-014455-ab12");
1086        std::fs::create_dir_all(talks.root()).expect("talks dir");
1087        std::fs::write(
1088            &path,
1089            serde_json::json!({
1090                "schema": 1,
1091                "id": "20260904-014455-ab12",
1092                "repo": tmp.path(),
1093                "agent": "sonnet",
1094                "status": "open",
1095                "turns": [
1096                    { "who": "operator", "body": "still there?",
1097                      "at": Timestamp::now().to_string() },
1098                ],
1099                "created_at": Timestamp::now().to_string(),
1100                "updated_at": Timestamp::now().to_string(),
1101                "seat": SeatState::new(SEAT, "sonnet", 7),
1102            })
1103            .to_string(),
1104        )
1105        .expect("write pre-attachments talk");
1106
1107        let talk = talks.get("20260904-014455-ab12").expect("must still read");
1108        assert!(talk.turns[0].attachments.is_empty());
1109    }
1110
1111    #[tokio::test]
1112    async fn the_first_turn_carries_the_briefing_and_later_turns_do_not() {
1113        let (tmp, talks) = store();
1114        let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1115        let cfg = config(spec);
1116        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1117
1118        say(
1119            &mut talk,
1120            &talks,
1121            &cfg,
1122            "what does the queue module do?",
1123            Vec::new(),
1124        )
1125        .await
1126        .expect("first turn");
1127        let first_prompt = &talk.turns[1].body;
1128        assert!(first_prompt.contains("magi task add --solo"));
1129        assert!(first_prompt.contains("what does the queue module do?"));
1130
1131        say(&mut talk, &talks, &cfg, "and how is it locked?", Vec::new())
1132            .await
1133            .expect("second turn");
1134        let second_prompt = &talk.turns[3].body;
1135        assert!(
1136            !second_prompt.contains("magi task add --solo"),
1137            "the briefing is sent once, not on every turn: {second_prompt}"
1138        );
1139        assert!(second_prompt.contains("and how is it locked?"));
1140    }
1141
1142    #[tokio::test]
1143    async fn say_appends_the_operator_turn_then_the_agent_turn() {
1144        let (tmp, talks) = store();
1145        let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
1146        let cfg = config(spec);
1147        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1148
1149        say(
1150            &mut talk,
1151            &talks,
1152            &cfg,
1153            "can I rename this function?",
1154            Vec::new(),
1155        )
1156        .await
1157        .expect("say");
1158
1159        assert_eq!(talk.turns.len(), 2);
1160        assert_eq!(talk.turns[0].who, Who::Operator);
1161        assert_eq!(talk.turns[0].body, "can I rename this function?");
1162        assert_eq!(talk.turns[1].who, Who::Agent);
1163        assert_eq!(talk.turns[1].body, "go ahead");
1164        assert_eq!(talks.get(&talk.id).expect("get").turns, talk.turns);
1165    }
1166
1167    #[tokio::test]
1168    async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1169        let (tmp, talks) = store();
1170        let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1171        let cfg = config(spec);
1172        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1173
1174        let err = say(&mut talk, &talks, &cfg, "check the tests", Vec::new())
1175            .await
1176            .expect_err("a turn with no answer is an error");
1177        assert!(err.to_string().contains("no answer"), "{err}");
1178
1179        let on_disk = talks.get(&talk.id).expect("get");
1180        assert_eq!(on_disk.turns.len(), 2);
1181        assert_eq!(on_disk.turns[0].body, "check the tests");
1182        let note = &on_disk.turns[1];
1183        assert_eq!(note.who, Who::Agent);
1184        assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
1185        assert!(note.body.contains("your message is saved"));
1186    }
1187
1188    /// An attachment lets the operator send an otherwise-empty message, and
1189    /// its absolute path is what actually reaches the agent's prompt - here
1190    /// on the very first turn, where it has to share the briefing.
1191    #[tokio::test]
1192    async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
1193        let (tmp, talks) = store();
1194        let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1195        let cfg = config(spec);
1196        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1197
1198        let att = talks
1199            .put_attachment(
1200                &talk.id,
1201                "image/png",
1202                "screenshot.png",
1203                b"pretend-png-bytes",
1204            )
1205            .expect("put attachment");
1206
1207        say(&mut talk, &talks, &cfg, "", vec![att.clone()])
1208            .await
1209            .expect("an empty body with an attachment is still a turn");
1210
1211        let operator_turn = &talk.turns[0];
1212        assert_eq!(operator_turn.who, Who::Operator);
1213        assert_eq!(operator_turn.body, "");
1214        assert_eq!(operator_turn.attachments, vec![att.clone()]);
1215
1216        let prompt = &talk.turns[1].body;
1217        let expected_path = talks
1218            .attachments_dir(&talk.id)
1219            .join(format!("{}.png", att.id));
1220        assert!(
1221            prompt.contains(&expected_path.display().to_string()),
1222            "the agent must be told the attachment's absolute path: {prompt}"
1223        );
1224        assert!(prompt.contains("image/png"), "and its mime: {prompt}");
1225    }
1226
1227    /// See `chat`'s test of the same name: `run::home()` returns a bare
1228    /// relative `PathBuf` verbatim when `MAGI_HOME` is set to a relative
1229    /// path, so a `Talks` store built on it has a relative `root` too. That
1230    /// is fine for this store's own I/O, which runs in this process against
1231    /// this process's cwd, but `attachment_path` hands its result to a
1232    /// *different* process invoked with `cwd: &talk.repo` - an uncorrected
1233    /// relative path would resolve against the repository instead of
1234    /// wherever the attachment actually landed.
1235    #[test]
1236    fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
1237        let talks = Talks::at(PathBuf::from("relative-talks-root-for-this-test"));
1238        let att = Attachment {
1239            id: "0".repeat(32),
1240            name: "shot.png".to_owned(),
1241            mime: "image/png".to_owned(),
1242            bytes: 3,
1243        };
1244        let path = talks
1245            .attachment_path("some-talk-id", &att)
1246            .expect("a supported mime always yields a path");
1247        assert!(
1248            path.is_absolute(),
1249            "must be absolute even off a relative store root: {}",
1250            path.display()
1251        );
1252    }
1253
1254    #[tokio::test]
1255    async fn a_turn_past_the_configured_talk_timeout_is_reported_with_that_timeout() {
1256        // `[graph] timeout_talk` must be the number this module actually
1257        // waits, not a leftover hardcoded fifteen minutes - so the mock
1258        // sleeps past a deliberately tiny override and the failure note is
1259        // checked against that same override, not the old default.
1260        let (tmp, talks) = store();
1261        let slow = mock_agent(
1262            tmp.path(),
1263            "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
1264            BTreeMap::new(),
1265        );
1266        let mut cfg = config(slow);
1267        cfg.graph.timeout_talk = 1;
1268        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1269
1270        let err = say(&mut talk, &talks, &cfg, "check the tests", Vec::new())
1271            .await
1272            .expect_err("a turn that never answers is an error");
1273        assert!(
1274            err.to_string().contains("did not answer within 1s"),
1275            "{err}"
1276        );
1277
1278        let on_disk = talks.get(&talk.id).expect("get");
1279        let note = on_disk.turns.last().expect("a note turn was recorded");
1280        assert!(
1281            note.body.contains("did not answer within 1s"),
1282            "the transcript must show the configured timeout: {}",
1283            note.body
1284        );
1285    }
1286
1287    #[test]
1288    fn closing_is_idempotent_and_a_closed_talk_takes_no_more_turns() {
1289        let (tmp, talks) = store();
1290        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1291        let cfg = config(spec);
1292        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1293
1294        close(&mut talk, &talks).expect("close");
1295        assert_eq!(talk.status, TalkStatus::Closed);
1296        close(&mut talk, &talks).expect("closing twice is not an error");
1297
1298        let err =
1299            record(&mut talk, &talks, "still there?", Vec::new()).expect_err("closed talks refuse");
1300        assert!(err.to_string().contains("closed"));
1301        let _ = &cfg; // config kept only to build the agent above
1302    }
1303
1304    #[tokio::test]
1305    async fn a_close_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1306        let (tmp, talks) = store();
1307        let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1308        let cfg = config(spec);
1309        // The in-flight turn's own handle: loaded once, the way a spawned
1310        // background task in `web::talk_say` holds one for the whole turn.
1311        let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1312
1313        // The operator closes the conversation through a *different* handle
1314        // while the turn above is still running - exactly what a close typed
1315        // on the phone while an agent is mid-answer looks like.
1316        let mut closed_elsewhere = talks.get(&in_flight.id).expect("reread");
1317        close(&mut closed_elsewhere, &talks).expect("close");
1318        assert_eq!(
1319            talks.get(&in_flight.id).expect("reread").status,
1320            TalkStatus::Closed,
1321            "the close landed on disk before the turn finished"
1322        );
1323
1324        // The turn's own handle still says `open` - it was loaded before the
1325        // close - and finishing it must not resurrect the conversation the
1326        // operator already ended.
1327        assert_eq!(in_flight.status, TalkStatus::Open);
1328        respond(&mut in_flight, &talks, &cfg, "one more question")
1329            .await
1330            .expect("the turn itself still completes");
1331
1332        let on_disk = talks.get(&in_flight.id).expect("reread");
1333        assert_eq!(
1334            on_disk.status,
1335            TalkStatus::Closed,
1336            "a close must stick even when a turn that started before it finishes after it"
1337        );
1338        // The reply is not lost either: a turn already in flight when the
1339        // operator closed still gets its answer recorded.
1340        assert!(
1341            on_disk.turns.iter().any(|t| t.body == "here you go"),
1342            "the in-flight turn's own reply is still recorded: {:?}",
1343            on_disk.turns
1344        );
1345    }
1346
1347    #[test]
1348    fn a_close_that_lands_before_record_is_called_is_not_undone_by_it() {
1349        let (tmp, talks) = store();
1350        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1351        let cfg = config(spec);
1352        // The handle `web::talk_say` would have read before awaiting config
1353        // discovery, then carried across that await into `record`.
1354        let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1355
1356        // The operator closes the conversation through a *different* handle
1357        // in the gap between that read and the call to `record` below.
1358        let mut closed_elsewhere = talks.get(&stale.id).expect("reread");
1359        close(&mut closed_elsewhere, &talks).expect("close");
1360        assert_eq!(
1361            talks.get(&stale.id).expect("reread").status,
1362            TalkStatus::Closed,
1363            "the close landed on disk before record was called"
1364        );
1365
1366        // The stale handle still says `open` - it was loaded before the
1367        // close - so a `record` that trusted it would append a turn and
1368        // write the conversation back open, undoing the close.
1369        assert_eq!(stale.status, TalkStatus::Open);
1370        let err = record(&mut stale, &talks, "still there?", Vec::new())
1371            .expect_err("a close that landed first must be honored, not overwritten");
1372        assert!(err.to_string().contains("closed"));
1373
1374        let on_disk = talks.get(&stale.id).expect("reread");
1375        assert_eq!(
1376            on_disk.status,
1377            TalkStatus::Closed,
1378            "record must not resurrect a conversation closed while its snapshot was stale"
1379        );
1380        assert!(
1381            on_disk.turns.is_empty(),
1382            "the rejected turn must not have been appended: {:?}",
1383            on_disk.turns
1384        );
1385        let _ = &cfg; // config kept only to build the agent above
1386    }
1387
1388    #[test]
1389    fn close_blocks_on_records_guard_rather_than_interleaving_with_it() {
1390        let (tmp, talks) = store();
1391        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1392        let cfg = config(spec);
1393        let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1394
1395        // Hold the same guard `record`'s read-modify-write section holds for
1396        // the whole of its own read-then-write, standing in for `record`
1397        // being paused between its read and its `put`.
1398        let held = talks.guard();
1399
1400        let talks2 = talks.clone();
1401        let id = talk.id.clone();
1402        let closing = std::thread::spawn(move || {
1403            let mut talk = talks2.get(&id).expect("get");
1404            close(&mut talk, &talks2).expect("close");
1405        });
1406
1407        std::thread::sleep(Duration::from_millis(50));
1408        assert!(
1409            !closing.is_finished(),
1410            "close must wait for the guard, not read and write while it is held - \
1411             a re-read alone narrows this window without closing it"
1412        );
1413
1414        drop(held);
1415        closing.join().expect("close thread panicked");
1416
1417        assert_eq!(
1418            talks.get(&talk.id).expect("reread").status,
1419            TalkStatus::Closed,
1420            "once the guard is free, close still lands"
1421        );
1422        let _ = &cfg; // config kept only to build the agent above
1423    }
1424
1425    #[test]
1426    fn reopening_a_closed_talk_lets_it_take_turns_again_and_reopening_twice_is_not_an_error() {
1427        let (tmp, talks) = store();
1428        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1429        let cfg = config(spec);
1430        let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1431
1432        close(&mut talk, &talks).expect("close");
1433        assert_eq!(talk.status, TalkStatus::Closed);
1434
1435        reopen(&mut talk, &talks).expect("reopen");
1436        assert_eq!(talk.status, TalkStatus::Open);
1437        assert_eq!(
1438            talks.get(&talk.id).expect("reread").status,
1439            TalkStatus::Open
1440        );
1441
1442        // Idempotent: reopening an already-open talk is not an error.
1443        reopen(&mut talk, &talks).expect("reopening an open talk is not an error");
1444        assert_eq!(talk.status, TalkStatus::Open);
1445
1446        record(&mut talk, &talks, "one more thing", Vec::new())
1447            .expect("a reopened talk takes turns again");
1448        let _ = &cfg; // config kept only to build the agent above
1449    }
1450
1451    #[test]
1452    fn removing_a_talk_deletes_its_record_and_artifacts_and_refuses_an_unknown_id() {
1453        let (tmp, talks) = store();
1454        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1455        let cfg = config(spec);
1456        let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1457
1458        let artifacts = talks.artifacts_of(&talk.id);
1459        std::fs::create_dir_all(&artifacts).expect("create artifacts dir");
1460        std::fs::write(artifacts.join("turn-1.txt"), "hello").expect("write artifact");
1461
1462        talks.remove(&talk.id).expect("remove");
1463        assert!(!talks.path_of(&talk.id).is_file(), "the record is gone");
1464        assert!(!artifacts.is_dir(), "the artifacts directory is gone");
1465        assert!(
1466            talks.get(&talk.id).is_err(),
1467            "a removed talk cannot be read back"
1468        );
1469
1470        let err = talks
1471            .remove("nonexistent-id")
1472            .expect_err("unknown id refused");
1473        assert!(err.to_string().contains("no talk matches"), "{err}");
1474        let _ = &cfg; // config kept only to build the agent above
1475    }
1476
1477    #[tokio::test]
1478    async fn a_delete_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1479        let (tmp, talks) = store();
1480        let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1481        let cfg = config(spec);
1482        // The in-flight turn's own handle, loaded before the delete lands -
1483        // the same shape as the matching close test above.
1484        let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1485
1486        talks.remove(&in_flight.id).expect("remove");
1487        assert!(
1488            talks.get(&in_flight.id).is_err(),
1489            "the delete landed on disk before the turn finished"
1490        );
1491
1492        // The turn's own handle has no way to know the record is gone -
1493        // finishing it must not write the file back into existence.
1494        respond(&mut in_flight, &talks, &cfg, "one more question")
1495            .await
1496            .expect("the turn itself still completes rather than erroring");
1497
1498        assert!(
1499            talks.get(&in_flight.id).is_err(),
1500            "a delete must stick even when a turn that started before it finishes after it"
1501        );
1502    }
1503
1504    #[test]
1505    fn a_delete_that_lands_before_record_is_called_is_not_undone_by_it() {
1506        let (tmp, talks) = store();
1507        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1508        let cfg = config(spec);
1509        // The handle `web::talk_say` would have read before awaiting config
1510        // discovery, then carried across that await into `record`.
1511        let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1512
1513        talks.remove(&stale.id).expect("remove");
1514
1515        // The stale handle has no way to know the record is gone - a
1516        // `record` that trusted it would append a turn and write the
1517        // conversation back into existence.
1518        let err = record(&mut stale, &talks, "still there?", Vec::new())
1519            .expect_err("a delete that landed first must be honored, not overwritten");
1520        assert!(err.to_string().contains("deleted"), "{err}");
1521
1522        assert!(
1523            talks.get(&stale.id).is_err(),
1524            "record must not resurrect a conversation deleted while its snapshot was stale"
1525        );
1526        let _ = &cfg; // config kept only to build the agent above
1527    }
1528
1529    #[test]
1530    fn a_delete_that_lands_before_close_is_called_is_not_undone_by_it() {
1531        let (tmp, talks) = store();
1532        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1533        let cfg = config(spec);
1534        // `web::talk_close` loads `talk` and calls `close` right after - this
1535        // stands in for a delete landing in that gap.
1536        let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1537
1538        talks.remove(&stale.id).expect("remove");
1539
1540        // The stale handle has no way to know the record is gone - a `close`
1541        // that fell back to it would write the conversation back into
1542        // existence, closed.
1543        let err = close(&mut stale, &talks)
1544            .expect_err("a delete that landed first must be honored, not overwritten");
1545        assert!(err.to_string().contains("deleted"), "{err}");
1546
1547        assert!(
1548            talks.get(&stale.id).is_err(),
1549            "close must not resurrect a conversation deleted while its snapshot was stale"
1550        );
1551        let _ = &cfg; // config kept only to build the agent above
1552    }
1553
1554    #[test]
1555    fn a_delete_that_lands_before_reopen_is_called_is_not_undone_by_it() {
1556        let (tmp, talks) = store();
1557        let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1558        let cfg = config(spec);
1559        // `web::talk_reopen` loads `talk` and calls `reopen` right after -
1560        // this stands in for a delete landing in that gap.
1561        let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1562        close(&mut stale, &talks).expect("close");
1563
1564        talks.remove(&stale.id).expect("remove");
1565
1566        // The stale handle has no way to know the record is gone - a
1567        // `reopen` that fell back to it would write the conversation back
1568        // into existence, open.
1569        let err = reopen(&mut stale, &talks)
1570            .expect_err("a delete that landed first must be honored, not overwritten");
1571        assert!(err.to_string().contains("deleted"), "{err}");
1572
1573        assert!(
1574            talks.get(&stale.id).is_err(),
1575            "reopen must not resurrect a conversation deleted while its snapshot was stale"
1576        );
1577        let _ = &cfg; // config kept only to build the agent above
1578    }
1579
1580    #[test]
1581    fn list_puts_open_talks_before_closed_ones() {
1582        let (tmp, talks) = store();
1583        let make = |id: &str, status: TalkStatus| {
1584            let mut t = Talk {
1585                schema: SCHEMA,
1586                id: id.to_owned(),
1587                repo: tmp.path().to_owned(),
1588                agent: "mock".to_owned(),
1589                status,
1590                turns: Vec::new(),
1591                created_at: Timestamp::now(),
1592                updated_at: Timestamp::now(),
1593                seat: SeatState::new(SEAT, "mock", 7),
1594            };
1595            talks.put(&mut t).expect("put");
1596        };
1597        make("20260901-000000-0001", TalkStatus::Open);
1598        make("20260902-000000-0002", TalkStatus::Open);
1599        make("20260903-000000-0003", TalkStatus::Closed);
1600
1601        let ids: Vec<String> = talks.list().into_iter().map(|t| t.id).collect();
1602        assert_eq!(
1603            ids,
1604            [
1605                "20260902-000000-0002",
1606                "20260901-000000-0001",
1607                "20260903-000000-0003"
1608            ]
1609        );
1610        assert_eq!(talks.count_open(), 2);
1611    }
1612
1613    #[test]
1614    fn tasks_of_finds_only_this_talks_own_tasks() {
1615        let dir = tempfile::tempdir().expect("tempdir");
1616        let queue = Queue::at(dir.path().join("queue"));
1617
1618        let mut mine = Task::new(
1619            "rework the loader".to_owned(),
1620            "rework the loader".to_owned(),
1621            PathBuf::from("/repo"),
1622            Source::Agent {
1623                run: "20260904-014455-ab12".to_owned(),
1624                node: "chat".to_owned(),
1625            },
1626        );
1627        queue.put(&mut mine).expect("put mine");
1628
1629        let mut theirs = Task::new(
1630            "unrelated".to_owned(),
1631            "unrelated".to_owned(),
1632            PathBuf::from("/repo"),
1633            Source::Agent {
1634                run: "20260904-090000-zz99".to_owned(),
1635                node: "implement".to_owned(),
1636            },
1637        );
1638        queue.put(&mut theirs).expect("put theirs");
1639
1640        let mut human = Task::new(
1641            "typed by hand".to_owned(),
1642            "typed by hand".to_owned(),
1643            PathBuf::from("/repo"),
1644            Source::Human,
1645        );
1646        queue.put(&mut human).expect("put human");
1647
1648        let found = tasks_of(&queue, "20260904-014455-ab12");
1649        assert_eq!(found.len(), 1);
1650        assert_eq!(found[0].id, mine.id);
1651    }
1652
1653    #[test]
1654    fn the_briefing_names_solo_task_add() {
1655        let brief = briefing(Path::new("/repo"), "en", false);
1656        assert!(brief.contains("magi task add --solo"));
1657        assert!(brief.contains("/repo"));
1658        assert!(!brief.contains("Hold this conversation in"));
1659    }
1660
1661    #[test]
1662    fn the_briefing_names_the_language_when_it_is_not_english() {
1663        let brief = briefing(Path::new("/repo"), "Japanese", false);
1664        assert!(brief.contains("Hold this conversation in Japanese"));
1665    }
1666
1667    #[test]
1668    fn the_briefing_forbids_writes_unless_the_repository_opted_in() {
1669        let read_only = briefing(Path::new("/repo"), "en", false);
1670        assert!(read_only.contains("Do not write files"));
1671        assert!(!read_only.contains("allow_write"));
1672
1673        let writable = briefing(Path::new("/repo"), "en", true);
1674        assert!(!writable.contains("Do not write files"));
1675        assert!(writable.contains("allow_write = true"));
1676        // Still names the queue for anything past a small named edit, and
1677        // still tells the agent to report what it changed.
1678        assert!(writable.contains("magi task add --solo"));
1679        assert!(writable.contains("say plainly what you"));
1680    }
1681}