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 #[serde(default)]
177 pub pending: String,
178 #[serde(default)]
180 pub pending_attachments: Vec<Attachment>,
181 pub created_at: Timestamp,
183 pub updated_at: Timestamp,
185 seat: SeatState,
190}
191
192impl Talk {
193 pub fn short(&self) -> &str {
195 short(&self.id)
196 }
197}
198
199#[derive(Debug, Clone)]
201pub struct Talks {
202 root: PathBuf,
203 lock: Arc<Mutex<()>>,
212}
213
214impl Talks {
215 pub fn open() -> Self {
217 Self::at(crate::run::home().join("talks"))
218 }
219
220 pub fn at(root: PathBuf) -> Self {
223 Self {
224 root,
225 lock: Arc::new(Mutex::new(())),
226 }
227 }
228
229 fn guard(&self) -> MutexGuard<'_, ()> {
238 self.lock.lock().unwrap_or_else(PoisonError::into_inner)
239 }
240
241 pub fn root(&self) -> &Path {
243 &self.root
244 }
245
246 pub fn path_of(&self, id: &str) -> PathBuf {
248 self.root.join(format!("{id}.json"))
249 }
250
251 pub fn artifacts_of(&self, id: &str) -> PathBuf {
254 self.root.join(format!("{id}.artifacts"))
255 }
256
257 pub fn attachments_dir(&self, id: &str) -> PathBuf {
261 self.artifacts_of(id).join("attachments")
262 }
263
264 pub fn put_attachment(
273 &self,
274 id: &str,
275 mime: &str,
276 name: &str,
277 data: &[u8],
278 ) -> Result<Attachment> {
279 let dir = self.attachments_dir(id);
280 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
281 let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
282 let att = Attachment {
283 id: new_attachment_id(),
284 name: name.to_owned(),
285 mime: mime.to_owned(),
286 bytes: data.len() as u64,
287 };
288 std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
289 .with_context(|| format!("write attachment {}", att.id))?;
290 std::fs::write(
291 dir.join(format!("{}.json", att.id)),
292 serde_json::to_string(&att).context("serialize attachment")?,
293 )
294 .with_context(|| format!("write attachment metadata {}", att.id))?;
295 Ok(att)
296 }
297
298 pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
307 if !valid_attachment_id(att_id) {
308 return Ok(None);
309 }
310 let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
311 if !meta_path.is_file() {
312 return Ok(None);
313 }
314 let att = serde_json::from_str(
315 &std::fs::read_to_string(&meta_path)
316 .with_context(|| format!("read {}", meta_path.display()))?,
317 )
318 .with_context(|| format!("parse {}", meta_path.display()))?;
319 Ok(Some(att))
320 }
321
322 pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
326 let Some(att) = self.attachment_meta(id, att_id)? else {
327 return Ok(None);
328 };
329 let ext = attachment_ext(&att.mime).with_context(|| {
330 format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
331 })?;
332 let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
333 let data =
334 std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
335 Ok(Some((att, data)))
336 }
337
338 fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
355 let ext = attachment_ext(&att.mime)?;
356 let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
357 std::path::absolute(&path).ok()
358 }
359
360 pub fn put(&self, t: &mut Talk) -> Result<()> {
363 std::fs::create_dir_all(&self.root)
364 .with_context(|| format!("create {}", self.root.display()))?;
365 t.updated_at = Timestamp::now();
366 let body = serde_json::to_string_pretty(t).context("serialize talk")?;
367 let path = self.path_of(&t.id);
368 let tmp = path.with_extension("json.tmp");
369 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
370 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
371 Ok(())
372 }
373
374 pub fn get(&self, id: &str) -> Result<Talk> {
376 let resolved = self.resolve_id(id)?;
377 read_path(&self.path_of(&resolved))
378 }
379
380 pub fn list(&self) -> Vec<Talk> {
383 let mut all: Vec<Talk> = std::fs::read_dir(&self.root)
384 .into_iter()
385 .flatten()
386 .flatten()
387 .map(|e| e.path())
388 .filter(|p| p.extension().is_some_and(|x| x == "json"))
389 .filter_map(|p| read_path(&p).ok())
390 .collect();
391 all.sort_unstable_by(|a, b| {
392 let rank = |t: &Talk| u8::from(!t.status.open());
393 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
394 });
395 all
396 }
397
398 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
400 if self.path_of(prefix).is_file() {
401 return Ok(prefix.to_owned());
402 }
403 let hits: Vec<String> = self
404 .list()
405 .into_iter()
406 .map(|t| t.id)
407 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
408 .collect();
409 match hits.len() {
410 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
411 0 => bail!("no talk matches `{prefix}`"),
412 _ => bail!(
413 "`{prefix}` matches {} talks: {}",
414 hits.len(),
415 hits.join(", ")
416 ),
417 }
418 }
419
420 pub fn revision(&self) -> u64 {
423 std::fs::read_dir(&self.root)
424 .into_iter()
425 .flatten()
426 .flatten()
427 .filter_map(|e| e.metadata().ok())
428 .filter_map(|m| m.modified().ok())
429 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
430 .map(|d| d.as_millis() as u64)
431 .max()
432 .unwrap_or(0)
433 }
434
435 pub fn count_open(&self) -> usize {
437 self.list().iter().filter(|t| t.status.open()).count()
438 }
439
440 pub fn remove(&self, id: &str) -> Result<()> {
453 let _guard = self.guard();
454 let resolved = self.resolve_id(id)?;
455 let path = self.path_of(&resolved);
456 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
457 let artifacts = self.artifacts_of(&resolved);
458 if artifacts.is_dir() {
459 std::fs::remove_dir_all(&artifacts)
460 .with_context(|| format!("remove {}", artifacts.display()))?;
461 }
462 Ok(())
463 }
464}
465
466pub fn begin(store: &Talks, cfg: &Config, repo: PathBuf, agent: Option<&str>) -> Result<Talk> {
476 let repo = repo.canonicalize().unwrap_or(repo);
479 let want = agent.or(cfg.roles.chatter.as_deref());
480 let spec = agent::pick(&cfg.agents, want, &agent::installed)?;
481
482 let now = Timestamp::now();
483 let mut talk = Talk {
484 schema: SCHEMA,
485 id: new_id(),
486 repo,
487 agent: spec.id.clone(),
488 status: TalkStatus::Open,
489 turns: Vec::new(),
490 pending: String::new(),
491 pending_attachments: Vec::new(),
492 created_at: now,
493 updated_at: now,
494 seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
495 };
496 store.put(&mut talk)?;
497 Ok(talk)
498}
499
500pub fn record(
507 talk: &mut Talk,
508 store: &Talks,
509 text: &str,
510 attachments: Vec<Attachment>,
511) -> Result<String> {
512 let _guard = store.guard();
520 let Ok(fresh) = store.get(&talk.id) else {
525 bail!("talk {} was deleted", talk.short());
526 };
527 talk.status = fresh.status;
528 talk.pending = fresh.pending;
531 talk.pending_attachments = fresh.pending_attachments;
532 if !talk.status.open() {
533 bail!(
534 "talk {} is {} and takes no more turns",
535 talk.short(),
536 talk.status.as_str()
537 );
538 }
539 let text = text.trim();
540 if text.is_empty() && attachments.is_empty() {
541 bail!("nothing to say");
542 }
543 talk.turns.push(Turn {
544 who: Who::Operator,
545 body: text.to_owned(),
546 at: Timestamp::now(),
547 attachments,
548 });
549 store.put(talk)?;
550 Ok(text.to_owned())
551}
552
553pub fn queue(
555 talk: &mut Talk,
556 store: &Talks,
557 text: &str,
558 attachments: Vec<Attachment>,
559) -> Result<()> {
560 let text = text.trim();
561 if text.is_empty() && attachments.is_empty() {
562 bail!("nothing to say");
563 }
564 let _guard = store.guard();
565 let mut fresh = store
566 .get(&talk.id)
567 .with_context(|| format!("talk {} was deleted", talk.short()))?;
568 if !fresh.status.open() {
569 bail!(
570 "talk {} is {} and takes no more turns",
571 fresh.short(),
572 fresh.status.as_str()
573 );
574 }
575 if !text.is_empty() {
576 if fresh.pending.is_empty() {
577 fresh.pending = text.to_owned();
578 } else {
579 fresh.pending.push_str("\n\n");
580 fresh.pending.push_str(text);
581 }
582 }
583 fresh.pending_attachments.extend(attachments);
584 store.put(&mut fresh)?;
585 *talk = fresh;
586 Ok(())
587}
588
589pub fn drain(talk: &mut Talk, store: &Talks) -> Result<Option<String>> {
591 let _guard = store.guard();
592 let mut fresh = store
593 .get(&talk.id)
594 .with_context(|| format!("talk {} was deleted", talk.short()))?;
595 if !fresh.status.open() || (fresh.pending.is_empty() && fresh.pending_attachments.is_empty()) {
596 *talk = fresh;
597 return Ok(None);
598 }
599 let text = std::mem::take(&mut fresh.pending);
600 let attachments = std::mem::take(&mut fresh.pending_attachments);
601 fresh.turns.push(Turn {
602 who: Who::Operator,
603 body: text.clone(),
604 at: Timestamp::now(),
605 attachments,
606 });
607 store.put(&mut fresh)?;
608 *talk = fresh;
609 Ok(Some(text))
610}
611
612pub async fn say(
615 talk: &mut Talk,
616 store: &Talks,
617 cfg: &Config,
618 text: &str,
619 attachments: Vec<Attachment>,
620) -> Result<()> {
621 let text = record(talk, store, text, attachments)?;
622 turn(talk, store, cfg, &text).await
623}
624
625pub async fn respond(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
627 turn(talk, store, cfg, text).await
628}
629
630pub fn close(talk: &mut Talk, store: &Talks) -> Result<()> {
648 let _guard = store.guard();
649 let mut fresh = store
650 .get(&talk.id)
651 .with_context(|| format!("talk {} was deleted", talk.short()))?;
652 fresh.status = TalkStatus::Closed;
653 fresh.pending.clear();
655 fresh.pending_attachments.clear();
656 store.put(&mut fresh)?;
657 *talk = fresh;
658 Ok(())
659}
660
661pub fn reopen(talk: &mut Talk, store: &Talks) -> Result<()> {
672 let _guard = store.guard();
673 let mut fresh = store
674 .get(&talk.id)
675 .with_context(|| format!("talk {} was deleted", talk.short()))?;
676 fresh.status = TalkStatus::Open;
677 store.put(&mut fresh)?;
678 *talk = fresh;
679 Ok(())
680}
681
682pub fn clear_pending(talk: &mut Talk, store: &Talks) -> Result<()> {
684 let _guard = store.guard();
685 let mut fresh = store
686 .get(&talk.id)
687 .with_context(|| format!("talk {} was deleted", talk.short()))?;
688 fresh.pending.clear();
689 fresh.pending_attachments.clear();
690 store.put(&mut fresh)?;
691 *talk = fresh;
692 Ok(())
693}
694
695pub fn clear_pending_if_matches(
697 talk: &mut Talk,
698 store: &Talks,
699 expected_text: &str,
700 expected_attachments: &[String],
701) -> Result<bool> {
702 let _guard = store.guard();
703 let mut fresh = store
704 .get(&talk.id)
705 .with_context(|| format!("talk {} was deleted", talk.short()))?;
706 if !pending_matches(&fresh, expected_text, expected_attachments) {
707 *talk = fresh;
708 return Ok(false);
709 }
710 fresh.pending.clear();
711 fresh.pending_attachments.clear();
712 store.put(&mut fresh)?;
713 *talk = fresh;
714 Ok(true)
715}
716
717pub fn edit_pending_text(
721 talk: &mut Talk,
722 store: &Talks,
723 text: &str,
724 expected_text: &str,
725 expected_attachments: &[String],
726) -> Result<bool> {
727 let _guard = store.guard();
728 let mut fresh = store
729 .get(&talk.id)
730 .with_context(|| format!("talk {} was deleted", talk.short()))?;
731 if !pending_matches(&fresh, expected_text, expected_attachments) {
732 *talk = fresh;
733 return Ok(false);
734 }
735 fresh.pending = text.trim().to_owned();
736 store.put(&mut fresh)?;
737 *talk = fresh;
738 Ok(true)
739}
740
741fn pending_matches(talk: &Talk, expected_text: &str, expected_attachments: &[String]) -> bool {
742 talk.pending == expected_text
743 && talk
744 .pending_attachments
745 .iter()
746 .map(|attachment| &attachment.id)
747 .eq(expected_attachments.iter())
748}
749
750async fn turn(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
757 let spec = cfg
758 .agents
759 .iter()
760 .find(|a| a.id == talk.agent)
761 .with_context(|| {
762 format!(
763 "talk {} was opened with agent `{}`, which is no longer in \
764 the roster; restore it in magi.toml or start a new \
765 conversation",
766 talk.short(),
767 talk.agent
768 )
769 })?;
770
771 let resuming = agent::has_session(spec.kind, &talk.seat, cfg.graph.sessions);
772 let last_note = attachment_note(
776 store,
777 &talk.id,
778 talk.turns
779 .last()
780 .map_or(&[][..], |t| t.attachments.as_slice()),
781 );
782 let body = if talk.seat.turns == 0 {
783 format!(
784 "{}\n\n# Operator\n\n{text}{last_note}",
785 briefing(&talk.repo, &cfg.graph.language, cfg.talk.allow_write)
786 )
787 } else if resuming {
788 format!("{text}{last_note}")
789 } else {
790 format!("{}\n\n{text}{last_note}", transcript(talk, store))
791 };
792
793 let attachment_paths: Vec<PathBuf> = talk
799 .turns
800 .iter()
801 .flat_map(|t| t.attachments.iter())
802 .filter_map(|a| store.attachment_path(&talk.id, a))
803 .collect();
804
805 let artifacts = store.artifacts_of(&talk.id);
806 let stem = format!("turn-{}", talk.seat.turns + 1);
807 let cache_dir = cfg.cache_dir();
810 let inv = Invocation {
811 cwd: &talk.repo,
812 prompt: &body,
813 timeout: turn_timeout(cfg),
814 allow_write: cfg.talk.allow_write,
819 sessions: cfg.graph.sessions,
820 artifacts: &artifacts,
821 stem: &stem,
822 run: &talk.id,
825 node: "chat",
826 cache_dir: cache_dir.as_deref(),
827 attachments: &attachment_paths,
828 };
829
830 let outcome = agent::invoke(spec, &mut talk.seat, &inv).await;
831 let note = |why: String| Turn {
832 who: Who::Agent,
833 body: format!("{MAGI_NOTE}{why}"),
834 at: Timestamp::now(),
835 attachments: Vec::new(),
836 };
837 let (reply, failure) = match outcome {
838 Err(e) => (
839 note(format!("could not run agent `{}`: {e}", talk.agent)),
840 Some(format!("could not run agent `{}`: {e}", talk.agent)),
841 ),
842 Ok(out) if out.quota_exhausted() => {
843 let reset = out
844 .quota
845 .as_ref()
846 .and_then(|q| q.reset.clone())
847 .map_or_else(String::new, |r| format!(" (resets {r})"));
848 let why = format!(
849 "agent `{}` is out of quota{reset}; your message is saved, so \
850 say it again when the window reopens",
851 talk.agent
852 );
853 (note(why.clone()), Some(why))
854 }
855 Ok(out) if out.timed_out => {
856 let why = format!(
857 "agent `{}` did not answer within {}s; your message is saved",
858 talk.agent,
859 turn_timeout(cfg).as_secs()
860 );
861 (note(why.clone()), Some(why))
862 }
863 Ok(out) if !out.usable() => {
864 let why = format!(
865 "agent `{}` produced no answer (exit {}); your message is saved",
866 talk.agent,
867 out.exit_code
868 .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
869 );
870 (note(why.clone()), Some(why))
871 }
872 Ok(out) => (
873 Turn {
874 who: Who::Agent,
875 body: out.text.trim().to_owned(),
876 at: Timestamp::now(),
877 attachments: Vec::new(),
878 },
879 None,
880 ),
881 };
882
883 let _guard = store.guard();
895 let Ok(fresh) = store.get(&talk.id) else {
901 return Ok(());
902 };
903 talk.status = fresh.status;
904 talk.pending = fresh.pending;
908 talk.pending_attachments = fresh.pending_attachments;
909 talk.turns.push(reply);
910 store.put(talk)?;
911
912 match failure {
913 Some(why) => bail!("{why}"),
914 None => Ok(()),
915 }
916}
917
918fn transcript(talk: &Talk, store: &Talks) -> String {
921 let mut out = String::from(
922 "This conversation cannot resume on the CLI's side, so here is \
923 everything said so far; answer only the last message.\n",
924 );
925 for t in &talk.turns {
926 let who = match t.who {
927 Who::Operator => "operator",
928 Who::Agent => "you",
929 };
930 out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
931 out.push_str(&attachment_note(store, &talk.id, &t.attachments));
932 }
933 out
934}
935
936fn attachment_note(store: &Talks, talk_id: &str, attachments: &[Attachment]) -> String {
941 if attachments.is_empty() {
942 return String::new();
943 }
944 let mut out = String::from(
945 "\n\nThe operator attached the image(s) below to this message. Open \
946 and look at each one before you answer.\n",
947 );
948 for att in attachments {
949 if let Some(path) = store.attachment_path(talk_id, att) {
950 out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
951 }
952 }
953 out.push('\n');
954 out
955}
956
957pub fn briefing(repo: &Path, language: &str, allow_write: bool) -> String {
979 let write_policy = if allow_write {
980 "Write access is enabled for this conversation (`allow_write = \
981 true`), so you may write files - but only a small, \
982 already-decided edit the operator names outright in this \
983 conversation, not an implementation. This is a permission on the \
984 conversation as a whole, not a property of whichever repository \
985 it happened to start in: if the operator names a different \
986 repository for that small edit, the policy allows it there too. \
987 Your own tool may still confine writes to the repository this \
988 conversation started in regardless - if a write elsewhere is \
989 refused, say so plainly rather than working around it. Once you \
990 have made an edit, say plainly what you edited. Anything bigger, \
991 or anything still open-ended, still goes through the queue below \
992 rather than being done here."
993 } else {
994 "Do not write files. Implementing a change is not this \
995 conversation's job; a separate, blind competition of agents does \
996 that, and a repository this conversation has already edited would \
997 make their diffs unjudgeable."
998 };
999 let mut out = format!(
1000 "You are magi's standing conversation partner for its operator, who \
1001 usually has this open on a phone. Keep replies short: no preamble, \
1002 no restating what they just said.\n\n\
1003 # Repository\n\n{repo}\n\n\
1004 You may look around: read files, run shell commands, search history, \
1005 run tests - whatever answers the question. {write_policy}\n\n\
1006 A short, command-shaped message (\"list\", \"info <id>\", \"show \
1007 3cbf\") is almost always the operator asking you to look something \
1008 up, not an instruction to file - answer it yourself with `magi \
1009 list`, `magi show <id>`, `magi task list`, or the like, the same way \
1010 you would answer any other question in this conversation.\n\n\
1011 # When the operator wants something done\n\n\
1012 Run:\n\n\
1013 magi task add --solo --repo {repo} <instruction>\n\n\
1014 and tell the operator the task id it prints, so they can follow it \
1015 from the Queue. Write <instruction> so that an implementer who has \
1016 never seen this conversation can act on it alone - it is everything \
1017 they get. Use --solo: it runs the task through one implementer \
1018 straight into review instead of the usual multi-agent competition, \
1019 which is the right shape for a change this conversation has already \
1020 settled, rather than one still worth several independent takes.\n\n\
1021 If the operator asks for something in a different repository, \
1022 --repo does not have to be a full path: --repo owner/repo (or just \
1023 repo, when that is unambiguous) is resolved against local checkouts \
1024 the same way `magi repos` lists them. If the command fails because \
1025 nothing matches or more than one checkout shares that name, ask the \
1026 operator which repository they mean (or run `magi repos` yourself \
1027 to see the candidates) rather than guessing.\n",
1028 repo = repo.display(),
1029 );
1030 out.push_str(&language_note(language));
1031 out
1032}
1033
1034fn language_note(language: &str) -> String {
1037 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
1038 String::new()
1039 } else {
1040 format!("\nHold this conversation in {language}.\n")
1041 }
1042}
1043
1044pub fn tasks_of(queue: &Queue, talk_id: &str) -> Vec<Task> {
1051 let mut tasks: Vec<Task> = queue
1052 .list()
1053 .into_iter()
1054 .filter(|t| matches!(&t.source, Source::Agent { run, .. } if run == talk_id))
1055 .collect();
1056 tasks.sort_unstable_by(|a, b| a.id.cmp(&b.id));
1057 tasks
1058}
1059
1060fn read_path(path: &Path) -> Result<Talk> {
1061 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1062 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
1063}
1064
1065fn short(id: &str) -> &str {
1066 id.split('-').next_back().unwrap_or(id)
1067}
1068
1069fn new_id() -> String {
1070 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
1071 let seed = crate::rng::entropy();
1072 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
1073}
1074
1075fn attachment_ext(mime: &str) -> Option<&'static str> {
1080 match mime {
1081 "image/png" => Some("png"),
1082 "image/jpeg" => Some("jpg"),
1083 "image/gif" => Some("gif"),
1084 "image/webp" => Some("webp"),
1085 _ => None,
1086 }
1087}
1088
1089pub fn valid_attachment_id(id: &str) -> bool {
1094 id.len() == 32
1095 && id
1096 .bytes()
1097 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1098}
1099
1100fn new_attachment_id() -> String {
1104 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
1105 format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use std::collections::BTreeMap;
1111
1112 use crate::config::{AgentKind, AgentSpec, Graph};
1113 use crate::queue::{Queue, Source, Task};
1114
1115 use super::*;
1116
1117 fn store() -> (tempfile::TempDir, Talks) {
1119 let tmp = tempfile::tempdir().expect("tempdir");
1120 let talks = Talks::at(tmp.path().join("talks"));
1121 (tmp, talks)
1122 }
1123
1124 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
1128 let path = dir.join("mock-talk-agent.sh");
1129 std::fs::write(&path, script).expect("write mock");
1130 AgentSpec {
1131 id: "mock".to_owned(),
1132 kind: AgentKind::Command,
1133 model: None,
1134 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1135 extra_args: Vec::new(),
1136 env,
1137 prompt_delivery: None,
1138 }
1139 }
1140
1141 fn config(spec: AgentSpec) -> Config {
1142 Config {
1143 agents: vec![spec],
1144 graph: Graph {
1145 language: "en".to_owned(),
1146 ..Graph::default()
1147 },
1148 ..Config::default()
1149 }
1150 }
1151
1152 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1154
1155 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1157
1158 const ECHO: &str = "#!/bin/sh\ncat\n";
1161
1162 fn env(reply: &str) -> BTreeMap<String, String> {
1163 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
1164 }
1165
1166 #[test]
1167 fn the_frozen_json_field_names_round_trip_through_disk() {
1168 let (tmp, talks) = store();
1169 let mut talk = Talk {
1170 schema: SCHEMA,
1171 id: "20260904-014455-ab12".to_owned(),
1172 repo: tmp.path().to_owned(),
1173 agent: "sonnet".to_owned(),
1174 status: TalkStatus::Open,
1175 turns: Vec::new(),
1176 pending: String::new(),
1177 pending_attachments: Vec::new(),
1178 created_at: Timestamp::now(),
1179 updated_at: Timestamp::now(),
1180 seat: SeatState::new(SEAT, "sonnet", 7),
1181 };
1182 talks.put(&mut talk).expect("put");
1183
1184 let raw = std::fs::read_to_string(talks.path_of(&talk.id)).expect("read back");
1185 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1186 for field in [
1187 "schema",
1188 "id",
1189 "repo",
1190 "agent",
1191 "status",
1192 "turns",
1193 "created_at",
1194 "updated_at",
1195 ] {
1196 assert!(v.get(field).is_some(), "missing field `{field}`");
1197 }
1198 assert_eq!(v["schema"], 1);
1199 assert_eq!(v["status"], "open");
1200
1201 let back = talks.get(&talk.id).expect("get");
1202 assert_eq!(back.id, talk.id);
1203 assert_eq!(back.status, TalkStatus::Open);
1204 }
1205
1206 #[test]
1207 fn opening_a_talk_takes_no_agent_turn() {
1208 let (tmp, talks) = store();
1209 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1213 let cfg = config(spec);
1214
1215 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1216 assert_eq!(talk.status, TalkStatus::Open);
1217 assert!(talk.turns.is_empty(), "nothing has been said yet");
1218
1219 let on_disk = talks.get(&talk.id).expect("get");
1220 assert_eq!(on_disk.turns.len(), 0);
1221 }
1222
1223 #[test]
1231 fn chatter_wins_when_set_and_falls_back_to_pick_s_default_order_otherwise() {
1232 let (tmp, talks) = store();
1233 let first_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1234 let mut chatter_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1235 chatter_spec.id = "chatter-mock".to_owned();
1236
1237 let mut cfg = Config {
1238 agents: vec![first_spec.clone(), chatter_spec.clone()],
1239 graph: Graph {
1240 language: "en".to_owned(),
1241 ..Graph::default()
1242 },
1243 ..Config::default()
1244 };
1245 cfg.roles.chatter = Some(chatter_spec.id.clone());
1246
1247 let talk =
1248 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter set");
1249 assert_eq!(talk.agent, chatter_spec.id, "an explicit chatter must win");
1250
1251 cfg.roles.chatter = None;
1252 let fallback =
1253 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter unset");
1254 assert_eq!(
1255 fallback.agent, first_spec.id,
1256 "unset chatter must fall back to agent::pick's own default order"
1257 );
1258 }
1259
1260 #[test]
1263 fn a_talk_recorded_without_attachments_still_reads() {
1264 let (tmp, talks) = store();
1265 let path = talks.path_of("20260904-014455-ab12");
1266 std::fs::create_dir_all(talks.root()).expect("talks dir");
1267 std::fs::write(
1268 &path,
1269 serde_json::json!({
1270 "schema": 1,
1271 "id": "20260904-014455-ab12",
1272 "repo": tmp.path(),
1273 "agent": "sonnet",
1274 "status": "open",
1275 "turns": [
1276 { "who": "operator", "body": "still there?",
1277 "at": Timestamp::now().to_string() },
1278 ],
1279 "created_at": Timestamp::now().to_string(),
1280 "updated_at": Timestamp::now().to_string(),
1281 "seat": SeatState::new(SEAT, "sonnet", 7),
1282 })
1283 .to_string(),
1284 )
1285 .expect("write pre-attachments talk");
1286
1287 let talk = talks.get("20260904-014455-ab12").expect("must still read");
1288 assert!(talk.turns[0].attachments.is_empty());
1289 }
1290
1291 #[test]
1292 fn queued_text_is_durable_combined_and_drained_as_one_operator_turn() {
1293 let (tmp, talks) = store();
1294 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
1295 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1296
1297 queue(&mut talk, &talks, "first", Vec::new()).expect("queue first");
1298 queue(&mut talk, &talks, "second", Vec::new()).expect("queue second");
1299 let saved = talks.get(&talk.id).expect("reload queued talk");
1300 assert_eq!(saved.pending, "first\n\nsecond");
1301 assert!(saved.turns.is_empty(), "a draft is not a transcript turn");
1302
1303 let drained = drain(&mut talk, &talks).expect("drain");
1304 assert_eq!(drained.as_deref(), Some("first\n\nsecond"));
1305 let saved = talks.get(&talk.id).expect("reload drained talk");
1306 assert!(saved.pending.is_empty());
1307 assert_eq!(saved.turns.len(), 1);
1308 assert_eq!(saved.turns[0].body, "first\n\nsecond");
1309 }
1310
1311 #[test]
1312 fn editing_a_queued_draft_preserves_its_attachments_and_rejects_a_stale_snapshot() {
1313 let (tmp, talks) = store();
1314 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
1315 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1316 let attachment = Attachment {
1317 id: "a".repeat(32),
1318 name: "shot.png".to_owned(),
1319 mime: "image/png".to_owned(),
1320 bytes: 3,
1321 };
1322
1323 queue(&mut talk, &talks, "first", vec![attachment.clone()]).expect("queue");
1324 assert!(
1325 edit_pending_text(
1326 &mut talk,
1327 &talks,
1328 "corrected",
1329 "first",
1330 std::slice::from_ref(&attachment.id),
1331 )
1332 .expect("edit")
1333 );
1334 let saved = talks.get(&talk.id).expect("reload edited draft");
1335 assert_eq!(saved.pending, "corrected");
1336 assert_eq!(saved.pending_attachments, vec![attachment]);
1337
1338 queue(&mut talk, &talks, "later", Vec::new()).expect("queue concurrent draft");
1339 assert!(
1340 !edit_pending_text(
1341 &mut talk,
1342 &talks,
1343 "stale edit",
1344 "corrected",
1345 &["a".repeat(32)],
1346 )
1347 .expect("stale edit is a conflict")
1348 );
1349 assert_eq!(
1350 talks.get(&talk.id).expect("reload after conflict").pending,
1351 "corrected\n\nlater"
1352 );
1353 assert!(
1354 !clear_pending_if_matches(&mut talk, &talks, "corrected", &["a".repeat(32)])
1355 .expect("stale clear is a conflict")
1356 );
1357 assert_eq!(
1358 talks
1359 .get(&talk.id)
1360 .expect("reload after stale clear")
1361 .pending,
1362 "corrected\n\nlater"
1363 );
1364 }
1365
1366 #[tokio::test]
1367 async fn a_reply_save_preserves_pending_accepted_while_the_cli_runs() {
1368 let (tmp, talks) = store();
1369 let slow = "#!/bin/sh\ncat >/dev/null\nsleep 0.1\nprintf reply\n";
1370 let cfg = config(mock_agent(tmp.path(), slow, BTreeMap::new()));
1371 let mut running = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1372 let id = running.id.clone();
1373 let first = record(&mut running, &talks, "first", Vec::new()).expect("record");
1374
1375 let response_talks = talks.clone();
1376 let response_cfg = cfg.clone();
1377 let reply = tokio::spawn(async move {
1378 respond(&mut running, &response_talks, &response_cfg, &first).await
1379 });
1380 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1381
1382 let mut queued = talks.get(&id).expect("queued handle");
1383 queue(&mut queued, &talks, "next", Vec::new()).expect("queue");
1384 reply.await.expect("join").expect("reply");
1385
1386 let saved = talks.get(&id).expect("reload");
1387 assert_eq!(saved.pending, "next");
1388 assert_eq!(saved.turns.len(), 2, "operator message and reply remain");
1389 }
1390
1391 #[tokio::test]
1392 async fn the_first_turn_carries_the_briefing_and_later_turns_do_not() {
1393 let (tmp, talks) = store();
1394 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1395 let cfg = config(spec);
1396 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1397
1398 say(
1399 &mut talk,
1400 &talks,
1401 &cfg,
1402 "what does the queue module do?",
1403 Vec::new(),
1404 )
1405 .await
1406 .expect("first turn");
1407 let first_prompt = &talk.turns[1].body;
1408 assert!(first_prompt.contains("magi task add --solo"));
1409 assert!(first_prompt.contains("what does the queue module do?"));
1410
1411 say(&mut talk, &talks, &cfg, "and how is it locked?", Vec::new())
1412 .await
1413 .expect("second turn");
1414 let second_prompt = &talk.turns[3].body;
1415 assert!(
1416 !second_prompt.contains("magi task add --solo"),
1417 "the briefing is sent once, not on every turn: {second_prompt}"
1418 );
1419 assert!(second_prompt.contains("and how is it locked?"));
1420 }
1421
1422 #[tokio::test]
1423 async fn say_appends_the_operator_turn_then_the_agent_turn() {
1424 let (tmp, talks) = store();
1425 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
1426 let cfg = config(spec);
1427 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1428
1429 say(
1430 &mut talk,
1431 &talks,
1432 &cfg,
1433 "can I rename this function?",
1434 Vec::new(),
1435 )
1436 .await
1437 .expect("say");
1438
1439 assert_eq!(talk.turns.len(), 2);
1440 assert_eq!(talk.turns[0].who, Who::Operator);
1441 assert_eq!(talk.turns[0].body, "can I rename this function?");
1442 assert_eq!(talk.turns[1].who, Who::Agent);
1443 assert_eq!(talk.turns[1].body, "go ahead");
1444 assert_eq!(talks.get(&talk.id).expect("get").turns, talk.turns);
1445 }
1446
1447 #[tokio::test]
1448 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1449 let (tmp, talks) = store();
1450 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1451 let cfg = config(spec);
1452 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1453
1454 let err = say(&mut talk, &talks, &cfg, "check the tests", Vec::new())
1455 .await
1456 .expect_err("a turn with no answer is an error");
1457 assert!(err.to_string().contains("no answer"), "{err}");
1458
1459 let on_disk = talks.get(&talk.id).expect("get");
1460 assert_eq!(on_disk.turns.len(), 2);
1461 assert_eq!(on_disk.turns[0].body, "check the tests");
1462 let note = &on_disk.turns[1];
1463 assert_eq!(note.who, Who::Agent);
1464 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
1465 assert!(note.body.contains("your message is saved"));
1466 }
1467
1468 #[tokio::test]
1472 async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
1473 let (tmp, talks) = store();
1474 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1475 let cfg = config(spec);
1476 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1477
1478 let att = talks
1479 .put_attachment(
1480 &talk.id,
1481 "image/png",
1482 "screenshot.png",
1483 b"pretend-png-bytes",
1484 )
1485 .expect("put attachment");
1486
1487 say(&mut talk, &talks, &cfg, "", vec![att.clone()])
1488 .await
1489 .expect("an empty body with an attachment is still a turn");
1490
1491 let operator_turn = &talk.turns[0];
1492 assert_eq!(operator_turn.who, Who::Operator);
1493 assert_eq!(operator_turn.body, "");
1494 assert_eq!(operator_turn.attachments, vec![att.clone()]);
1495
1496 let prompt = &talk.turns[1].body;
1497 let expected_path = talks
1498 .attachments_dir(&talk.id)
1499 .join(format!("{}.png", att.id));
1500 assert!(
1501 prompt.contains(&expected_path.display().to_string()),
1502 "the agent must be told the attachment's absolute path: {prompt}"
1503 );
1504 assert!(prompt.contains("image/png"), "and its mime: {prompt}");
1505 }
1506
1507 #[test]
1516 fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
1517 let talks = Talks::at(PathBuf::from("relative-talks-root-for-this-test"));
1518 let att = Attachment {
1519 id: "0".repeat(32),
1520 name: "shot.png".to_owned(),
1521 mime: "image/png".to_owned(),
1522 bytes: 3,
1523 };
1524 let path = talks
1525 .attachment_path("some-talk-id", &att)
1526 .expect("a supported mime always yields a path");
1527 assert!(
1528 path.is_absolute(),
1529 "must be absolute even off a relative store root: {}",
1530 path.display()
1531 );
1532 }
1533
1534 #[tokio::test]
1535 async fn a_turn_past_the_configured_talk_timeout_is_reported_with_that_timeout() {
1536 let (tmp, talks) = store();
1541 let slow = mock_agent(
1542 tmp.path(),
1543 "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
1544 BTreeMap::new(),
1545 );
1546 let mut cfg = config(slow);
1547 cfg.graph.timeout_talk = 1;
1548 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1549
1550 let err = say(&mut talk, &talks, &cfg, "check the tests", Vec::new())
1551 .await
1552 .expect_err("a turn that never answers is an error");
1553 assert!(
1554 err.to_string().contains("did not answer within 1s"),
1555 "{err}"
1556 );
1557
1558 let on_disk = talks.get(&talk.id).expect("get");
1559 let note = on_disk.turns.last().expect("a note turn was recorded");
1560 assert!(
1561 note.body.contains("did not answer within 1s"),
1562 "the transcript must show the configured timeout: {}",
1563 note.body
1564 );
1565 }
1566
1567 #[test]
1568 fn closing_is_idempotent_and_a_closed_talk_takes_no_more_turns() {
1569 let (tmp, talks) = store();
1570 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1571 let cfg = config(spec);
1572 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1573
1574 close(&mut talk, &talks).expect("close");
1575 assert_eq!(talk.status, TalkStatus::Closed);
1576 close(&mut talk, &talks).expect("closing twice is not an error");
1577
1578 let err =
1579 record(&mut talk, &talks, "still there?", Vec::new()).expect_err("closed talks refuse");
1580 assert!(err.to_string().contains("closed"));
1581 let _ = &cfg; }
1583
1584 #[tokio::test]
1585 async fn a_close_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1586 let (tmp, talks) = store();
1587 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1588 let cfg = config(spec);
1589 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1592
1593 let mut closed_elsewhere = talks.get(&in_flight.id).expect("reread");
1597 close(&mut closed_elsewhere, &talks).expect("close");
1598 assert_eq!(
1599 talks.get(&in_flight.id).expect("reread").status,
1600 TalkStatus::Closed,
1601 "the close landed on disk before the turn finished"
1602 );
1603
1604 assert_eq!(in_flight.status, TalkStatus::Open);
1608 respond(&mut in_flight, &talks, &cfg, "one more question")
1609 .await
1610 .expect("the turn itself still completes");
1611
1612 let on_disk = talks.get(&in_flight.id).expect("reread");
1613 assert_eq!(
1614 on_disk.status,
1615 TalkStatus::Closed,
1616 "a close must stick even when a turn that started before it finishes after it"
1617 );
1618 assert!(
1621 on_disk.turns.iter().any(|t| t.body == "here you go"),
1622 "the in-flight turn's own reply is still recorded: {:?}",
1623 on_disk.turns
1624 );
1625 }
1626
1627 #[test]
1628 fn a_close_that_lands_before_record_is_called_is_not_undone_by_it() {
1629 let (tmp, talks) = store();
1630 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1631 let cfg = config(spec);
1632 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1635
1636 let mut closed_elsewhere = talks.get(&stale.id).expect("reread");
1639 close(&mut closed_elsewhere, &talks).expect("close");
1640 assert_eq!(
1641 talks.get(&stale.id).expect("reread").status,
1642 TalkStatus::Closed,
1643 "the close landed on disk before record was called"
1644 );
1645
1646 assert_eq!(stale.status, TalkStatus::Open);
1650 let err = record(&mut stale, &talks, "still there?", Vec::new())
1651 .expect_err("a close that landed first must be honored, not overwritten");
1652 assert!(err.to_string().contains("closed"));
1653
1654 let on_disk = talks.get(&stale.id).expect("reread");
1655 assert_eq!(
1656 on_disk.status,
1657 TalkStatus::Closed,
1658 "record must not resurrect a conversation closed while its snapshot was stale"
1659 );
1660 assert!(
1661 on_disk.turns.is_empty(),
1662 "the rejected turn must not have been appended: {:?}",
1663 on_disk.turns
1664 );
1665 let _ = &cfg; }
1667
1668 #[test]
1669 fn close_blocks_on_records_guard_rather_than_interleaving_with_it() {
1670 let (tmp, talks) = store();
1671 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1672 let cfg = config(spec);
1673 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1674
1675 let held = talks.guard();
1679
1680 let talks2 = talks.clone();
1681 let id = talk.id.clone();
1682 let closing = std::thread::spawn(move || {
1683 let mut talk = talks2.get(&id).expect("get");
1684 close(&mut talk, &talks2).expect("close");
1685 });
1686
1687 std::thread::sleep(Duration::from_millis(50));
1688 assert!(
1689 !closing.is_finished(),
1690 "close must wait for the guard, not read and write while it is held - \
1691 a re-read alone narrows this window without closing it"
1692 );
1693
1694 drop(held);
1695 closing.join().expect("close thread panicked");
1696
1697 assert_eq!(
1698 talks.get(&talk.id).expect("reread").status,
1699 TalkStatus::Closed,
1700 "once the guard is free, close still lands"
1701 );
1702 let _ = &cfg; }
1704
1705 #[test]
1706 fn reopening_a_closed_talk_lets_it_take_turns_again_and_reopening_twice_is_not_an_error() {
1707 let (tmp, talks) = store();
1708 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1709 let cfg = config(spec);
1710 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1711
1712 close(&mut talk, &talks).expect("close");
1713 assert_eq!(talk.status, TalkStatus::Closed);
1714
1715 reopen(&mut talk, &talks).expect("reopen");
1716 assert_eq!(talk.status, TalkStatus::Open);
1717 assert_eq!(
1718 talks.get(&talk.id).expect("reread").status,
1719 TalkStatus::Open
1720 );
1721
1722 reopen(&mut talk, &talks).expect("reopening an open talk is not an error");
1724 assert_eq!(talk.status, TalkStatus::Open);
1725
1726 record(&mut talk, &talks, "one more thing", Vec::new())
1727 .expect("a reopened talk takes turns again");
1728 let _ = &cfg; }
1730
1731 #[test]
1732 fn removing_a_talk_deletes_its_record_and_artifacts_and_refuses_an_unknown_id() {
1733 let (tmp, talks) = store();
1734 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1735 let cfg = config(spec);
1736 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1737
1738 let artifacts = talks.artifacts_of(&talk.id);
1739 std::fs::create_dir_all(&artifacts).expect("create artifacts dir");
1740 std::fs::write(artifacts.join("turn-1.txt"), "hello").expect("write artifact");
1741
1742 talks.remove(&talk.id).expect("remove");
1743 assert!(!talks.path_of(&talk.id).is_file(), "the record is gone");
1744 assert!(!artifacts.is_dir(), "the artifacts directory is gone");
1745 assert!(
1746 talks.get(&talk.id).is_err(),
1747 "a removed talk cannot be read back"
1748 );
1749
1750 let err = talks
1751 .remove("nonexistent-id")
1752 .expect_err("unknown id refused");
1753 assert!(err.to_string().contains("no talk matches"), "{err}");
1754 let _ = &cfg; }
1756
1757 #[tokio::test]
1758 async fn a_delete_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1759 let (tmp, talks) = store();
1760 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1761 let cfg = config(spec);
1762 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1765
1766 talks.remove(&in_flight.id).expect("remove");
1767 assert!(
1768 talks.get(&in_flight.id).is_err(),
1769 "the delete landed on disk before the turn finished"
1770 );
1771
1772 respond(&mut in_flight, &talks, &cfg, "one more question")
1775 .await
1776 .expect("the turn itself still completes rather than erroring");
1777
1778 assert!(
1779 talks.get(&in_flight.id).is_err(),
1780 "a delete must stick even when a turn that started before it finishes after it"
1781 );
1782 }
1783
1784 #[test]
1785 fn a_delete_that_lands_before_record_is_called_is_not_undone_by_it() {
1786 let (tmp, talks) = store();
1787 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1788 let cfg = config(spec);
1789 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1792
1793 talks.remove(&stale.id).expect("remove");
1794
1795 let err = record(&mut stale, &talks, "still there?", Vec::new())
1799 .expect_err("a delete that landed first must be honored, not overwritten");
1800 assert!(err.to_string().contains("deleted"), "{err}");
1801
1802 assert!(
1803 talks.get(&stale.id).is_err(),
1804 "record must not resurrect a conversation deleted while its snapshot was stale"
1805 );
1806 let _ = &cfg; }
1808
1809 #[test]
1810 fn a_delete_that_lands_before_close_is_called_is_not_undone_by_it() {
1811 let (tmp, talks) = store();
1812 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1813 let cfg = config(spec);
1814 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1817
1818 talks.remove(&stale.id).expect("remove");
1819
1820 let err = close(&mut stale, &talks)
1824 .expect_err("a delete that landed first must be honored, not overwritten");
1825 assert!(err.to_string().contains("deleted"), "{err}");
1826
1827 assert!(
1828 talks.get(&stale.id).is_err(),
1829 "close must not resurrect a conversation deleted while its snapshot was stale"
1830 );
1831 let _ = &cfg; }
1833
1834 #[test]
1835 fn a_delete_that_lands_before_reopen_is_called_is_not_undone_by_it() {
1836 let (tmp, talks) = store();
1837 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1838 let cfg = config(spec);
1839 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1842 close(&mut stale, &talks).expect("close");
1843
1844 talks.remove(&stale.id).expect("remove");
1845
1846 let err = reopen(&mut stale, &talks)
1850 .expect_err("a delete that landed first must be honored, not overwritten");
1851 assert!(err.to_string().contains("deleted"), "{err}");
1852
1853 assert!(
1854 talks.get(&stale.id).is_err(),
1855 "reopen must not resurrect a conversation deleted while its snapshot was stale"
1856 );
1857 let _ = &cfg; }
1859
1860 #[test]
1861 fn list_puts_open_talks_before_closed_ones() {
1862 let (tmp, talks) = store();
1863 let make = |id: &str, status: TalkStatus| {
1864 let mut t = Talk {
1865 schema: SCHEMA,
1866 id: id.to_owned(),
1867 repo: tmp.path().to_owned(),
1868 agent: "mock".to_owned(),
1869 status,
1870 turns: Vec::new(),
1871 pending: String::new(),
1872 pending_attachments: Vec::new(),
1873 created_at: Timestamp::now(),
1874 updated_at: Timestamp::now(),
1875 seat: SeatState::new(SEAT, "mock", 7),
1876 };
1877 talks.put(&mut t).expect("put");
1878 };
1879 make("20260901-000000-0001", TalkStatus::Open);
1880 make("20260902-000000-0002", TalkStatus::Open);
1881 make("20260903-000000-0003", TalkStatus::Closed);
1882
1883 let ids: Vec<String> = talks.list().into_iter().map(|t| t.id).collect();
1884 assert_eq!(
1885 ids,
1886 [
1887 "20260902-000000-0002",
1888 "20260901-000000-0001",
1889 "20260903-000000-0003"
1890 ]
1891 );
1892 assert_eq!(talks.count_open(), 2);
1893 }
1894
1895 #[test]
1896 fn tasks_of_finds_only_this_talks_own_tasks() {
1897 let dir = tempfile::tempdir().expect("tempdir");
1898 let queue = Queue::at(dir.path().join("queue"));
1899
1900 let mut mine = Task::new(
1901 "rework the loader".to_owned(),
1902 "rework the loader".to_owned(),
1903 PathBuf::from("/repo"),
1904 Source::Agent {
1905 run: "20260904-014455-ab12".to_owned(),
1906 node: "chat".to_owned(),
1907 },
1908 );
1909 queue.put(&mut mine).expect("put mine");
1910
1911 let mut theirs = Task::new(
1912 "unrelated".to_owned(),
1913 "unrelated".to_owned(),
1914 PathBuf::from("/repo"),
1915 Source::Agent {
1916 run: "20260904-090000-zz99".to_owned(),
1917 node: "implement".to_owned(),
1918 },
1919 );
1920 queue.put(&mut theirs).expect("put theirs");
1921
1922 let mut human = Task::new(
1923 "typed by hand".to_owned(),
1924 "typed by hand".to_owned(),
1925 PathBuf::from("/repo"),
1926 Source::Human,
1927 );
1928 queue.put(&mut human).expect("put human");
1929
1930 let found = tasks_of(&queue, "20260904-014455-ab12");
1931 assert_eq!(found.len(), 1);
1932 assert_eq!(found[0].id, mine.id);
1933 }
1934
1935 #[test]
1936 fn the_briefing_names_solo_task_add() {
1937 let brief = briefing(Path::new("/repo"), "en", false);
1938 assert!(brief.contains("magi task add --solo"));
1939 assert!(brief.contains("/repo"));
1940 assert!(!brief.contains("Hold this conversation in"));
1941 }
1942
1943 #[test]
1949 fn the_briefing_explains_targeting_a_different_repository_by_name() {
1950 let brief = briefing(Path::new("/repo"), "en", false);
1951 assert!(brief.contains("--repo does not have to be a full path"));
1952 assert!(brief.contains("owner/repo"));
1953 assert!(brief.contains("magi repos"));
1954 assert!(brief.contains("ask the operator"));
1955 }
1956
1957 #[test]
1958 fn the_briefing_names_the_language_when_it_is_not_english() {
1959 let brief = briefing(Path::new("/repo"), "Japanese", false);
1960 assert!(brief.contains("Hold this conversation in Japanese"));
1961 }
1962
1963 #[test]
1964 fn the_briefing_forbids_writes_unless_the_repository_opted_in() {
1965 let read_only = briefing(Path::new("/repo"), "en", false);
1966 assert!(read_only.contains("Do not write files"));
1967 assert!(!read_only.contains("allow_write"));
1968
1969 let writable = briefing(Path::new("/repo"), "en", true);
1970 assert!(!writable.contains("Do not write files"));
1971 assert!(writable.contains("allow_write = true"));
1972 assert!(writable.contains("magi task add --solo"));
1975 assert!(writable.contains("say plainly what you"));
1976 }
1977}