1use 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
58pub const SCHEMA: u32 = 1;
60
61fn turn_timeout(cfg: &Config) -> Duration {
72 Duration::from_secs(cfg.graph.timeout_talk)
73}
74
75const SEAT: &str = "talk";
78
79const MAGI_NOTE: &str = "magi: ";
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Who {
86 Operator,
88 Agent,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct Attachment {
103 pub id: String,
105 pub name: String,
107 pub mime: String,
110 pub bytes: u64,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct Turn {
118 pub who: Who,
120 pub body: String,
122 pub at: Timestamp,
124 #[serde(default)]
127 pub attachments: Vec<Attachment>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum TalkStatus {
136 Open,
139 Closed,
141}
142
143impl TalkStatus {
144 pub fn open(self) -> bool {
146 matches!(self, Self::Open)
147 }
148
149 pub fn as_str(self) -> &'static str {
151 match self {
152 Self::Open => "open",
153 Self::Closed => "closed",
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct Talk {
162 pub schema: u32,
164 pub id: String,
166 pub repo: PathBuf,
168 pub agent: String,
170 pub status: TalkStatus,
172 pub turns: Vec<Turn>,
174 pub created_at: Timestamp,
176 pub updated_at: Timestamp,
178 seat: SeatState,
183}
184
185impl Talk {
186 pub fn short(&self) -> &str {
188 short(&self.id)
189 }
190}
191
192#[derive(Debug, Clone)]
194pub struct Talks {
195 root: PathBuf,
196 lock: Arc<Mutex<()>>,
205}
206
207impl Talks {
208 pub fn open() -> Self {
210 Self::at(crate::run::home().join("talks"))
211 }
212
213 pub fn at(root: PathBuf) -> Self {
216 Self {
217 root,
218 lock: Arc::new(Mutex::new(())),
219 }
220 }
221
222 fn guard(&self) -> MutexGuard<'_, ()> {
231 self.lock.lock().unwrap_or_else(PoisonError::into_inner)
232 }
233
234 pub fn root(&self) -> &Path {
236 &self.root
237 }
238
239 pub fn path_of(&self, id: &str) -> PathBuf {
241 self.root.join(format!("{id}.json"))
242 }
243
244 pub fn artifacts_of(&self, id: &str) -> PathBuf {
247 self.root.join(format!("{id}.artifacts"))
248 }
249
250 pub fn attachments_dir(&self, id: &str) -> PathBuf {
254 self.artifacts_of(id).join("attachments")
255 }
256
257 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 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 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 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 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 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 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 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 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 pub fn count_open(&self) -> usize {
430 self.list().iter().filter(|t| t.status.open()).count()
431 }
432
433 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
459pub fn begin(store: &Talks, cfg: &Config, repo: PathBuf, agent: Option<&str>) -> Result<Talk> {
469 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
491pub fn record(
498 talk: &mut Talk,
499 store: &Talks,
500 text: &str,
501 attachments: Vec<Attachment>,
502) -> Result<String> {
503 let _guard = store.guard();
511 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
540pub 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
553pub async fn respond(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
555 turn(talk, store, cfg, text).await
556}
557
558pub 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
586pub 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
607async 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 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 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 let cache_dir = cfg.cache_dir();
667 let inv = Invocation {
668 cwd: &talk.repo,
669 prompt: &body,
670 timeout: turn_timeout(cfg),
671 allow_write: cfg.talk.allow_write,
676 sessions: cfg.graph.sessions,
677 artifacts: &artifacts,
678 stem: &stem,
679 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 let _guard = store.guard();
752 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
770fn 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
788fn 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
809pub 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
856fn 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
866pub 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
897fn 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
911pub 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
922fn 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 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 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 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
976
977 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
979
980 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 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 #[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 #[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 #[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 #[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 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; }
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 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1312
1313 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 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 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 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1355
1356 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 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; }
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 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; }
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 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; }
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; }
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 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 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 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1512
1513 talks.remove(&stale.id).expect("remove");
1514
1515 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; }
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 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1537
1538 talks.remove(&stale.id).expect("remove");
1539
1540 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; }
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 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 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; }
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 assert!(writable.contains("magi task add --solo"));
1679 assert!(writable.contains("say plainly what you"));
1680 }
1681}