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