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 {
968 let write_policy = if allow_write {
969 "This repository has set `[talk] allow_write = true`, so you may \
970 write files here - but only a small, already-decided edit the \
971 operator names outright in this conversation, not an \
972 implementation. Once you have made it, say plainly what you \
973 edited. Anything bigger, or anything still open-ended, still goes \
974 through the queue below rather than being done here."
975 } else {
976 "Do not write files. Implementing a change is not this \
977 conversation's job; a separate, blind competition of agents does \
978 that, and a repository this conversation has already edited would \
979 make their diffs unjudgeable."
980 };
981 let mut out = format!(
982 "You are magi's standing conversation partner for its operator, who \
983 usually has this open on a phone. Keep replies short: no preamble, \
984 no restating what they just said.\n\n\
985 # Repository\n\n{repo}\n\n\
986 You may look around: read files, run shell commands, search history, \
987 run tests - whatever answers the question. {write_policy}\n\n\
988 # When the operator wants something done\n\n\
989 Run:\n\n\
990 magi task add --solo --repo {repo} <instruction>\n\n\
991 and tell the operator the task id it prints, so they can follow it \
992 from the Queue. Write <instruction> so that an implementer who has \
993 never seen this conversation can act on it alone - it is everything \
994 they get. Use --solo: it runs the task through one implementer \
995 straight into review instead of the usual multi-agent competition, \
996 which is the right shape for a change this conversation has already \
997 settled, rather than one still worth several independent takes.\n",
998 repo = repo.display(),
999 );
1000 out.push_str(&language_note(language));
1001 out
1002}
1003
1004fn language_note(language: &str) -> String {
1007 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
1008 String::new()
1009 } else {
1010 format!("\nHold this conversation in {language}.\n")
1011 }
1012}
1013
1014pub fn tasks_of(queue: &Queue, talk_id: &str) -> Vec<Task> {
1021 let mut tasks: Vec<Task> = queue
1022 .list()
1023 .into_iter()
1024 .filter(|t| matches!(&t.source, Source::Agent { run, .. } if run == talk_id))
1025 .collect();
1026 tasks.sort_unstable_by(|a, b| a.id.cmp(&b.id));
1027 tasks
1028}
1029
1030fn read_path(path: &Path) -> Result<Talk> {
1031 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1032 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
1033}
1034
1035fn short(id: &str) -> &str {
1036 id.split('-').next_back().unwrap_or(id)
1037}
1038
1039fn new_id() -> String {
1040 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
1041 let seed = crate::rng::entropy();
1042 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
1043}
1044
1045fn attachment_ext(mime: &str) -> Option<&'static str> {
1050 match mime {
1051 "image/png" => Some("png"),
1052 "image/jpeg" => Some("jpg"),
1053 "image/gif" => Some("gif"),
1054 "image/webp" => Some("webp"),
1055 _ => None,
1056 }
1057}
1058
1059pub fn valid_attachment_id(id: &str) -> bool {
1064 id.len() == 32
1065 && id
1066 .bytes()
1067 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1068}
1069
1070fn new_attachment_id() -> String {
1074 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
1075 format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use std::collections::BTreeMap;
1081
1082 use crate::config::{AgentKind, AgentSpec, Graph};
1083 use crate::queue::{Queue, Source, Task};
1084
1085 use super::*;
1086
1087 fn store() -> (tempfile::TempDir, Talks) {
1089 let tmp = tempfile::tempdir().expect("tempdir");
1090 let talks = Talks::at(tmp.path().join("talks"));
1091 (tmp, talks)
1092 }
1093
1094 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
1098 let path = dir.join("mock-talk-agent.sh");
1099 std::fs::write(&path, script).expect("write mock");
1100 AgentSpec {
1101 id: "mock".to_owned(),
1102 kind: AgentKind::Command,
1103 model: None,
1104 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1105 extra_args: Vec::new(),
1106 env,
1107 prompt_delivery: None,
1108 }
1109 }
1110
1111 fn config(spec: AgentSpec) -> Config {
1112 Config {
1113 agents: vec![spec],
1114 graph: Graph {
1115 language: "en".to_owned(),
1116 ..Graph::default()
1117 },
1118 ..Config::default()
1119 }
1120 }
1121
1122 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1124
1125 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1127
1128 const ECHO: &str = "#!/bin/sh\ncat\n";
1131
1132 fn env(reply: &str) -> BTreeMap<String, String> {
1133 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
1134 }
1135
1136 #[test]
1137 fn the_frozen_json_field_names_round_trip_through_disk() {
1138 let (tmp, talks) = store();
1139 let mut talk = Talk {
1140 schema: SCHEMA,
1141 id: "20260904-014455-ab12".to_owned(),
1142 repo: tmp.path().to_owned(),
1143 agent: "sonnet".to_owned(),
1144 status: TalkStatus::Open,
1145 turns: Vec::new(),
1146 pending: String::new(),
1147 pending_attachments: Vec::new(),
1148 created_at: Timestamp::now(),
1149 updated_at: Timestamp::now(),
1150 seat: SeatState::new(SEAT, "sonnet", 7),
1151 };
1152 talks.put(&mut talk).expect("put");
1153
1154 let raw = std::fs::read_to_string(talks.path_of(&talk.id)).expect("read back");
1155 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1156 for field in [
1157 "schema",
1158 "id",
1159 "repo",
1160 "agent",
1161 "status",
1162 "turns",
1163 "created_at",
1164 "updated_at",
1165 ] {
1166 assert!(v.get(field).is_some(), "missing field `{field}`");
1167 }
1168 assert_eq!(v["schema"], 1);
1169 assert_eq!(v["status"], "open");
1170
1171 let back = talks.get(&talk.id).expect("get");
1172 assert_eq!(back.id, talk.id);
1173 assert_eq!(back.status, TalkStatus::Open);
1174 }
1175
1176 #[test]
1177 fn opening_a_talk_takes_no_agent_turn() {
1178 let (tmp, talks) = store();
1179 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1183 let cfg = config(spec);
1184
1185 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1186 assert_eq!(talk.status, TalkStatus::Open);
1187 assert!(talk.turns.is_empty(), "nothing has been said yet");
1188
1189 let on_disk = talks.get(&talk.id).expect("get");
1190 assert_eq!(on_disk.turns.len(), 0);
1191 }
1192
1193 #[test]
1201 fn chatter_wins_when_set_and_falls_back_to_pick_s_default_order_otherwise() {
1202 let (tmp, talks) = store();
1203 let first_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1204 let mut chatter_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1205 chatter_spec.id = "chatter-mock".to_owned();
1206
1207 let mut cfg = Config {
1208 agents: vec![first_spec.clone(), chatter_spec.clone()],
1209 graph: Graph {
1210 language: "en".to_owned(),
1211 ..Graph::default()
1212 },
1213 ..Config::default()
1214 };
1215 cfg.roles.chatter = Some(chatter_spec.id.clone());
1216
1217 let talk =
1218 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter set");
1219 assert_eq!(talk.agent, chatter_spec.id, "an explicit chatter must win");
1220
1221 cfg.roles.chatter = None;
1222 let fallback =
1223 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter unset");
1224 assert_eq!(
1225 fallback.agent, first_spec.id,
1226 "unset chatter must fall back to agent::pick's own default order"
1227 );
1228 }
1229
1230 #[test]
1233 fn a_talk_recorded_without_attachments_still_reads() {
1234 let (tmp, talks) = store();
1235 let path = talks.path_of("20260904-014455-ab12");
1236 std::fs::create_dir_all(talks.root()).expect("talks dir");
1237 std::fs::write(
1238 &path,
1239 serde_json::json!({
1240 "schema": 1,
1241 "id": "20260904-014455-ab12",
1242 "repo": tmp.path(),
1243 "agent": "sonnet",
1244 "status": "open",
1245 "turns": [
1246 { "who": "operator", "body": "still there?",
1247 "at": Timestamp::now().to_string() },
1248 ],
1249 "created_at": Timestamp::now().to_string(),
1250 "updated_at": Timestamp::now().to_string(),
1251 "seat": SeatState::new(SEAT, "sonnet", 7),
1252 })
1253 .to_string(),
1254 )
1255 .expect("write pre-attachments talk");
1256
1257 let talk = talks.get("20260904-014455-ab12").expect("must still read");
1258 assert!(talk.turns[0].attachments.is_empty());
1259 }
1260
1261 #[test]
1262 fn queued_text_is_durable_combined_and_drained_as_one_operator_turn() {
1263 let (tmp, talks) = store();
1264 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
1265 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1266
1267 queue(&mut talk, &talks, "first", Vec::new()).expect("queue first");
1268 queue(&mut talk, &talks, "second", Vec::new()).expect("queue second");
1269 let saved = talks.get(&talk.id).expect("reload queued talk");
1270 assert_eq!(saved.pending, "first\n\nsecond");
1271 assert!(saved.turns.is_empty(), "a draft is not a transcript turn");
1272
1273 let drained = drain(&mut talk, &talks).expect("drain");
1274 assert_eq!(drained.as_deref(), Some("first\n\nsecond"));
1275 let saved = talks.get(&talk.id).expect("reload drained talk");
1276 assert!(saved.pending.is_empty());
1277 assert_eq!(saved.turns.len(), 1);
1278 assert_eq!(saved.turns[0].body, "first\n\nsecond");
1279 }
1280
1281 #[test]
1282 fn editing_a_queued_draft_preserves_its_attachments_and_rejects_a_stale_snapshot() {
1283 let (tmp, talks) = store();
1284 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
1285 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1286 let attachment = Attachment {
1287 id: "a".repeat(32),
1288 name: "shot.png".to_owned(),
1289 mime: "image/png".to_owned(),
1290 bytes: 3,
1291 };
1292
1293 queue(&mut talk, &talks, "first", vec![attachment.clone()]).expect("queue");
1294 assert!(
1295 edit_pending_text(
1296 &mut talk,
1297 &talks,
1298 "corrected",
1299 "first",
1300 std::slice::from_ref(&attachment.id),
1301 )
1302 .expect("edit")
1303 );
1304 let saved = talks.get(&talk.id).expect("reload edited draft");
1305 assert_eq!(saved.pending, "corrected");
1306 assert_eq!(saved.pending_attachments, vec![attachment]);
1307
1308 queue(&mut talk, &talks, "later", Vec::new()).expect("queue concurrent draft");
1309 assert!(
1310 !edit_pending_text(
1311 &mut talk,
1312 &talks,
1313 "stale edit",
1314 "corrected",
1315 &["a".repeat(32)],
1316 )
1317 .expect("stale edit is a conflict")
1318 );
1319 assert_eq!(
1320 talks.get(&talk.id).expect("reload after conflict").pending,
1321 "corrected\n\nlater"
1322 );
1323 assert!(
1324 !clear_pending_if_matches(&mut talk, &talks, "corrected", &["a".repeat(32)])
1325 .expect("stale clear is a conflict")
1326 );
1327 assert_eq!(
1328 talks
1329 .get(&talk.id)
1330 .expect("reload after stale clear")
1331 .pending,
1332 "corrected\n\nlater"
1333 );
1334 }
1335
1336 #[tokio::test]
1337 async fn a_reply_save_preserves_pending_accepted_while_the_cli_runs() {
1338 let (tmp, talks) = store();
1339 let slow = "#!/bin/sh\ncat >/dev/null\nsleep 0.1\nprintf reply\n";
1340 let cfg = config(mock_agent(tmp.path(), slow, BTreeMap::new()));
1341 let mut running = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1342 let id = running.id.clone();
1343 let first = record(&mut running, &talks, "first", Vec::new()).expect("record");
1344
1345 let response_talks = talks.clone();
1346 let response_cfg = cfg.clone();
1347 let reply = tokio::spawn(async move {
1348 respond(&mut running, &response_talks, &response_cfg, &first).await
1349 });
1350 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1351
1352 let mut queued = talks.get(&id).expect("queued handle");
1353 queue(&mut queued, &talks, "next", Vec::new()).expect("queue");
1354 reply.await.expect("join").expect("reply");
1355
1356 let saved = talks.get(&id).expect("reload");
1357 assert_eq!(saved.pending, "next");
1358 assert_eq!(saved.turns.len(), 2, "operator message and reply remain");
1359 }
1360
1361 #[tokio::test]
1362 async fn the_first_turn_carries_the_briefing_and_later_turns_do_not() {
1363 let (tmp, talks) = store();
1364 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1365 let cfg = config(spec);
1366 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1367
1368 say(
1369 &mut talk,
1370 &talks,
1371 &cfg,
1372 "what does the queue module do?",
1373 Vec::new(),
1374 )
1375 .await
1376 .expect("first turn");
1377 let first_prompt = &talk.turns[1].body;
1378 assert!(first_prompt.contains("magi task add --solo"));
1379 assert!(first_prompt.contains("what does the queue module do?"));
1380
1381 say(&mut talk, &talks, &cfg, "and how is it locked?", Vec::new())
1382 .await
1383 .expect("second turn");
1384 let second_prompt = &talk.turns[3].body;
1385 assert!(
1386 !second_prompt.contains("magi task add --solo"),
1387 "the briefing is sent once, not on every turn: {second_prompt}"
1388 );
1389 assert!(second_prompt.contains("and how is it locked?"));
1390 }
1391
1392 #[tokio::test]
1393 async fn say_appends_the_operator_turn_then_the_agent_turn() {
1394 let (tmp, talks) = store();
1395 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
1396 let cfg = config(spec);
1397 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1398
1399 say(
1400 &mut talk,
1401 &talks,
1402 &cfg,
1403 "can I rename this function?",
1404 Vec::new(),
1405 )
1406 .await
1407 .expect("say");
1408
1409 assert_eq!(talk.turns.len(), 2);
1410 assert_eq!(talk.turns[0].who, Who::Operator);
1411 assert_eq!(talk.turns[0].body, "can I rename this function?");
1412 assert_eq!(talk.turns[1].who, Who::Agent);
1413 assert_eq!(talk.turns[1].body, "go ahead");
1414 assert_eq!(talks.get(&talk.id).expect("get").turns, talk.turns);
1415 }
1416
1417 #[tokio::test]
1418 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1419 let (tmp, talks) = store();
1420 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1421 let cfg = config(spec);
1422 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1423
1424 let err = say(&mut talk, &talks, &cfg, "check the tests", Vec::new())
1425 .await
1426 .expect_err("a turn with no answer is an error");
1427 assert!(err.to_string().contains("no answer"), "{err}");
1428
1429 let on_disk = talks.get(&talk.id).expect("get");
1430 assert_eq!(on_disk.turns.len(), 2);
1431 assert_eq!(on_disk.turns[0].body, "check the tests");
1432 let note = &on_disk.turns[1];
1433 assert_eq!(note.who, Who::Agent);
1434 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
1435 assert!(note.body.contains("your message is saved"));
1436 }
1437
1438 #[tokio::test]
1442 async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
1443 let (tmp, talks) = store();
1444 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1445 let cfg = config(spec);
1446 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1447
1448 let att = talks
1449 .put_attachment(
1450 &talk.id,
1451 "image/png",
1452 "screenshot.png",
1453 b"pretend-png-bytes",
1454 )
1455 .expect("put attachment");
1456
1457 say(&mut talk, &talks, &cfg, "", vec![att.clone()])
1458 .await
1459 .expect("an empty body with an attachment is still a turn");
1460
1461 let operator_turn = &talk.turns[0];
1462 assert_eq!(operator_turn.who, Who::Operator);
1463 assert_eq!(operator_turn.body, "");
1464 assert_eq!(operator_turn.attachments, vec![att.clone()]);
1465
1466 let prompt = &talk.turns[1].body;
1467 let expected_path = talks
1468 .attachments_dir(&talk.id)
1469 .join(format!("{}.png", att.id));
1470 assert!(
1471 prompt.contains(&expected_path.display().to_string()),
1472 "the agent must be told the attachment's absolute path: {prompt}"
1473 );
1474 assert!(prompt.contains("image/png"), "and its mime: {prompt}");
1475 }
1476
1477 #[test]
1486 fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
1487 let talks = Talks::at(PathBuf::from("relative-talks-root-for-this-test"));
1488 let att = Attachment {
1489 id: "0".repeat(32),
1490 name: "shot.png".to_owned(),
1491 mime: "image/png".to_owned(),
1492 bytes: 3,
1493 };
1494 let path = talks
1495 .attachment_path("some-talk-id", &att)
1496 .expect("a supported mime always yields a path");
1497 assert!(
1498 path.is_absolute(),
1499 "must be absolute even off a relative store root: {}",
1500 path.display()
1501 );
1502 }
1503
1504 #[tokio::test]
1505 async fn a_turn_past_the_configured_talk_timeout_is_reported_with_that_timeout() {
1506 let (tmp, talks) = store();
1511 let slow = mock_agent(
1512 tmp.path(),
1513 "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
1514 BTreeMap::new(),
1515 );
1516 let mut cfg = config(slow);
1517 cfg.graph.timeout_talk = 1;
1518 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1519
1520 let err = say(&mut talk, &talks, &cfg, "check the tests", Vec::new())
1521 .await
1522 .expect_err("a turn that never answers is an error");
1523 assert!(
1524 err.to_string().contains("did not answer within 1s"),
1525 "{err}"
1526 );
1527
1528 let on_disk = talks.get(&talk.id).expect("get");
1529 let note = on_disk.turns.last().expect("a note turn was recorded");
1530 assert!(
1531 note.body.contains("did not answer within 1s"),
1532 "the transcript must show the configured timeout: {}",
1533 note.body
1534 );
1535 }
1536
1537 #[test]
1538 fn closing_is_idempotent_and_a_closed_talk_takes_no_more_turns() {
1539 let (tmp, talks) = store();
1540 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1541 let cfg = config(spec);
1542 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1543
1544 close(&mut talk, &talks).expect("close");
1545 assert_eq!(talk.status, TalkStatus::Closed);
1546 close(&mut talk, &talks).expect("closing twice is not an error");
1547
1548 let err =
1549 record(&mut talk, &talks, "still there?", Vec::new()).expect_err("closed talks refuse");
1550 assert!(err.to_string().contains("closed"));
1551 let _ = &cfg; }
1553
1554 #[tokio::test]
1555 async fn a_close_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1556 let (tmp, talks) = store();
1557 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1558 let cfg = config(spec);
1559 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1562
1563 let mut closed_elsewhere = talks.get(&in_flight.id).expect("reread");
1567 close(&mut closed_elsewhere, &talks).expect("close");
1568 assert_eq!(
1569 talks.get(&in_flight.id).expect("reread").status,
1570 TalkStatus::Closed,
1571 "the close landed on disk before the turn finished"
1572 );
1573
1574 assert_eq!(in_flight.status, TalkStatus::Open);
1578 respond(&mut in_flight, &talks, &cfg, "one more question")
1579 .await
1580 .expect("the turn itself still completes");
1581
1582 let on_disk = talks.get(&in_flight.id).expect("reread");
1583 assert_eq!(
1584 on_disk.status,
1585 TalkStatus::Closed,
1586 "a close must stick even when a turn that started before it finishes after it"
1587 );
1588 assert!(
1591 on_disk.turns.iter().any(|t| t.body == "here you go"),
1592 "the in-flight turn's own reply is still recorded: {:?}",
1593 on_disk.turns
1594 );
1595 }
1596
1597 #[test]
1598 fn a_close_that_lands_before_record_is_called_is_not_undone_by_it() {
1599 let (tmp, talks) = store();
1600 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1601 let cfg = config(spec);
1602 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1605
1606 let mut closed_elsewhere = talks.get(&stale.id).expect("reread");
1609 close(&mut closed_elsewhere, &talks).expect("close");
1610 assert_eq!(
1611 talks.get(&stale.id).expect("reread").status,
1612 TalkStatus::Closed,
1613 "the close landed on disk before record was called"
1614 );
1615
1616 assert_eq!(stale.status, TalkStatus::Open);
1620 let err = record(&mut stale, &talks, "still there?", Vec::new())
1621 .expect_err("a close that landed first must be honored, not overwritten");
1622 assert!(err.to_string().contains("closed"));
1623
1624 let on_disk = talks.get(&stale.id).expect("reread");
1625 assert_eq!(
1626 on_disk.status,
1627 TalkStatus::Closed,
1628 "record must not resurrect a conversation closed while its snapshot was stale"
1629 );
1630 assert!(
1631 on_disk.turns.is_empty(),
1632 "the rejected turn must not have been appended: {:?}",
1633 on_disk.turns
1634 );
1635 let _ = &cfg; }
1637
1638 #[test]
1639 fn close_blocks_on_records_guard_rather_than_interleaving_with_it() {
1640 let (tmp, talks) = store();
1641 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1642 let cfg = config(spec);
1643 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1644
1645 let held = talks.guard();
1649
1650 let talks2 = talks.clone();
1651 let id = talk.id.clone();
1652 let closing = std::thread::spawn(move || {
1653 let mut talk = talks2.get(&id).expect("get");
1654 close(&mut talk, &talks2).expect("close");
1655 });
1656
1657 std::thread::sleep(Duration::from_millis(50));
1658 assert!(
1659 !closing.is_finished(),
1660 "close must wait for the guard, not read and write while it is held - \
1661 a re-read alone narrows this window without closing it"
1662 );
1663
1664 drop(held);
1665 closing.join().expect("close thread panicked");
1666
1667 assert_eq!(
1668 talks.get(&talk.id).expect("reread").status,
1669 TalkStatus::Closed,
1670 "once the guard is free, close still lands"
1671 );
1672 let _ = &cfg; }
1674
1675 #[test]
1676 fn reopening_a_closed_talk_lets_it_take_turns_again_and_reopening_twice_is_not_an_error() {
1677 let (tmp, talks) = store();
1678 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1679 let cfg = config(spec);
1680 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1681
1682 close(&mut talk, &talks).expect("close");
1683 assert_eq!(talk.status, TalkStatus::Closed);
1684
1685 reopen(&mut talk, &talks).expect("reopen");
1686 assert_eq!(talk.status, TalkStatus::Open);
1687 assert_eq!(
1688 talks.get(&talk.id).expect("reread").status,
1689 TalkStatus::Open
1690 );
1691
1692 reopen(&mut talk, &talks).expect("reopening an open talk is not an error");
1694 assert_eq!(talk.status, TalkStatus::Open);
1695
1696 record(&mut talk, &talks, "one more thing", Vec::new())
1697 .expect("a reopened talk takes turns again");
1698 let _ = &cfg; }
1700
1701 #[test]
1702 fn removing_a_talk_deletes_its_record_and_artifacts_and_refuses_an_unknown_id() {
1703 let (tmp, talks) = store();
1704 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1705 let cfg = config(spec);
1706 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1707
1708 let artifacts = talks.artifacts_of(&talk.id);
1709 std::fs::create_dir_all(&artifacts).expect("create artifacts dir");
1710 std::fs::write(artifacts.join("turn-1.txt"), "hello").expect("write artifact");
1711
1712 talks.remove(&talk.id).expect("remove");
1713 assert!(!talks.path_of(&talk.id).is_file(), "the record is gone");
1714 assert!(!artifacts.is_dir(), "the artifacts directory is gone");
1715 assert!(
1716 talks.get(&talk.id).is_err(),
1717 "a removed talk cannot be read back"
1718 );
1719
1720 let err = talks
1721 .remove("nonexistent-id")
1722 .expect_err("unknown id refused");
1723 assert!(err.to_string().contains("no talk matches"), "{err}");
1724 let _ = &cfg; }
1726
1727 #[tokio::test]
1728 async fn a_delete_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1729 let (tmp, talks) = store();
1730 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1731 let cfg = config(spec);
1732 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1735
1736 talks.remove(&in_flight.id).expect("remove");
1737 assert!(
1738 talks.get(&in_flight.id).is_err(),
1739 "the delete landed on disk before the turn finished"
1740 );
1741
1742 respond(&mut in_flight, &talks, &cfg, "one more question")
1745 .await
1746 .expect("the turn itself still completes rather than erroring");
1747
1748 assert!(
1749 talks.get(&in_flight.id).is_err(),
1750 "a delete must stick even when a turn that started before it finishes after it"
1751 );
1752 }
1753
1754 #[test]
1755 fn a_delete_that_lands_before_record_is_called_is_not_undone_by_it() {
1756 let (tmp, talks) = store();
1757 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1758 let cfg = config(spec);
1759 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1762
1763 talks.remove(&stale.id).expect("remove");
1764
1765 let err = record(&mut stale, &talks, "still there?", Vec::new())
1769 .expect_err("a delete that landed first must be honored, not overwritten");
1770 assert!(err.to_string().contains("deleted"), "{err}");
1771
1772 assert!(
1773 talks.get(&stale.id).is_err(),
1774 "record must not resurrect a conversation deleted while its snapshot was stale"
1775 );
1776 let _ = &cfg; }
1778
1779 #[test]
1780 fn a_delete_that_lands_before_close_is_called_is_not_undone_by_it() {
1781 let (tmp, talks) = store();
1782 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1783 let cfg = config(spec);
1784 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1787
1788 talks.remove(&stale.id).expect("remove");
1789
1790 let err = close(&mut stale, &talks)
1794 .expect_err("a delete that landed first must be honored, not overwritten");
1795 assert!(err.to_string().contains("deleted"), "{err}");
1796
1797 assert!(
1798 talks.get(&stale.id).is_err(),
1799 "close must not resurrect a conversation deleted while its snapshot was stale"
1800 );
1801 let _ = &cfg; }
1803
1804 #[test]
1805 fn a_delete_that_lands_before_reopen_is_called_is_not_undone_by_it() {
1806 let (tmp, talks) = store();
1807 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
1808 let cfg = config(spec);
1809 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
1812 close(&mut stale, &talks).expect("close");
1813
1814 talks.remove(&stale.id).expect("remove");
1815
1816 let err = reopen(&mut stale, &talks)
1820 .expect_err("a delete that landed first must be honored, not overwritten");
1821 assert!(err.to_string().contains("deleted"), "{err}");
1822
1823 assert!(
1824 talks.get(&stale.id).is_err(),
1825 "reopen must not resurrect a conversation deleted while its snapshot was stale"
1826 );
1827 let _ = &cfg; }
1829
1830 #[test]
1831 fn list_puts_open_talks_before_closed_ones() {
1832 let (tmp, talks) = store();
1833 let make = |id: &str, status: TalkStatus| {
1834 let mut t = Talk {
1835 schema: SCHEMA,
1836 id: id.to_owned(),
1837 repo: tmp.path().to_owned(),
1838 agent: "mock".to_owned(),
1839 status,
1840 turns: Vec::new(),
1841 pending: String::new(),
1842 pending_attachments: Vec::new(),
1843 created_at: Timestamp::now(),
1844 updated_at: Timestamp::now(),
1845 seat: SeatState::new(SEAT, "mock", 7),
1846 };
1847 talks.put(&mut t).expect("put");
1848 };
1849 make("20260901-000000-0001", TalkStatus::Open);
1850 make("20260902-000000-0002", TalkStatus::Open);
1851 make("20260903-000000-0003", TalkStatus::Closed);
1852
1853 let ids: Vec<String> = talks.list().into_iter().map(|t| t.id).collect();
1854 assert_eq!(
1855 ids,
1856 [
1857 "20260902-000000-0002",
1858 "20260901-000000-0001",
1859 "20260903-000000-0003"
1860 ]
1861 );
1862 assert_eq!(talks.count_open(), 2);
1863 }
1864
1865 #[test]
1866 fn tasks_of_finds_only_this_talks_own_tasks() {
1867 let dir = tempfile::tempdir().expect("tempdir");
1868 let queue = Queue::at(dir.path().join("queue"));
1869
1870 let mut mine = Task::new(
1871 "rework the loader".to_owned(),
1872 "rework the loader".to_owned(),
1873 PathBuf::from("/repo"),
1874 Source::Agent {
1875 run: "20260904-014455-ab12".to_owned(),
1876 node: "chat".to_owned(),
1877 },
1878 );
1879 queue.put(&mut mine).expect("put mine");
1880
1881 let mut theirs = Task::new(
1882 "unrelated".to_owned(),
1883 "unrelated".to_owned(),
1884 PathBuf::from("/repo"),
1885 Source::Agent {
1886 run: "20260904-090000-zz99".to_owned(),
1887 node: "implement".to_owned(),
1888 },
1889 );
1890 queue.put(&mut theirs).expect("put theirs");
1891
1892 let mut human = Task::new(
1893 "typed by hand".to_owned(),
1894 "typed by hand".to_owned(),
1895 PathBuf::from("/repo"),
1896 Source::Human,
1897 );
1898 queue.put(&mut human).expect("put human");
1899
1900 let found = tasks_of(&queue, "20260904-014455-ab12");
1901 assert_eq!(found.len(), 1);
1902 assert_eq!(found[0].id, mine.id);
1903 }
1904
1905 #[test]
1906 fn the_briefing_names_solo_task_add() {
1907 let brief = briefing(Path::new("/repo"), "en", false);
1908 assert!(brief.contains("magi task add --solo"));
1909 assert!(brief.contains("/repo"));
1910 assert!(!brief.contains("Hold this conversation in"));
1911 }
1912
1913 #[test]
1914 fn the_briefing_names_the_language_when_it_is_not_english() {
1915 let brief = briefing(Path::new("/repo"), "Japanese", false);
1916 assert!(brief.contains("Hold this conversation in Japanese"));
1917 }
1918
1919 #[test]
1920 fn the_briefing_forbids_writes_unless_the_repository_opted_in() {
1921 let read_only = briefing(Path::new("/repo"), "en", false);
1922 assert!(read_only.contains("Do not write files"));
1923 assert!(!read_only.contains("allow_write"));
1924
1925 let writable = briefing(Path::new("/repo"), "en", true);
1926 assert!(!writable.contains("Do not write files"));
1927 assert!(writable.contains("allow_write = true"));
1928 assert!(writable.contains("magi task add --solo"));
1931 assert!(writable.contains("say plainly what you"));
1932 }
1933}