1use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
42use std::time::Duration;
43
44use anyhow::{Context, Result, bail};
45use jiff::Timestamp;
46use serde::{Deserialize, Serialize};
47
48use crate::agent::{self, Invocation, SeatState};
49use crate::config::Config;
50use crate::plan;
51use crate::queue::{self, Queue, Source, Task};
52
53pub const SCHEMA: u32 = 1;
58
59fn turn_timeout(cfg: &Config) -> Duration {
70 Duration::from_secs(cfg.graph.timeout_chat)
71}
72
73const SEAT: &str = "plan";
78
79pub const MAGI_NOTE: &str = "magi: ";
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum Who {
93 Operator,
95 Agent,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct Attachment {
110 pub id: String,
112 pub name: String,
114 pub mime: String,
117 pub bytes: u64,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct Turn {
125 pub who: Who,
127 pub body: String,
129 pub at: Timestamp,
131 #[serde(default)]
136 pub attachments: Vec<Attachment>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "lowercase")]
142pub enum ChatStatus {
143 Open,
145 Filed,
147 Abandoned,
150}
151
152impl ChatStatus {
153 pub fn open(self) -> bool {
155 matches!(self, Self::Open)
156 }
157
158 pub fn as_str(self) -> &'static str {
160 match self {
161 Self::Open => "open",
162 Self::Filed => "filed",
163 Self::Abandoned => "abandoned",
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct Chat {
172 pub schema: u32,
174 pub id: String,
176 pub repo: PathBuf,
178 #[serde(default)]
182 pub from: Option<String>,
183 pub agent: String,
185 pub status: ChatStatus,
187 pub turns: Vec<Turn>,
189 pub draft: Option<String>,
191 pub task: Option<String>,
193 pub created_at: Timestamp,
195 pub updated_at: Timestamp,
197 seat: SeatState,
206}
207
208impl Chat {
209 pub fn short(&self) -> &str {
211 short(&self.id)
212 }
213
214 pub fn agent_turns(&self) -> usize {
220 self.seat.turns
221 }
222}
223
224#[derive(Debug, Clone)]
226pub struct Chats {
227 root: PathBuf,
228 lock: Arc<Mutex<()>>,
238}
239
240impl Chats {
241 pub fn open() -> Self {
243 Self::at(crate::run::home().join("chats"))
244 }
245
246 pub fn at(root: PathBuf) -> Self {
249 Self {
250 root,
251 lock: Arc::new(Mutex::new(())),
252 }
253 }
254
255 fn guard(&self) -> MutexGuard<'_, ()> {
265 self.lock.lock().unwrap_or_else(PoisonError::into_inner)
266 }
267
268 fn lock_path(&self, id: &str) -> PathBuf {
272 self.root.join(format!("{id}.lock"))
273 }
274
275 fn claim(&self, id: &str) -> Result<ChatClaim> {
287 std::fs::create_dir_all(&self.root)
288 .with_context(|| format!("create {}", self.root.display()))?;
289 let path = self.lock_path(id);
290 match std::fs::OpenOptions::new()
291 .write(true)
292 .create_new(true)
293 .open(&path)
294 {
295 Ok(mut f) => {
296 use std::io::Write as _;
297 let _ = writeln!(f, "{}", std::process::id());
299 Ok(ChatClaim { path })
300 }
301 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
302 bail!("chat {id} is claimed by another process right now")
303 }
304 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
305 }
306 }
307
308 pub fn root(&self) -> &Path {
310 &self.root
311 }
312
313 pub fn path_of(&self, id: &str) -> PathBuf {
315 self.root.join(format!("{id}.json"))
316 }
317
318 pub fn artifacts_of(&self, id: &str) -> PathBuf {
325 self.root.join(format!("{id}.artifacts"))
326 }
327
328 pub fn attachments_dir(&self, id: &str) -> PathBuf {
332 self.artifacts_of(id).join("attachments")
333 }
334
335 pub fn put_attachment(
344 &self,
345 id: &str,
346 mime: &str,
347 name: &str,
348 data: &[u8],
349 ) -> Result<Attachment> {
350 let dir = self.attachments_dir(id);
351 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
352 let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
353 let att = Attachment {
354 id: new_attachment_id(),
355 name: name.to_owned(),
356 mime: mime.to_owned(),
357 bytes: data.len() as u64,
358 };
359 std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
360 .with_context(|| format!("write attachment {}", att.id))?;
361 std::fs::write(
362 dir.join(format!("{}.json", att.id)),
363 serde_json::to_string(&att).context("serialize attachment")?,
364 )
365 .with_context(|| format!("write attachment metadata {}", att.id))?;
366 Ok(att)
367 }
368
369 pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
378 if !valid_attachment_id(att_id) {
379 return Ok(None);
380 }
381 let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
382 if !meta_path.is_file() {
383 return Ok(None);
384 }
385 let att = serde_json::from_str(
386 &std::fs::read_to_string(&meta_path)
387 .with_context(|| format!("read {}", meta_path.display()))?,
388 )
389 .with_context(|| format!("parse {}", meta_path.display()))?;
390 Ok(Some(att))
391 }
392
393 pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
397 let Some(att) = self.attachment_meta(id, att_id)? else {
398 return Ok(None);
399 };
400 let ext = attachment_ext(&att.mime).with_context(|| {
401 format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
402 })?;
403 let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
404 let data =
405 std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
406 Ok(Some((att, data)))
407 }
408
409 fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
426 let ext = attachment_ext(&att.mime)?;
427 let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
428 std::path::absolute(&path).ok()
429 }
430
431 pub fn put(&self, c: &mut Chat) -> Result<()> {
435 std::fs::create_dir_all(&self.root)
436 .with_context(|| format!("create {}", self.root.display()))?;
437 c.updated_at = Timestamp::now();
438 let body = serde_json::to_string_pretty(c).context("serialize chat")?;
439 let path = self.path_of(&c.id);
440 let tmp = path.with_extension("json.tmp");
441 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
442 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
443 Ok(())
444 }
445
446 pub fn get(&self, id: &str) -> Result<Chat> {
448 let resolved = self.resolve_id(id)?;
449 read_path(&self.path_of(&resolved))
450 }
451
452 pub fn list(&self) -> Vec<Chat> {
460 let mut all: Vec<Chat> = std::fs::read_dir(&self.root)
461 .into_iter()
462 .flatten()
463 .flatten()
464 .map(|e| e.path())
465 .filter(|p| p.extension().is_some_and(|x| x == "json"))
466 .filter_map(|p| read_path(&p).ok())
467 .collect();
468 all.sort_unstable_by(|a, b| {
469 let rank = |c: &Chat| u8::from(!c.status.open());
470 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
471 });
472 all
473 }
474
475 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
478 if self.path_of(prefix).is_file() {
479 return Ok(prefix.to_owned());
480 }
481 let hits: Vec<String> = self
482 .list()
483 .into_iter()
484 .map(|c| c.id)
485 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
486 .collect();
487 match hits.len() {
488 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
489 0 => bail!("no chat matches `{prefix}`"),
490 _ => bail!(
491 "`{prefix}` matches {} chats: {}",
492 hits.len(),
493 hits.join(", ")
494 ),
495 }
496 }
497
498 pub fn revision(&self) -> u64 {
502 std::fs::read_dir(&self.root)
503 .into_iter()
504 .flatten()
505 .flatten()
506 .filter_map(|e| e.metadata().ok())
507 .filter_map(|m| m.modified().ok())
508 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
509 .map(|d| d.as_millis() as u64)
510 .max()
511 .unwrap_or(0)
512 }
513
514 pub fn count_open(&self) -> usize {
516 self.list().iter().filter(|c| c.status.open()).count()
517 }
518}
519
520#[derive(Debug)]
524struct ChatClaim {
525 path: PathBuf,
526}
527
528impl Drop for ChatClaim {
529 fn drop(&mut self) {
530 let _ = std::fs::remove_file(&self.path);
531 }
532}
533
534pub fn build(
555 cfg: &Config,
556 repo: PathBuf,
557 idea: &str,
558 agent: Option<&str>,
559 from: Option<&Chat>,
560) -> Result<Chat> {
561 let idea = idea.trim();
562 if idea.is_empty() {
563 bail!("an interview needs something to start from: say what you want to change");
564 }
565 let repo = repo.canonicalize().unwrap_or(repo);
569 let want = agent
576 .or(cfg.roles.chatter.as_deref())
577 .or(cfg.roles.planner.as_deref());
578 let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
579
580 let now = Timestamp::now();
581 let id = new_id();
582 Ok(Chat {
583 schema: SCHEMA,
584 id,
585 repo,
586 from: from.map(|c| c.id.clone()),
587 agent: spec.id.clone(),
588 status: ChatStatus::Open,
589 turns: vec![Turn {
590 who: Who::Operator,
591 body: idea.to_owned(),
592 at: now,
593 attachments: Vec::new(),
598 }],
599 draft: None,
600 task: None,
601 created_at: now,
602 updated_at: now,
603 seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
604 })
605}
606
607pub fn open(
614 store: &Chats,
615 cfg: &Config,
616 repo: PathBuf,
617 idea: &str,
618 agent: Option<&str>,
619 from: Option<&Chat>,
620) -> Result<Chat> {
621 let mut chat = build(cfg, repo, idea, agent, from)?;
622 store.put(&mut chat)?;
623 Ok(chat)
624}
625
626pub async fn first_turn(
633 chat: &mut Chat,
634 store: &Chats,
635 cfg: &Config,
636 from: Option<&Chat>,
637) -> Result<()> {
638 let idea = chat
639 .turns
640 .first()
641 .map(|t| t.body.as_str())
642 .unwrap_or_default();
643 let mut prompt = briefing(idea, &chat.repo);
644 let mut inherited_attachments: Vec<PathBuf> = Vec::new();
650 if let Some(source) = from {
651 prompt = format!("{}\n\n{prompt}", derived_background(source, store));
655 inherited_attachments = source
656 .turns
657 .iter()
658 .flat_map(|t| t.attachments.iter())
659 .filter_map(|a| store.attachment_path(&source.id, a))
660 .collect();
661 }
662 prompt.push_str(&language_note(&cfg.graph.language));
663 turn(chat, store, cfg, &prompt, &inherited_attachments).await
664}
665
666pub async fn start(
674 store: &Chats,
675 cfg: &Config,
676 repo: PathBuf,
677 idea: &str,
678 agent: Option<&str>,
679 from: Option<&Chat>,
680) -> Result<Chat> {
681 let mut chat = open(store, cfg, repo, idea, agent, from)?;
682 first_turn(&mut chat, store, cfg, from).await?;
683 Ok(chat)
684}
685
686pub fn derived_background(from: &Chat, store: &Chats) -> String {
697 format!(
698 "# Background: derived from another conversation\n\n\
699 This interview continues from a conversation about a *different* \
700 repository. Read it for context, but do not treat it as being about \
701 the repository named below in \"# Repository\" - that repository may \
702 have nothing to do with this one.\n\n\
703 Source repository: {}\n\n{}",
704 from.repo.display(),
705 transcript(from, store),
706 )
707}
708
709pub async fn say(
721 chat: &mut Chat,
722 store: &Chats,
723 cfg: &Config,
724 text: &str,
725 attachments: Vec<Attachment>,
726) -> Result<()> {
727 if !chat.status.open() {
728 bail!(
729 "chat {} is {} and takes no more turns",
730 chat.short(),
731 chat.status.as_str()
732 );
733 }
734 let text = text.trim();
735 if text.is_empty() && attachments.is_empty() {
736 bail!("nothing to say");
737 }
738 let text = record(chat, store, text, attachments)?;
739 turn(chat, store, cfg, &text, &[]).await
740}
741
742pub fn record(
765 chat: &mut Chat,
766 store: &Chats,
767 text: &str,
768 attachments: Vec<Attachment>,
769) -> Result<String> {
770 let _guard = store.guard();
771 let _claim = store.claim(&chat.id)?;
772 let fresh = store
773 .get(&chat.id)
774 .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
775 chat.status = fresh.status;
776 if !chat.status.open() {
777 bail!(
778 "chat {} is {} and takes no more turns",
779 chat.short(),
780 chat.status.as_str()
781 );
782 }
783 let text = text.trim();
784 if text.is_empty() && attachments.is_empty() {
785 bail!("nothing to say");
786 }
787 chat.turns.push(Turn {
788 who: Who::Operator,
789 body: text.to_owned(),
790 at: Timestamp::now(),
791 attachments,
792 });
793 store.put(chat)?;
794 Ok(text.to_owned())
795}
796
797pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
803 turn(chat, store, cfg, text, &[]).await
804}
805
806async fn turn(
818 chat: &mut Chat,
819 store: &Chats,
820 cfg: &Config,
821 prompt: &str,
822 inherited_attachments: &[PathBuf],
823) -> Result<()> {
824 let spec = cfg
825 .agents
826 .iter()
827 .find(|a| a.id == chat.agent)
828 .with_context(|| {
829 format!(
830 "chat {} was interviewed by agent `{}`, which is no longer in \
831 the roster; restore it in magi.toml or start a new chat",
832 chat.short(),
833 chat.agent
834 )
835 })?;
836
837 let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
838 let last_note = attachment_note(
843 store,
844 &chat.id,
845 chat.turns
846 .last()
847 .map_or(&[][..], |t| t.attachments.as_slice()),
848 );
849 let body = if resuming {
850 format!("{prompt}{last_note}")
851 } else {
852 format!("{}\n\n{prompt}{last_note}", transcript(chat, store))
853 };
854
855 let attachment_paths: Vec<PathBuf> = chat
865 .turns
866 .iter()
867 .flat_map(|t| t.attachments.iter())
868 .filter_map(|a| store.attachment_path(&chat.id, a))
869 .chain(inherited_attachments.iter().cloned())
870 .collect();
871
872 let artifacts = store.artifacts_of(&chat.id);
873 let stem = format!("turn-{}", chat.seat.turns + 1);
874 let cache_dir = cfg.cache_dir();
875 let inv = Invocation {
876 cwd: &chat.repo,
877 prompt: &body,
878 timeout: turn_timeout(cfg),
879 allow_write: false,
884 sessions: cfg.graph.sessions,
885 artifacts: &artifacts,
886 stem: &stem,
887 run: &chat.id,
888 node: "chat",
889 cache_dir: cache_dir.as_deref(),
890 attachments: &attachment_paths,
891 };
892
893 let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
894 let note = |why: String| Turn {
895 who: Who::Agent,
896 body: format!("{MAGI_NOTE}{why}"),
897 at: Timestamp::now(),
898 attachments: Vec::new(),
899 };
900 let (reply, failure) = match outcome {
901 Err(e) => (
902 note(format!("could not run agent `{}`: {e}", chat.agent)),
903 Some(format!("could not run agent `{}`: {e}", chat.agent)),
904 ),
905 Ok(out) if out.quota_exhausted() => {
906 let reset = out
907 .quota
908 .as_ref()
909 .and_then(|q| q.reset.clone())
910 .map_or_else(String::new, |r| format!(" (resets {r})"));
911 let why = format!(
912 "agent `{}` is out of quota{reset}; your message is saved, so \
913 say it again when the window reopens",
914 chat.agent
915 );
916 (note(why.clone()), Some(why))
917 }
918 Ok(out) if out.timed_out => {
919 let why = format!(
920 "agent `{}` did not answer within {}s; your message is saved",
921 chat.agent,
922 turn_timeout(cfg).as_secs()
923 );
924 (note(why.clone()), Some(why))
925 }
926 Ok(out) if !out.usable() => {
927 let why = format!(
928 "agent `{}` produced no answer (exit {}); your message is saved",
929 chat.agent,
930 out.exit_code
931 .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
932 );
933 (note(why.clone()), Some(why))
934 }
935 Ok(out) => (
936 Turn {
937 who: Who::Agent,
938 body: out.text.trim().to_owned(),
939 at: Timestamp::now(),
940 attachments: Vec::new(),
941 },
942 None,
943 ),
944 };
945
946 if let Some(draft) = extract_draft(&reply.body) {
950 chat.draft = Some(draft);
951 }
952
953 let _guard = store.guard();
962 let _claim = store.claim(&chat.id)?;
963 let fresh = store
964 .get(&chat.id)
965 .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
966 chat.status = fresh.status;
967 chat.task = fresh.task;
968 chat.turns.push(reply);
969 store.put(chat)?;
970
971 match failure {
972 Some(why) => bail!("{why}"),
973 None => Ok(()),
974 }
975}
976
977fn transcript(chat: &Chat, store: &Chats) -> String {
984 let mut out = String::from(
985 "You are mid-interview. This CLI cannot resume its own conversation, \
986 so here is everything said so far; answer only the last message.\n",
987 );
988 for t in &chat.turns {
989 let who = match t.who {
990 Who::Operator => "operator",
991 Who::Agent => "you",
992 };
993 out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
994 out.push_str(&attachment_note(store, &chat.id, &t.attachments));
995 }
996 out
997}
998
999fn attachment_note(store: &Chats, chat_id: &str, attachments: &[Attachment]) -> String {
1005 if attachments.is_empty() {
1006 return String::new();
1007 }
1008 let mut out = String::from(
1009 "\n\nThe operator attached the image(s) below to this message. Open \
1010 and look at each one before you answer.\n",
1011 );
1012 for att in attachments {
1013 if let Some(path) = store.attachment_path(chat_id, att) {
1014 out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
1015 }
1016 }
1017 out.push('\n');
1018 out
1019}
1020
1021pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
1039 let _guard = store.guard();
1040 let _claim = store.claim(&chat.id)?;
1041 let mut fresh = store
1042 .get(&chat.id)
1043 .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
1044 if !fresh.status.open() {
1045 bail!(
1046 "chat {} is {} and takes no more turns",
1047 fresh.short(),
1048 fresh.status.as_str()
1049 );
1050 }
1051 if let Err(problems) = draft_problems(&fresh) {
1052 bail!(
1053 "this draft is not fileable yet:\n- {}",
1054 problems.join("\n- ")
1055 );
1056 }
1057 let body = fresh
1058 .draft
1059 .clone()
1060 .expect("draft_problems accepted a chat with a draft");
1061
1062 let title = queue::title_from(&body, 72);
1066 let mut task = Task::new(title, body, fresh.repo.clone(), Source::Human);
1070 task.priority = priority;
1071 queue.put(&mut task)?;
1072
1073 fresh.task = Some(task.id.clone());
1074 fresh.status = ChatStatus::Filed;
1075 store.put(&mut fresh)?;
1076 *chat = fresh;
1077 Ok(task.id)
1078}
1079
1080pub fn abandon(chat: &mut Chat, store: &Chats) -> Result<()> {
1101 let _guard = store.guard();
1102 let _claim = store.claim(&chat.id)?;
1103 let mut fresh = store
1104 .get(&chat.id)
1105 .with_context(|| format!("chat {} could not be re-read", chat.short()))?;
1106 match fresh.status {
1107 ChatStatus::Open => {
1108 fresh.status = ChatStatus::Abandoned;
1109 store.put(&mut fresh)?;
1110 }
1111 ChatStatus::Abandoned => {}
1112 ChatStatus::Filed => bail!(
1113 "chat {} is {} and takes no more turns",
1114 fresh.short(),
1115 fresh.status.as_str()
1116 ),
1117 }
1118 *chat = fresh;
1119 Ok(())
1120}
1121
1122pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
1134 let Some(body) = chat.draft.as_deref() else {
1135 return Err(vec![
1136 "this chat has no draft yet: the agent has not written a task file".to_owned(),
1137 ]);
1138 };
1139 match plan::review_draft(body) {
1140 Ok(()) => Ok(()),
1141 Err(problems) => {
1142 if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
1143 Ok(())
1144 } else {
1145 Err(problems)
1146 }
1147 }
1148 }
1149}
1150
1151pub fn briefing(idea: &str, repo: &Path) -> String {
1166 format!(
1167 "You are the planning leader for magi, which runs a blind \
1168 multi-agent implementation competition: several agents will implement \
1169 the task file you write, in isolated worktrees, unaware of each other, \
1170 and judges will rank the results without knowing who wrote what.\n\n\
1171 Your job is not to implement anything. It is to interview the operator \
1172 until the change is pinned down, and then write one task file.\n\n\
1173 The operator is on a phone. Every message you send is read on a small \
1174 screen, so keep it short: no preamble, no restating what they just \
1175 said.\n\n\
1176 # Repository\n\n{repo}\n\n\
1177 Read it before you start asking. Questions the code already answers \
1178 spend the operator's patience for nothing. Do not modify it: the \
1179 competing agents do the implementation, and a repository you have \
1180 already edited makes their diffs unjudgeable.\n\n\
1181 # The idea\n\n{idea}\n\n\
1182 # How to run the interview\n\n\
1183 - Ask about what you cannot determine yourself: intent, scope, which \
1184 of several defensible designs the operator wants, what must not \
1185 change.\n\
1186 - Ask about ONE thing per message and wait for the answer. This is a \
1187 phone, not a form: a message with five questions in it gets one of \
1188 them answered.\n\
1189 - Do not produce the task file after one exchange.\n\
1190 - Disagree when you have grounds. A leader that agrees with everything \
1191 adds nothing to what the operator already typed.\n\
1192 - Confirm the plan in your own words and get an explicit yes before \
1193 writing.\n\n\
1194 # How to deliver the task file\n\n\
1195 When the operator agrees the plan is right, put the whole task file in \
1196 your reply inside a fenced block tagged `task`, like this:\n\n\
1197 ```task\n\
1198 # <the task file>\n\
1199 ```\n\n\
1200 Nothing else goes in that block, and there is exactly one of them per \
1201 message. magi extracts it and files it; a task file written to a file \
1202 on disk, or pasted without the fence, is one magi cannot see. You may \
1203 send a revised version later in the same conversation - the newest \
1204 `task` block wins - and while you are still asking questions, send no \
1205 `task` block at all.\n\n\
1206 magi will refuse a task file with no completion criteria, so those are \
1207 not optional.\n\n\
1208 # Task file specification\n\n{spec}",
1209 repo = repo.display(),
1210 spec = plan::TASK_FILE_SPEC,
1211 )
1212}
1213
1214fn language_note(language: &str) -> String {
1219 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
1220 String::new()
1221 } else {
1222 format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
1223 }
1224}
1225
1226pub fn extract_draft(reply: &str) -> Option<String> {
1239 let mut last: Option<String> = None;
1240 let mut open: Option<(usize, Vec<&str>)> = None;
1241 for line in reply.lines() {
1242 let trimmed = line.trim_start();
1243 let ticks = trimmed.chars().take_while(|c| *c == '`').count();
1246 match &mut open {
1247 Some((width, body)) => {
1248 if ticks >= *width && trimmed[ticks..].trim().is_empty() {
1249 last = Some(joined(body));
1250 open = None;
1251 } else {
1252 body.push(line);
1253 }
1254 }
1255 None => {
1256 if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
1257 open = Some((ticks, Vec::new()));
1258 }
1259 }
1260 }
1261 }
1262 if let Some((_, body)) = open {
1263 last = Some(joined(&body));
1264 }
1265 last.filter(|s| !s.trim().is_empty())
1266}
1267
1268fn joined(lines: &[&str]) -> String {
1271 if lines.is_empty() {
1272 return String::new();
1273 }
1274 let mut out = lines.join("\n");
1275 out.push('\n');
1276 out
1277}
1278
1279fn read_path(path: &Path) -> Result<Chat> {
1280 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1281 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
1282}
1283
1284fn short(id: &str) -> &str {
1285 id.split('-').next_back().unwrap_or(id)
1286}
1287
1288fn new_id() -> String {
1289 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
1290 let seed = crate::rng::entropy();
1291 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
1292}
1293
1294fn attachment_ext(mime: &str) -> Option<&'static str> {
1299 match mime {
1300 "image/png" => Some("png"),
1301 "image/jpeg" => Some("jpg"),
1302 "image/gif" => Some("gif"),
1303 "image/webp" => Some("webp"),
1304 _ => None,
1305 }
1306}
1307
1308pub fn valid_attachment_id(id: &str) -> bool {
1313 id.len() == 32
1314 && id
1315 .bytes()
1316 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1317}
1318
1319fn new_attachment_id() -> String {
1323 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
1324 format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use std::collections::BTreeMap;
1330
1331 use crate::config::{AgentKind, AgentSpec, Graph};
1332
1333 use super::*;
1334
1335 fn store() -> (tempfile::TempDir, Chats) {
1338 let tmp = tempfile::tempdir().expect("tempdir");
1339 let chats = Chats::at(tmp.path().join("chats"));
1340 (tmp, chats)
1341 }
1342
1343 fn good_draft() -> String {
1346 "# Report per-node durations in `magi show`\n\
1347 \n\
1348 ## Context\n\
1349 \n\
1350 `magi show` prints a run's nodes but not how long any of them took, so \
1351 the operator cannot see which seat is expensive. The data is already \
1352 in `run.events`.\n\
1353 \n\
1354 ## Change\n\
1355 \n\
1356 Add a duration column to the node table in `src/report.rs`.\n\
1357 \n\
1358 ## Constraints\n\
1359 \n\
1360 Do not change the JSON shape of a run record.\n\
1361 \n\
1362 ## Completion criteria\n\
1363 \n\
1364 - [ ] `magi show <run>` prints a duration for every completed node.\n\
1365 - [ ] A node with no end event prints nothing rather than zero.\n\
1366 \n\
1367 ## Out of scope\n\
1368 \n\
1369 The TUI's detail pane.\n"
1370 .to_owned()
1371 }
1372
1373 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
1378 let path = dir.join("mock-chat-agent.sh");
1379 std::fs::write(&path, script).expect("write mock");
1380 AgentSpec {
1381 id: "mock".to_owned(),
1382 kind: AgentKind::Command,
1383 model: None,
1384 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1385 extra_args: Vec::new(),
1386 env,
1387 prompt_delivery: None,
1388 }
1389 }
1390
1391 fn config(spec: AgentSpec) -> Config {
1395 Config {
1396 agents: vec![spec],
1397 graph: Graph {
1398 language: "en".to_owned(),
1399 ..Graph::default()
1400 },
1401 ..Config::default()
1402 }
1403 }
1404
1405 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
1407
1408 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
1410
1411 const ECHO: &str = "#!/bin/sh\ncat\n";
1414
1415 fn env(reply: &str) -> BTreeMap<String, String> {
1416 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
1417 }
1418
1419 #[test]
1420 fn the_frozen_json_field_names_round_trip_through_disk() {
1421 let (tmp, chats) = store();
1422 let mut chat = Chat {
1423 schema: SCHEMA,
1424 id: "20260903-014455-ab12".to_owned(),
1425 repo: tmp.path().to_owned(),
1426 from: None,
1427 agent: "sonnet".to_owned(),
1428 status: ChatStatus::Open,
1429 turns: vec![Turn {
1430 who: Who::Operator,
1431 body: "rework the config loader".to_owned(),
1432 at: Timestamp::now(),
1433 attachments: Vec::new(),
1434 }],
1435 draft: None,
1436 task: None,
1437 created_at: Timestamp::now(),
1438 updated_at: Timestamp::now(),
1439 seat: SeatState::new(SEAT, "sonnet", 7),
1440 };
1441 chats.put(&mut chat).expect("put");
1442
1443 let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
1447 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1448 for field in [
1449 "schema",
1450 "id",
1451 "repo",
1452 "from",
1453 "agent",
1454 "status",
1455 "turns",
1456 "draft",
1457 "task",
1458 "created_at",
1459 "updated_at",
1460 ] {
1461 assert!(v.get(field).is_some(), "missing field `{field}`");
1462 }
1463 assert_eq!(v["schema"], 1);
1464 assert_eq!(v["status"], "open");
1465 assert_eq!(v["turns"][0]["who"], "operator");
1466 assert_eq!(v["turns"][0]["body"], "rework the config loader");
1467 assert!(v["turns"][0].get("at").is_some());
1468 assert!(v["turns"][0].get("attachments").is_some());
1469 assert!(v["draft"].is_null());
1470 assert!(v["task"].is_null());
1471 assert!(v["from"].is_null());
1472
1473 let back = chats.get(&chat.id).expect("get");
1474 assert_eq!(back.id, chat.id);
1475 assert_eq!(back.turns, chat.turns);
1476 assert_eq!(back.status, ChatStatus::Open);
1477 assert_eq!(back.from, None);
1478 }
1479
1480 #[test]
1484 fn a_chat_recorded_without_a_from_field_still_reads() {
1485 let (tmp, chats) = store();
1486 let path = chats.path_of("20260903-014455-ab12");
1487 std::fs::create_dir_all(chats.root()).expect("chats dir");
1488 std::fs::write(
1489 &path,
1490 serde_json::json!({
1491 "schema": SCHEMA,
1492 "id": "20260903-014455-ab12",
1493 "repo": tmp.path(),
1494 "agent": "sonnet",
1495 "status": "open",
1496 "turns": [],
1497 "draft": null,
1498 "task": null,
1499 "created_at": Timestamp::now().to_string(),
1500 "updated_at": Timestamp::now().to_string(),
1501 "seat": SeatState::new(SEAT, "sonnet", 7),
1502 })
1503 .to_string(),
1504 )
1505 .expect("write pre-`from` chat");
1506
1507 let chat = chats.get("20260903-014455-ab12").expect("must still read");
1508 assert_eq!(chat.from, None);
1509 }
1510
1511 #[test]
1515 fn a_chat_recorded_without_attachments_still_reads() {
1516 let (tmp, chats) = store();
1517 let path = chats.path_of("20260903-014455-ab12");
1518 std::fs::create_dir_all(chats.root()).expect("chats dir");
1519 std::fs::write(
1520 &path,
1521 serde_json::json!({
1522 "schema": 1,
1523 "id": "20260903-014455-ab12",
1524 "repo": tmp.path(),
1525 "agent": "sonnet",
1526 "status": "open",
1527 "turns": [
1528 { "who": "operator", "body": "rework the config loader",
1529 "at": Timestamp::now().to_string() },
1530 ],
1531 "draft": null,
1532 "task": null,
1533 "created_at": Timestamp::now().to_string(),
1534 "updated_at": Timestamp::now().to_string(),
1535 "seat": SeatState::new(SEAT, "sonnet", 7),
1536 })
1537 .to_string(),
1538 )
1539 .expect("write pre-attachments chat");
1540
1541 let chat = chats.get("20260903-014455-ab12").expect("must still read");
1542 assert!(chat.turns[0].attachments.is_empty());
1543 }
1544
1545 #[test]
1546 fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1547 let (_tmp, chats) = store();
1548 let chat = Chat {
1549 schema: SCHEMA,
1550 id: "20260903-014455-ab12".to_owned(),
1551 repo: PathBuf::from("/repo/other"),
1552 from: None,
1553 agent: "sonnet".to_owned(),
1554 status: ChatStatus::Open,
1555 turns: vec![
1556 Turn {
1557 who: Who::Operator,
1558 body: "rework the queue drain".to_owned(),
1559 at: Timestamp::now(),
1560 attachments: Vec::new(),
1561 },
1562 Turn {
1563 who: Who::Agent,
1564 body: "which part of the drain?".to_owned(),
1565 at: Timestamp::now(),
1566 attachments: Vec::new(),
1567 },
1568 ],
1569 draft: None,
1570 task: None,
1571 created_at: Timestamp::now(),
1572 updated_at: Timestamp::now(),
1573 seat: SeatState::new(SEAT, "sonnet", 7),
1574 };
1575 let background = derived_background(&chat, &chats);
1576 assert!(background.contains("/repo/other"));
1577 assert!(background.contains("rework the queue drain"));
1578 assert!(background.contains("which part of the drain?"));
1579 assert!(background.contains("different"));
1580 }
1581
1582 #[tokio::test]
1583 async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1584 let (tmp, chats) = store();
1585 let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1586 let source_cfg = config(source_spec);
1587 let source = start(
1588 &chats,
1589 &source_cfg,
1590 tmp.path().to_owned(),
1591 "rework the queue drain",
1592 None,
1593 None,
1594 )
1595 .await
1596 .expect("start source");
1597 let before = source.clone();
1598
1599 let other_repo = tmp.path().join("other-repo");
1600 std::fs::create_dir_all(&other_repo).expect("other repo dir");
1601 let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1604 let derived_cfg = config(echo_spec);
1605 let derived = start(
1606 &chats,
1607 &derived_cfg,
1608 other_repo,
1609 "same idea, different repository",
1610 None,
1611 Some(&source),
1612 )
1613 .await
1614 .expect("start derived");
1615
1616 assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1617
1618 let prompt = &derived.turns.last().expect("agent reply").body;
1619 assert!(prompt.contains("Background: derived from another conversation"));
1620 assert!(prompt.contains(&source.repo.display().to_string()));
1621 assert!(prompt.contains("rework the queue drain"));
1622 assert!(prompt.contains("same idea, different repository"));
1623
1624 let reread = chats.get(&source.id).expect("source still on disk");
1626 assert_eq!(reread.status, before.status);
1627 assert_eq!(reread.turns, before.turns);
1628 assert_eq!(reread.draft, before.draft);
1629 }
1630
1631 #[test]
1637 fn build_constructs_the_record_without_writing_it_anywhere() {
1638 let (tmp, chats) = store();
1639 let spec = mock_agent(tmp.path(), REPLY, BTreeMap::new());
1640 let cfg = config(spec);
1641
1642 let chat = build(
1643 &cfg,
1644 tmp.path().to_owned(),
1645 "rework the config loader",
1646 None,
1647 None,
1648 )
1649 .expect("build");
1650
1651 assert!(
1652 !chats.path_of(&chat.id).is_file(),
1653 "build must not touch the filesystem"
1654 );
1655 assert!(
1656 chats.list().is_empty(),
1657 "no record must be resolvable until something calls `Chats::put`"
1658 );
1659 }
1660
1661 #[tokio::test]
1667 async fn a_chat_prefers_the_chatter_role_over_the_planner_role() {
1668 let (tmp, chats) = store();
1669 let planner_spec = mock_agent(tmp.path(), REPLY, env("from the planner"));
1670 let mut chatter_spec = mock_agent(tmp.path(), REPLY, env("from the chatter"));
1671 chatter_spec.id = "chatter-mock".to_owned();
1672
1673 let mut cfg = Config {
1674 agents: vec![planner_spec.clone(), chatter_spec.clone()],
1675 graph: Graph {
1676 language: "en".to_owned(),
1677 ..Graph::default()
1678 },
1679 ..Config::default()
1680 };
1681 cfg.roles.planner = Some(planner_spec.id.clone());
1682 cfg.roles.chatter = Some(chatter_spec.id.clone());
1683
1684 let chat = start(
1685 &chats,
1686 &cfg,
1687 tmp.path().to_owned(),
1688 "rework the drain",
1689 None,
1690 None,
1691 )
1692 .await
1693 .expect("start with chatter set");
1694 assert_eq!(chat.agent, chatter_spec.id, "chatter must win over planner");
1695
1696 cfg.roles.chatter = None;
1697 let fallback = start(
1698 &chats,
1699 &cfg,
1700 tmp.path().to_owned(),
1701 "rework the drain again",
1702 None,
1703 None,
1704 )
1705 .await
1706 .expect("start with chatter unset");
1707 assert_eq!(
1708 fallback.agent, planner_spec.id,
1709 "unset chatter must fall back to planner, unchanged from before this role existed"
1710 );
1711 }
1712
1713 #[test]
1714 fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1715 let reply = "here is a sketch\n\
1716 \n\
1717 ```rust\n\
1718 fn not_the_draft() {}\n\
1719 ```\n\
1720 \n\
1721 ```task\n\
1722 # first version\n\
1723 ```\n\
1724 \n\
1725 ```json\n\
1726 {\"also\": \"not it\"}\n\
1727 ```\n\
1728 \n\
1729 revised:\n\
1730 \n\
1731 ```task\n\
1732 # second version\n\
1733 ## Completion criteria\n\
1734 ```\n";
1735 assert_eq!(
1736 extract_draft(reply).as_deref(),
1737 Some("# second version\n## Completion criteria\n")
1738 );
1739 }
1740
1741 #[test]
1742 fn extract_draft_returns_none_when_there_is_no_task_block() {
1743 assert_eq!(extract_draft("which storage backend do you want?"), None);
1744 assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1745 assert_eq!(extract_draft("```task\n```\n"), None);
1748 }
1749
1750 #[tokio::test]
1751 async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1752 let (tmp, chats) = store();
1753 let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1754 let cfg = config(spec);
1755 let mut chat = start(
1756 &chats,
1757 &cfg,
1758 tmp.path().to_owned(),
1759 "add durations",
1760 None,
1761 None,
1762 )
1763 .await
1764 .expect("start");
1765 chat.draft = Some(good_draft());
1766 chats.put(&mut chat).expect("put");
1767
1768 say(&mut chat, &chats, &cfg, "the report module", Vec::new())
1769 .await
1770 .expect("say");
1771
1772 assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1773 assert_eq!(
1774 chats.get(&chat.id).expect("get").draft.as_deref(),
1775 Some(good_draft().as_str())
1776 );
1777 }
1778
1779 #[test]
1780 fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1781 let brief = briefing("rework the config loader", Path::new("/repo"));
1782 assert!(brief.contains(plan::TASK_FILE_SPEC));
1785 assert!(brief.contains("```task"));
1786 assert!(brief.contains("rework the config loader"));
1787 assert!(brief.contains("/repo"));
1788 assert!(brief.contains("completion criteria"));
1789 }
1790
1791 #[test]
1792 fn file_draft_refuses_a_bad_draft_with_every_problem() {
1793 let (tmp, chats) = store();
1794 let queue = Queue::at(tmp.path().join("queue"));
1795 let mut chat = Chat {
1796 schema: SCHEMA,
1797 id: "20260903-014455-ab12".to_owned(),
1798 repo: tmp.path().to_owned(),
1799 from: None,
1800 agent: "mock".to_owned(),
1801 status: ChatStatus::Open,
1802 turns: Vec::new(),
1803 draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1806 task: None,
1807 created_at: Timestamp::now(),
1808 updated_at: Timestamp::now(),
1809 seat: SeatState::new(SEAT, "mock", 7),
1810 };
1811 chats.put(&mut chat).expect("put");
1815
1816 let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1817 assert!(
1818 problems.len() >= 2,
1819 "expected every problem, got {problems:?}"
1820 );
1821 assert!(problems.iter().any(|p| p.contains("completion criteria")));
1822 assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1823
1824 let err = file_draft(&mut chat, &chats, &queue, 0)
1825 .expect_err("file_draft must refuse it too")
1826 .to_string();
1827 for p in &problems {
1828 assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1829 }
1830 assert_eq!(chat.status, ChatStatus::Open);
1831 assert!(chat.task.is_none());
1832 assert!(queue.list().is_empty());
1833 }
1834
1835 #[test]
1836 fn file_draft_queues_a_good_draft_and_records_the_task() {
1837 let (tmp, chats) = store();
1838 let queue = Queue::at(tmp.path().join("queue"));
1839 let mut chat = Chat {
1840 schema: SCHEMA,
1841 id: "20260903-014455-cd34".to_owned(),
1842 repo: tmp.path().to_owned(),
1843 from: None,
1844 agent: "mock".to_owned(),
1845 status: ChatStatus::Open,
1846 turns: Vec::new(),
1847 draft: Some(good_draft()),
1848 task: None,
1849 created_at: Timestamp::now(),
1850 updated_at: Timestamp::now(),
1851 seat: SeatState::new(SEAT, "mock", 7),
1852 };
1853 chats.put(&mut chat).expect("put");
1856
1857 let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1858
1859 assert_eq!(chat.status, ChatStatus::Filed);
1860 assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1861 assert_eq!(
1862 chats.get(&chat.id).expect("get").task.as_deref(),
1863 Some(id.as_str()),
1864 "the task id must survive on disk, or the phone shows an unfiled chat"
1865 );
1866
1867 let task = queue.get(&id).expect("queued task");
1868 assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1869 assert_eq!(task.instruction, good_draft());
1870 assert_eq!(task.priority, 5);
1871 assert_eq!(task.source, Source::Human);
1872 }
1873
1874 #[test]
1875 fn abandon_moves_an_open_chat_to_abandoned() {
1876 let (tmp, chats) = store();
1877 let mut chat = Chat {
1878 schema: SCHEMA,
1879 id: "20260903-014455-ab12".to_owned(),
1880 repo: tmp.path().to_owned(),
1881 from: None,
1882 agent: "mock".to_owned(),
1883 status: ChatStatus::Open,
1884 turns: Vec::new(),
1885 draft: None,
1886 task: None,
1887 created_at: Timestamp::now(),
1888 updated_at: Timestamp::now(),
1889 seat: SeatState::new(SEAT, "mock", 7),
1890 };
1891 chats.put(&mut chat).expect("put");
1892
1893 abandon(&mut chat, &chats).expect("abandon");
1894
1895 assert_eq!(chat.status, ChatStatus::Abandoned);
1896 assert_eq!(
1897 chats.get(&chat.id).expect("get").status,
1898 ChatStatus::Abandoned
1899 );
1900 }
1901
1902 #[test]
1903 fn abandoning_an_already_abandoned_chat_is_not_an_error() {
1904 let (tmp, chats) = store();
1905 let mut chat = Chat {
1906 schema: SCHEMA,
1907 id: "20260903-014455-ab13".to_owned(),
1908 repo: tmp.path().to_owned(),
1909 from: None,
1910 agent: "mock".to_owned(),
1911 status: ChatStatus::Abandoned,
1912 turns: Vec::new(),
1913 draft: None,
1914 task: None,
1915 created_at: Timestamp::now(),
1916 updated_at: Timestamp::now(),
1917 seat: SeatState::new(SEAT, "mock", 7),
1918 };
1919 chats.put(&mut chat).expect("put");
1920
1921 abandon(&mut chat, &chats).expect("abandoning twice is not an error");
1922
1923 assert_eq!(chat.status, ChatStatus::Abandoned);
1924 assert_eq!(
1925 chats.get(&chat.id).expect("get").status,
1926 ChatStatus::Abandoned
1927 );
1928 }
1929
1930 #[test]
1931 fn abandon_refuses_a_filed_chat_and_leaves_it_filed() {
1932 let (tmp, chats) = store();
1933 let mut chat = Chat {
1934 schema: SCHEMA,
1935 id: "20260903-014455-ab14".to_owned(),
1936 repo: tmp.path().to_owned(),
1937 from: None,
1938 agent: "mock".to_owned(),
1939 status: ChatStatus::Filed,
1940 turns: Vec::new(),
1941 draft: None,
1942 task: Some("some-task-id".to_owned()),
1943 created_at: Timestamp::now(),
1944 updated_at: Timestamp::now(),
1945 seat: SeatState::new(SEAT, "mock", 7),
1946 };
1947 chats.put(&mut chat).expect("put");
1948
1949 let err = abandon(&mut chat, &chats).expect_err("a filed chat refuses abandon");
1950 assert!(err.to_string().contains("filed"), "{err}");
1951
1952 assert_eq!(
1953 chats.get(&chat.id).expect("get").status,
1954 ChatStatus::Filed,
1955 "a refused abandon must not touch the on-disk status"
1956 );
1957 }
1958
1959 #[tokio::test]
1960 async fn an_abandon_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
1961 let (tmp, chats) = store();
1962 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
1963 let cfg = config(spec);
1964 let mut in_flight = open(
1967 &chats,
1968 &cfg,
1969 tmp.path().to_owned(),
1970 "add durations",
1971 None,
1972 None,
1973 )
1974 .expect("open");
1975
1976 let mut abandoned_elsewhere = chats.get(&in_flight.id).expect("reread");
1980 abandon(&mut abandoned_elsewhere, &chats).expect("abandon");
1981 assert_eq!(
1982 chats.get(&in_flight.id).expect("reread").status,
1983 ChatStatus::Abandoned,
1984 "the abandon landed on disk before the turn finished"
1985 );
1986
1987 assert_eq!(in_flight.status, ChatStatus::Open);
1991 first_turn(&mut in_flight, &chats, &cfg, None)
1992 .await
1993 .expect("the turn itself still completes");
1994
1995 let on_disk = chats.get(&in_flight.id).expect("reread");
1996 assert_eq!(
1997 on_disk.status,
1998 ChatStatus::Abandoned,
1999 "an abandon must stick even when a turn that started before it finishes after it"
2000 );
2001 assert!(
2004 on_disk.turns.iter().any(|t| t.body == "here you go"),
2005 "the in-flight turn's own reply is still recorded: {:?}",
2006 on_disk.turns
2007 );
2008 }
2009
2010 #[test]
2011 fn abandon_blocks_on_the_shared_guard_rather_than_interleaving_with_a_racing_writer() {
2012 let (tmp, chats) = store();
2013 let queue = Queue::at(tmp.path().join("queue"));
2014 let mut chat = Chat {
2015 schema: SCHEMA,
2016 id: "20260903-014455-ee15".to_owned(),
2017 repo: tmp.path().to_owned(),
2018 from: None,
2019 agent: "mock".to_owned(),
2020 status: ChatStatus::Open,
2021 turns: Vec::new(),
2022 draft: Some(good_draft()),
2023 task: None,
2024 created_at: Timestamp::now(),
2025 updated_at: Timestamp::now(),
2026 seat: SeatState::new(SEAT, "mock", 7),
2027 };
2028 chats.put(&mut chat).expect("put");
2029
2030 let held = chats.guard();
2034
2035 let chats2 = chats.clone();
2036 let id = chat.id.clone();
2037 let abandoning = std::thread::spawn(move || {
2038 let mut chat = chats2.get(&id).expect("get");
2039 abandon(&mut chat, &chats2).expect("abandon");
2040 });
2041
2042 std::thread::sleep(Duration::from_millis(50));
2043 assert!(
2044 !abandoning.is_finished(),
2045 "abandon must wait for the guard, not read and write while it is held - \
2046 a re-read alone narrows this window without closing it"
2047 );
2048
2049 drop(held);
2050 abandoning.join().expect("abandon thread panicked");
2051
2052 assert_eq!(
2053 chats.get(&chat.id).expect("reread").status,
2054 ChatStatus::Abandoned,
2055 "once the guard is free, abandon still lands"
2056 );
2057 assert!(queue.list().is_empty(), "file_draft never ran in this test");
2058 }
2059
2060 #[test]
2068 fn abandon_is_refused_while_another_process_holds_the_chats_claim() {
2069 let (tmp, chats) = store();
2070 let mut chat = Chat {
2071 schema: SCHEMA,
2072 id: "20260903-014455-ee16".to_owned(),
2073 repo: tmp.path().to_owned(),
2074 from: None,
2075 agent: "mock".to_owned(),
2076 status: ChatStatus::Open,
2077 turns: Vec::new(),
2078 draft: None,
2079 task: None,
2080 created_at: Timestamp::now(),
2081 updated_at: Timestamp::now(),
2082 seat: SeatState::new(SEAT, "mock", 7),
2083 };
2084 chats.put(&mut chat).expect("put");
2085
2086 let held = chats.claim(&chat.id).expect("claim");
2087 let err = abandon(&mut chat, &chats).expect_err("a claimed chat refuses abandon");
2088 assert!(err.to_string().contains("claimed"), "{err}");
2089 assert_eq!(
2090 chats.get(&chat.id).expect("reread").status,
2091 ChatStatus::Open,
2092 "a refused abandon must not touch the on-disk status"
2093 );
2094
2095 drop(held);
2096 abandon(&mut chat, &chats).expect("abandon succeeds once the claim is released");
2097 assert_eq!(chat.status, ChatStatus::Abandoned);
2098 }
2099
2100 #[tokio::test]
2101 async fn say_appends_the_operator_turn_then_the_agent_turn() {
2102 let (tmp, chats) = store();
2103 let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
2104 let cfg = config(spec);
2105 let mut chat = start(
2106 &chats,
2107 &cfg,
2108 tmp.path().to_owned(),
2109 "add durations",
2110 None,
2111 None,
2112 )
2113 .await
2114 .expect("start");
2115 assert_eq!(chat.turns.len(), 2);
2117 assert_eq!(chat.turns[0].who, Who::Operator);
2118 assert_eq!(chat.turns[1].who, Who::Agent);
2119
2120 say(&mut chat, &chats, &cfg, "the report module", Vec::new())
2121 .await
2122 .expect("say");
2123
2124 assert_eq!(chat.turns.len(), 4);
2125 assert_eq!(chat.turns[2].who, Who::Operator);
2126 assert_eq!(chat.turns[2].body, "the report module");
2127 assert_eq!(chat.turns[3].who, Who::Agent);
2128 assert_eq!(chat.turns[3].body, "which module?");
2129 assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
2130 }
2131
2132 #[tokio::test]
2133 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
2134 let (tmp, chats) = store();
2135 let good = mock_agent(tmp.path(), REPLY, env("which module?"));
2136 let cfg = config(good);
2137 let mut chat = start(
2138 &chats,
2139 &cfg,
2140 tmp.path().to_owned(),
2141 "add durations",
2142 None,
2143 None,
2144 )
2145 .await
2146 .expect("start");
2147
2148 mock_agent(tmp.path(), BROKEN, BTreeMap::new());
2152 let err = say(&mut chat, &chats, &cfg, "the report module", Vec::new())
2153 .await
2154 .expect_err("a turn with no answer is an error");
2155 assert!(err.to_string().contains("no answer"), "{err}");
2156
2157 let on_disk = chats.get(&chat.id).expect("get");
2158 assert_eq!(on_disk.turns.len(), 4);
2159 assert_eq!(
2160 on_disk.turns[2].body, "the report module",
2161 "the operator's message must survive the failure"
2162 );
2163 let note = &on_disk.turns[3];
2164 assert_eq!(note.who, Who::Agent);
2165 assert!(
2166 note.body.starts_with(MAGI_NOTE),
2167 "the failure must be visible in the transcript: {}",
2168 note.body
2169 );
2170 assert!(note.body.contains("your message is saved"));
2171 }
2172
2173 #[tokio::test]
2178 async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
2179 let (tmp, chats) = store();
2180 let spec = mock_agent(tmp.path(), REPLY, env("first reply"));
2181 let cfg = config(spec);
2182 let mut chat = start(
2183 &chats,
2184 &cfg,
2185 tmp.path().to_owned(),
2186 "add durations",
2187 None,
2188 None,
2189 )
2190 .await
2191 .expect("start");
2192
2193 let att = chats
2194 .put_attachment(
2195 &chat.id,
2196 "image/png",
2197 "screenshot.png",
2198 b"pretend-png-bytes",
2199 )
2200 .expect("put attachment");
2201
2202 mock_agent(tmp.path(), ECHO, BTreeMap::new());
2206 say(&mut chat, &chats, &cfg, "", vec![att.clone()])
2207 .await
2208 .expect("an empty body with an attachment is still a turn");
2209
2210 let operator_turn = &chat.turns[chat.turns.len() - 2];
2211 assert_eq!(operator_turn.who, Who::Operator);
2212 assert_eq!(operator_turn.body, "");
2213 assert_eq!(operator_turn.attachments, vec![att.clone()]);
2214
2215 let prompt = &chat.turns.last().expect("agent reply").body;
2216 let expected_path = chats
2217 .attachments_dir(&chat.id)
2218 .join(format!("{}.png", att.id));
2219 assert!(
2220 prompt.contains(&expected_path.display().to_string()),
2221 "the agent must be told the attachment's absolute path: {prompt}"
2222 );
2223 assert!(prompt.contains("image/png"), "and its mime: {prompt}");
2224 }
2225
2226 #[test]
2236 fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
2237 let chats = Chats::at(PathBuf::from("relative-chats-root-for-this-test"));
2238 let att = Attachment {
2239 id: "0".repeat(32),
2240 name: "shot.png".to_owned(),
2241 mime: "image/png".to_owned(),
2242 bytes: 3,
2243 };
2244 let path = chats
2245 .attachment_path("some-chat-id", &att)
2246 .expect("a supported mime always yields a path");
2247 assert!(
2248 path.is_absolute(),
2249 "must be absolute even off a relative store root: {}",
2250 path.display()
2251 );
2252 }
2253
2254 #[tokio::test]
2255 async fn a_turn_past_the_configured_chat_timeout_is_reported_with_that_timeout() {
2256 let (tmp, chats) = store();
2261 let slow = mock_agent(
2262 tmp.path(),
2263 "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
2264 BTreeMap::new(),
2265 );
2266 let mut cfg = config(slow);
2267 cfg.graph.timeout_chat = 1;
2268
2269 let err = start(
2270 &chats,
2271 &cfg,
2272 tmp.path().to_owned(),
2273 "add durations",
2274 None,
2275 None,
2276 )
2277 .await
2278 .expect_err("a turn that never answers is an error");
2279 assert!(
2280 err.to_string().contains("did not answer within 1s"),
2281 "{err}"
2282 );
2283
2284 let on_disk = chats.list();
2285 let chat = &on_disk[0];
2286 let note = chat.turns.last().expect("a note turn was recorded");
2287 assert!(
2288 note.body.contains("did not answer within 1s"),
2289 "the transcript must show the configured timeout: {}",
2290 note.body
2291 );
2292 }
2293
2294 #[test]
2295 fn list_puts_open_chats_before_filed_ones() {
2296 let (tmp, chats) = store();
2297 let make = |id: &str, status: ChatStatus| {
2298 let mut c = Chat {
2299 schema: SCHEMA,
2300 id: id.to_owned(),
2301 repo: tmp.path().to_owned(),
2302 from: None,
2303 agent: "mock".to_owned(),
2304 status,
2305 turns: Vec::new(),
2306 draft: None,
2307 task: None,
2308 created_at: Timestamp::now(),
2309 updated_at: Timestamp::now(),
2310 seat: SeatState::new(SEAT, "mock", 7),
2311 };
2312 chats.put(&mut c).expect("put");
2313 };
2314 make("20260901-000000-0001", ChatStatus::Open);
2316 make("20260902-000000-0002", ChatStatus::Open);
2317 make("20260903-000000-0003", ChatStatus::Filed);
2318
2319 let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
2320 assert_eq!(
2321 ids,
2322 [
2323 "20260902-000000-0002",
2324 "20260901-000000-0001",
2325 "20260903-000000-0003"
2326 ]
2327 );
2328 assert_eq!(chats.count_open(), 2);
2329 }
2330}