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