1use 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
62pub const SCHEMA: u32 = 1;
64
65fn turn_timeout(cfg: &Config) -> Duration {
77 Duration::from_secs(cfg.graph.timeout_talk)
78}
79
80const SEAT: &str = "talk";
84
85const MAGI_NOTE: &str = "magi: ";
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum Who {
93 Operator,
95 Agent,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct Attachment {
112 pub id: String,
114 pub name: String,
116 pub mime: String,
119 pub bytes: u64,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct Turn {
127 pub who: Who,
129 pub body: String,
131 pub at: Timestamp,
133 #[serde(default)]
136 pub attachments: Vec<Attachment>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum TalkStatus {
145 Open,
148 Closed,
150}
151
152impl TalkStatus {
153 pub fn open(self) -> bool {
155 matches!(self, Self::Open)
156 }
157
158 pub fn as_str(self) -> &'static str {
160 match self {
161 Self::Open => "open",
162 Self::Closed => "closed",
163 }
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct Talk {
171 pub schema: u32,
173 pub id: String,
175 pub repo: PathBuf,
177 pub agent: String,
179 pub status: TalkStatus,
181 pub turns: Vec<Turn>,
183 pub created_at: Timestamp,
185 pub updated_at: Timestamp,
187 seat: SeatState,
193}
194
195impl Talk {
196 pub fn short(&self) -> &str {
198 short(&self.id)
199 }
200}
201
202#[derive(Debug, Clone)]
204pub struct Talks {
205 root: PathBuf,
206 lock: Arc<Mutex<()>>,
215}
216
217impl Talks {
218 pub fn open() -> Self {
220 Self::at(crate::run::home().join("talks"))
221 }
222
223 pub fn at(root: PathBuf) -> Self {
226 Self {
227 root,
228 lock: Arc::new(Mutex::new(())),
229 }
230 }
231
232 fn guard(&self) -> MutexGuard<'_, ()> {
241 self.lock.lock().unwrap_or_else(PoisonError::into_inner)
242 }
243
244 pub fn root(&self) -> &Path {
246 &self.root
247 }
248
249 pub fn path_of(&self, id: &str) -> PathBuf {
251 self.root.join(format!("{id}.json"))
252 }
253
254 pub fn artifacts_of(&self, id: &str) -> PathBuf {
257 self.root.join(format!("{id}.artifacts"))
258 }
259
260 pub fn attachments_dir(&self, id: &str) -> PathBuf {
264 self.artifacts_of(id).join("attachments")
265 }
266
267 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 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 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 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 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 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 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 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 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 pub fn count_open(&self) -> usize {
442 self.list().iter().filter(|t| t.status.open()).count()
443 }
444
445 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
471pub fn begin(store: &Talks, cfg: &Config, repo: PathBuf, agent: Option<&str>) -> Result<Talk> {
488 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
513pub fn record(
521 talk: &mut Talk,
522 store: &Talks,
523 text: &str,
524 attachments: Vec<Attachment>,
525) -> Result<String> {
526 let _guard = store.guard();
534 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
563pub 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
576pub async fn respond(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
579 turn(talk, store, cfg, text).await
580}
581
582pub 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
610pub 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
631async 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 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 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 let cache_dir = cfg.cache_dir();
692 let inv = Invocation {
693 cwd: &talk.repo,
694 prompt: &body,
695 timeout: turn_timeout(cfg),
696 allow_write: cfg.talk.allow_write,
701 sessions: cfg.graph.sessions,
702 artifacts: &artifacts,
703 stem: &stem,
704 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 let _guard = store.guard();
777 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
795fn 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
813fn 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
835pub 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
883fn 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
894pub 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
925fn 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
939pub 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
950fn 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 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 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 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1004
1005 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1007
1008 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 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 #[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 #[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 #[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 #[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 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; }
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 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1342
1343 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 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 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 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1385
1386 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 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; }
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 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; }
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 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; }
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; }
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 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 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 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1542
1543 talks.remove(&stale.id).expect("remove");
1544
1545 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; }
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 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1567
1568 talks.remove(&stale.id).expect("remove");
1569
1570 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; }
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 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 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; }
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 assert!(writable.contains("magi task add --solo"));
1710 assert!(writable.contains("say plainly what you"));
1711 }
1712}