1use std::path::{Path, PathBuf};
41use std::time::Duration;
42
43use anyhow::{Context, Result, bail};
44use jiff::Timestamp;
45use serde::{Deserialize, Serialize};
46
47use crate::agent::{self, Invocation, SeatState};
48use crate::config::Config;
49use crate::plan;
50use crate::queue::{self, Queue, Source, Task};
51
52pub const SCHEMA: u32 = 1;
57
58const TURN_TIMEOUT: Duration = Duration::from_secs(300);
70
71const SEAT: &str = "plan";
76
77pub const MAGI_NOTE: &str = "magi: ";
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum Who {
91 Operator,
93 Agent,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct Turn {
102 pub who: Who,
104 pub body: String,
106 pub at: Timestamp,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum ChatStatus {
114 Open,
116 Filed,
118 Abandoned,
121}
122
123impl ChatStatus {
124 pub fn open(self) -> bool {
126 matches!(self, Self::Open)
127 }
128
129 pub fn as_str(self) -> &'static str {
131 match self {
132 Self::Open => "open",
133 Self::Filed => "filed",
134 Self::Abandoned => "abandoned",
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct Chat {
143 pub schema: u32,
145 pub id: String,
147 pub repo: PathBuf,
149 #[serde(default)]
153 pub from: Option<String>,
154 pub agent: String,
156 pub status: ChatStatus,
158 pub turns: Vec<Turn>,
160 pub draft: Option<String>,
162 pub task: Option<String>,
164 pub created_at: Timestamp,
166 pub updated_at: Timestamp,
168 seat: SeatState,
177}
178
179impl Chat {
180 pub fn short(&self) -> &str {
182 short(&self.id)
183 }
184
185 pub fn agent_turns(&self) -> usize {
191 self.seat.turns
192 }
193}
194
195#[derive(Debug, Clone)]
197pub struct Chats {
198 root: PathBuf,
199}
200
201impl Chats {
202 pub fn open() -> Self {
204 Self::at(crate::run::home().join("chats"))
205 }
206
207 pub fn at(root: PathBuf) -> Self {
210 Self { root }
211 }
212
213 pub fn root(&self) -> &Path {
215 &self.root
216 }
217
218 pub fn path_of(&self, id: &str) -> PathBuf {
220 self.root.join(format!("{id}.json"))
221 }
222
223 pub fn artifacts_of(&self, id: &str) -> PathBuf {
230 self.root.join(format!("{id}.artifacts"))
231 }
232
233 pub fn put(&self, c: &mut Chat) -> Result<()> {
237 std::fs::create_dir_all(&self.root)
238 .with_context(|| format!("create {}", self.root.display()))?;
239 c.updated_at = Timestamp::now();
240 let body = serde_json::to_string_pretty(c).context("serialize chat")?;
241 let path = self.path_of(&c.id);
242 let tmp = path.with_extension("json.tmp");
243 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
244 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
245 Ok(())
246 }
247
248 pub fn get(&self, id: &str) -> Result<Chat> {
250 let resolved = self.resolve_id(id)?;
251 read_path(&self.path_of(&resolved))
252 }
253
254 pub fn list(&self) -> Vec<Chat> {
262 let mut all: Vec<Chat> = std::fs::read_dir(&self.root)
263 .into_iter()
264 .flatten()
265 .flatten()
266 .map(|e| e.path())
267 .filter(|p| p.extension().is_some_and(|x| x == "json"))
268 .filter_map(|p| read_path(&p).ok())
269 .collect();
270 all.sort_unstable_by(|a, b| {
271 let rank = |c: &Chat| u8::from(!c.status.open());
272 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
273 });
274 all
275 }
276
277 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
280 if self.path_of(prefix).is_file() {
281 return Ok(prefix.to_owned());
282 }
283 let hits: Vec<String> = self
284 .list()
285 .into_iter()
286 .map(|c| c.id)
287 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
288 .collect();
289 match hits.len() {
290 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
291 0 => bail!("no chat matches `{prefix}`"),
292 _ => bail!(
293 "`{prefix}` matches {} chats: {}",
294 hits.len(),
295 hits.join(", ")
296 ),
297 }
298 }
299
300 pub fn revision(&self) -> u64 {
304 std::fs::read_dir(&self.root)
305 .into_iter()
306 .flatten()
307 .flatten()
308 .filter_map(|e| e.metadata().ok())
309 .filter_map(|m| m.modified().ok())
310 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
311 .map(|d| d.as_millis() as u64)
312 .max()
313 .unwrap_or(0)
314 }
315
316 pub fn count_open(&self) -> usize {
318 self.list().iter().filter(|c| c.status.open()).count()
319 }
320}
321
322pub fn build(
343 cfg: &Config,
344 repo: PathBuf,
345 idea: &str,
346 agent: Option<&str>,
347 from: Option<&Chat>,
348) -> Result<Chat> {
349 let idea = idea.trim();
350 if idea.is_empty() {
351 bail!("an interview needs something to start from: say what you want to change");
352 }
353 let repo = repo.canonicalize().unwrap_or(repo);
357 let want = agent
364 .or(cfg.roles.chatter.as_deref())
365 .or(cfg.roles.planner.as_deref());
366 let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
367
368 let now = Timestamp::now();
369 let id = new_id();
370 Ok(Chat {
371 schema: SCHEMA,
372 id,
373 repo,
374 from: from.map(|c| c.id.clone()),
375 agent: spec.id.clone(),
376 status: ChatStatus::Open,
377 turns: vec![Turn {
378 who: Who::Operator,
379 body: idea.to_owned(),
380 at: now,
381 }],
382 draft: None,
383 task: None,
384 created_at: now,
385 updated_at: now,
386 seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
387 })
388}
389
390pub fn open(
397 store: &Chats,
398 cfg: &Config,
399 repo: PathBuf,
400 idea: &str,
401 agent: Option<&str>,
402 from: Option<&Chat>,
403) -> Result<Chat> {
404 let mut chat = build(cfg, repo, idea, agent, from)?;
405 store.put(&mut chat)?;
406 Ok(chat)
407}
408
409pub async fn first_turn(
416 chat: &mut Chat,
417 store: &Chats,
418 cfg: &Config,
419 from: Option<&Chat>,
420) -> Result<()> {
421 let idea = chat
422 .turns
423 .first()
424 .map(|t| t.body.as_str())
425 .unwrap_or_default();
426 let mut prompt = briefing(idea, &chat.repo);
427 if let Some(source) = from {
428 prompt = format!("{}\n\n{prompt}", derived_background(source));
432 }
433 prompt.push_str(&language_note(&cfg.graph.language));
434 turn(chat, store, cfg, &prompt).await
435}
436
437pub async fn start(
445 store: &Chats,
446 cfg: &Config,
447 repo: PathBuf,
448 idea: &str,
449 agent: Option<&str>,
450 from: Option<&Chat>,
451) -> Result<Chat> {
452 let mut chat = open(store, cfg, repo, idea, agent, from)?;
453 first_turn(&mut chat, store, cfg, from).await?;
454 Ok(chat)
455}
456
457pub fn derived_background(from: &Chat) -> String {
466 format!(
467 "# Background: derived from another conversation\n\n\
468 This interview continues from a conversation about a *different* \
469 repository. Read it for context, but do not treat it as being about \
470 the repository named below in \"# Repository\" - that repository may \
471 have nothing to do with this one.\n\n\
472 Source repository: {}\n\n{}",
473 from.repo.display(),
474 transcript(from),
475 )
476}
477
478pub async fn say(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
490 if !chat.status.open() {
491 bail!(
492 "chat {} is {} and takes no more turns",
493 chat.short(),
494 chat.status.as_str()
495 );
496 }
497 let text = text.trim();
498 if text.is_empty() {
499 bail!("nothing to say");
500 }
501 let text = record(chat, store, text)?;
502 turn(chat, store, cfg, &text).await
503}
504
505pub fn record(chat: &mut Chat, store: &Chats, text: &str) -> Result<String> {
516 if !chat.status.open() {
517 bail!(
518 "chat {} is {} and takes no more turns",
519 chat.short(),
520 chat.status.as_str()
521 );
522 }
523 let text = text.trim();
524 if text.is_empty() {
525 bail!("nothing to say");
526 }
527 chat.turns.push(Turn {
528 who: Who::Operator,
529 body: text.to_owned(),
530 at: Timestamp::now(),
531 });
532 store.put(chat)?;
533 Ok(text.to_owned())
534}
535
536pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
542 turn(chat, store, cfg, text).await
543}
544
545async fn turn(chat: &mut Chat, store: &Chats, cfg: &Config, prompt: &str) -> Result<()> {
557 let spec = cfg
558 .agents
559 .iter()
560 .find(|a| a.id == chat.agent)
561 .with_context(|| {
562 format!(
563 "chat {} was interviewed by agent `{}`, which is no longer in \
564 the roster; restore it in magi.toml or start a new chat",
565 chat.short(),
566 chat.agent
567 )
568 })?;
569
570 let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
571 let body = if resuming {
572 prompt.to_owned()
573 } else {
574 format!("{}\n\n{prompt}", transcript(chat))
575 };
576
577 let artifacts = store.artifacts_of(&chat.id);
578 let stem = format!("turn-{}", chat.seat.turns + 1);
579 let cache_dir = cfg.cache_dir();
580 let inv = Invocation {
581 cwd: &chat.repo,
582 prompt: &body,
583 timeout: TURN_TIMEOUT,
584 allow_write: false,
589 sessions: cfg.graph.sessions,
590 artifacts: &artifacts,
591 stem: &stem,
592 run: &chat.id,
593 node: "chat",
594 cache_dir: cache_dir.as_deref(),
595 };
596
597 let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
598 let note = |why: String| Turn {
599 who: Who::Agent,
600 body: format!("{MAGI_NOTE}{why}"),
601 at: Timestamp::now(),
602 };
603 let (reply, failure) = match outcome {
604 Err(e) => (
605 note(format!("could not run agent `{}`: {e}", chat.agent)),
606 Some(format!("could not run agent `{}`: {e}", chat.agent)),
607 ),
608 Ok(out) if out.quota_exhausted() => {
609 let reset = out
610 .quota
611 .as_ref()
612 .and_then(|q| q.reset.clone())
613 .map_or_else(String::new, |r| format!(" (resets {r})"));
614 let why = format!(
615 "agent `{}` is out of quota{reset}; your message is saved, so \
616 say it again when the window reopens",
617 chat.agent
618 );
619 (note(why.clone()), Some(why))
620 }
621 Ok(out) if out.timed_out => {
622 let why = format!(
623 "agent `{}` did not answer within {}s; your message is saved",
624 chat.agent,
625 TURN_TIMEOUT.as_secs()
626 );
627 (note(why.clone()), Some(why))
628 }
629 Ok(out) if !out.usable() => {
630 let why = format!(
631 "agent `{}` produced no answer (exit {}); your message is saved",
632 chat.agent,
633 out.exit_code
634 .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
635 );
636 (note(why.clone()), Some(why))
637 }
638 Ok(out) => (
639 Turn {
640 who: Who::Agent,
641 body: out.text.trim().to_owned(),
642 at: Timestamp::now(),
643 },
644 None,
645 ),
646 };
647
648 if let Some(draft) = extract_draft(&reply.body) {
652 chat.draft = Some(draft);
653 }
654 chat.turns.push(reply);
655 store.put(chat)?;
656
657 match failure {
658 Some(why) => bail!("{why}"),
659 None => Ok(()),
660 }
661}
662
663fn transcript(chat: &Chat) -> String {
670 let mut out = String::from(
671 "You are mid-interview. This CLI cannot resume its own conversation, \
672 so here is everything said so far; answer only the last message.\n",
673 );
674 for t in &chat.turns {
675 let who = match t.who {
676 Who::Operator => "operator",
677 Who::Agent => "you",
678 };
679 out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
680 }
681 out
682}
683
684pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
690 if let Err(problems) = draft_problems(chat) {
691 bail!(
692 "this draft is not fileable yet:\n- {}",
693 problems.join("\n- ")
694 );
695 }
696 let body = chat
697 .draft
698 .clone()
699 .expect("draft_problems accepted a chat with a draft");
700
701 let title = queue::title_from(&body, 72);
705 let mut task = Task::new(title, body, chat.repo.clone(), Source::Human);
709 task.priority = priority;
710 queue.put(&mut task)?;
711
712 chat.task = Some(task.id.clone());
713 chat.status = ChatStatus::Filed;
714 store.put(chat)?;
715 Ok(task.id)
716}
717
718pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
730 let Some(body) = chat.draft.as_deref() else {
731 return Err(vec![
732 "this chat has no draft yet: the agent has not written a task file".to_owned(),
733 ]);
734 };
735 match plan::review_draft(body) {
736 Ok(()) => Ok(()),
737 Err(problems) => {
738 if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
739 Ok(())
740 } else {
741 Err(problems)
742 }
743 }
744 }
745}
746
747pub fn briefing(idea: &str, repo: &Path) -> String {
762 format!(
763 "You are the planning leader for magi, which runs a blind \
764 multi-agent implementation competition: several agents will implement \
765 the task file you write, in isolated worktrees, unaware of each other, \
766 and judges will rank the results without knowing who wrote what.\n\n\
767 Your job is not to implement anything. It is to interview the operator \
768 until the change is pinned down, and then write one task file.\n\n\
769 The operator is on a phone. Every message you send is read on a small \
770 screen, so keep it short: no preamble, no restating what they just \
771 said.\n\n\
772 # Repository\n\n{repo}\n\n\
773 Read it before you start asking. Questions the code already answers \
774 spend the operator's patience for nothing. Do not modify it: the \
775 competing agents do the implementation, and a repository you have \
776 already edited makes their diffs unjudgeable.\n\n\
777 # The idea\n\n{idea}\n\n\
778 # How to run the interview\n\n\
779 - Ask about what you cannot determine yourself: intent, scope, which \
780 of several defensible designs the operator wants, what must not \
781 change.\n\
782 - Ask about ONE thing per message and wait for the answer. This is a \
783 phone, not a form: a message with five questions in it gets one of \
784 them answered.\n\
785 - Do not produce the task file after one exchange.\n\
786 - Disagree when you have grounds. A leader that agrees with everything \
787 adds nothing to what the operator already typed.\n\
788 - Confirm the plan in your own words and get an explicit yes before \
789 writing.\n\n\
790 # How to deliver the task file\n\n\
791 When the operator agrees the plan is right, put the whole task file in \
792 your reply inside a fenced block tagged `task`, like this:\n\n\
793 ```task\n\
794 # <the task file>\n\
795 ```\n\n\
796 Nothing else goes in that block, and there is exactly one of them per \
797 message. magi extracts it and files it; a task file written to a file \
798 on disk, or pasted without the fence, is one magi cannot see. You may \
799 send a revised version later in the same conversation - the newest \
800 `task` block wins - and while you are still asking questions, send no \
801 `task` block at all.\n\n\
802 magi will refuse a task file with no completion criteria, so those are \
803 not optional.\n\n\
804 # Task file specification\n\n{spec}",
805 repo = repo.display(),
806 spec = plan::TASK_FILE_SPEC,
807 )
808}
809
810fn language_note(language: &str) -> String {
815 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
816 String::new()
817 } else {
818 format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
819 }
820}
821
822pub fn extract_draft(reply: &str) -> Option<String> {
835 let mut last: Option<String> = None;
836 let mut open: Option<(usize, Vec<&str>)> = None;
837 for line in reply.lines() {
838 let trimmed = line.trim_start();
839 let ticks = trimmed.chars().take_while(|c| *c == '`').count();
842 match &mut open {
843 Some((width, body)) => {
844 if ticks >= *width && trimmed[ticks..].trim().is_empty() {
845 last = Some(joined(body));
846 open = None;
847 } else {
848 body.push(line);
849 }
850 }
851 None => {
852 if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
853 open = Some((ticks, Vec::new()));
854 }
855 }
856 }
857 }
858 if let Some((_, body)) = open {
859 last = Some(joined(&body));
860 }
861 last.filter(|s| !s.trim().is_empty())
862}
863
864fn joined(lines: &[&str]) -> String {
867 if lines.is_empty() {
868 return String::new();
869 }
870 let mut out = lines.join("\n");
871 out.push('\n');
872 out
873}
874
875fn read_path(path: &Path) -> Result<Chat> {
876 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
877 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
878}
879
880fn short(id: &str) -> &str {
881 id.split('-').next_back().unwrap_or(id)
882}
883
884fn new_id() -> String {
885 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
886 let seed = crate::rng::entropy();
887 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
888}
889
890#[cfg(test)]
891mod tests {
892 use std::collections::BTreeMap;
893
894 use crate::config::{AgentKind, AgentSpec, Graph};
895
896 use super::*;
897
898 fn store() -> (tempfile::TempDir, Chats) {
901 let tmp = tempfile::tempdir().expect("tempdir");
902 let chats = Chats::at(tmp.path().join("chats"));
903 (tmp, chats)
904 }
905
906 fn good_draft() -> String {
909 "# Report per-node durations in `magi show`\n\
910 \n\
911 ## Context\n\
912 \n\
913 `magi show` prints a run's nodes but not how long any of them took, so \
914 the operator cannot see which seat is expensive. The data is already \
915 in `run.events`.\n\
916 \n\
917 ## Change\n\
918 \n\
919 Add a duration column to the node table in `src/report.rs`.\n\
920 \n\
921 ## Constraints\n\
922 \n\
923 Do not change the JSON shape of a run record.\n\
924 \n\
925 ## Completion criteria\n\
926 \n\
927 - [ ] `magi show <run>` prints a duration for every completed node.\n\
928 - [ ] A node with no end event prints nothing rather than zero.\n\
929 \n\
930 ## Out of scope\n\
931 \n\
932 The TUI's detail pane.\n"
933 .to_owned()
934 }
935
936 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
941 let path = dir.join("mock-chat-agent.sh");
942 std::fs::write(&path, script).expect("write mock");
943 AgentSpec {
944 id: "mock".to_owned(),
945 kind: AgentKind::Command,
946 model: None,
947 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
948 extra_args: Vec::new(),
949 env,
950 prompt_delivery: None,
951 }
952 }
953
954 fn config(spec: AgentSpec) -> Config {
958 Config {
959 agents: vec![spec],
960 graph: Graph {
961 language: "en".to_owned(),
962 ..Graph::default()
963 },
964 ..Config::default()
965 }
966 }
967
968 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
970
971 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
973
974 const ECHO: &str = "#!/bin/sh\ncat\n";
977
978 fn env(reply: &str) -> BTreeMap<String, String> {
979 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
980 }
981
982 #[test]
983 fn the_frozen_json_field_names_round_trip_through_disk() {
984 let (tmp, chats) = store();
985 let mut chat = Chat {
986 schema: SCHEMA,
987 id: "20260903-014455-ab12".to_owned(),
988 repo: tmp.path().to_owned(),
989 from: None,
990 agent: "sonnet".to_owned(),
991 status: ChatStatus::Open,
992 turns: vec![Turn {
993 who: Who::Operator,
994 body: "rework the config loader".to_owned(),
995 at: Timestamp::now(),
996 }],
997 draft: None,
998 task: None,
999 created_at: Timestamp::now(),
1000 updated_at: Timestamp::now(),
1001 seat: SeatState::new(SEAT, "sonnet", 7),
1002 };
1003 chats.put(&mut chat).expect("put");
1004
1005 let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
1009 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1010 for field in [
1011 "schema",
1012 "id",
1013 "repo",
1014 "from",
1015 "agent",
1016 "status",
1017 "turns",
1018 "draft",
1019 "task",
1020 "created_at",
1021 "updated_at",
1022 ] {
1023 assert!(v.get(field).is_some(), "missing field `{field}`");
1024 }
1025 assert_eq!(v["schema"], 1);
1026 assert_eq!(v["status"], "open");
1027 assert_eq!(v["turns"][0]["who"], "operator");
1028 assert_eq!(v["turns"][0]["body"], "rework the config loader");
1029 assert!(v["turns"][0].get("at").is_some());
1030 assert!(v["draft"].is_null());
1031 assert!(v["task"].is_null());
1032 assert!(v["from"].is_null());
1033
1034 let back = chats.get(&chat.id).expect("get");
1035 assert_eq!(back.id, chat.id);
1036 assert_eq!(back.turns, chat.turns);
1037 assert_eq!(back.status, ChatStatus::Open);
1038 assert_eq!(back.from, None);
1039 }
1040
1041 #[test]
1045 fn a_chat_recorded_without_a_from_field_still_reads() {
1046 let (tmp, chats) = store();
1047 let path = chats.path_of("20260903-014455-ab12");
1048 std::fs::create_dir_all(chats.root()).expect("chats dir");
1049 std::fs::write(
1050 &path,
1051 serde_json::json!({
1052 "schema": SCHEMA,
1053 "id": "20260903-014455-ab12",
1054 "repo": tmp.path(),
1055 "agent": "sonnet",
1056 "status": "open",
1057 "turns": [],
1058 "draft": null,
1059 "task": null,
1060 "created_at": Timestamp::now().to_string(),
1061 "updated_at": Timestamp::now().to_string(),
1062 "seat": SeatState::new(SEAT, "sonnet", 7),
1063 })
1064 .to_string(),
1065 )
1066 .expect("write pre-`from` chat");
1067
1068 let chat = chats.get("20260903-014455-ab12").expect("must still read");
1069 assert_eq!(chat.from, None);
1070 }
1071
1072 #[test]
1073 fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1074 let chat = Chat {
1075 schema: SCHEMA,
1076 id: "20260903-014455-ab12".to_owned(),
1077 repo: PathBuf::from("/repo/other"),
1078 from: None,
1079 agent: "sonnet".to_owned(),
1080 status: ChatStatus::Open,
1081 turns: vec![
1082 Turn {
1083 who: Who::Operator,
1084 body: "rework the queue drain".to_owned(),
1085 at: Timestamp::now(),
1086 },
1087 Turn {
1088 who: Who::Agent,
1089 body: "which part of the drain?".to_owned(),
1090 at: Timestamp::now(),
1091 },
1092 ],
1093 draft: None,
1094 task: None,
1095 created_at: Timestamp::now(),
1096 updated_at: Timestamp::now(),
1097 seat: SeatState::new(SEAT, "sonnet", 7),
1098 };
1099 let background = derived_background(&chat);
1100 assert!(background.contains("/repo/other"));
1101 assert!(background.contains("rework the queue drain"));
1102 assert!(background.contains("which part of the drain?"));
1103 assert!(background.contains("different"));
1104 }
1105
1106 #[tokio::test]
1107 async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1108 let (tmp, chats) = store();
1109 let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1110 let source_cfg = config(source_spec);
1111 let source = start(
1112 &chats,
1113 &source_cfg,
1114 tmp.path().to_owned(),
1115 "rework the queue drain",
1116 None,
1117 None,
1118 )
1119 .await
1120 .expect("start source");
1121 let before = source.clone();
1122
1123 let other_repo = tmp.path().join("other-repo");
1124 std::fs::create_dir_all(&other_repo).expect("other repo dir");
1125 let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1128 let derived_cfg = config(echo_spec);
1129 let derived = start(
1130 &chats,
1131 &derived_cfg,
1132 other_repo,
1133 "same idea, different repository",
1134 None,
1135 Some(&source),
1136 )
1137 .await
1138 .expect("start derived");
1139
1140 assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1141
1142 let prompt = &derived.turns.last().expect("agent reply").body;
1143 assert!(prompt.contains("Background: derived from another conversation"));
1144 assert!(prompt.contains(&source.repo.display().to_string()));
1145 assert!(prompt.contains("rework the queue drain"));
1146 assert!(prompt.contains("same idea, different repository"));
1147
1148 let reread = chats.get(&source.id).expect("source still on disk");
1150 assert_eq!(reread.status, before.status);
1151 assert_eq!(reread.turns, before.turns);
1152 assert_eq!(reread.draft, before.draft);
1153 }
1154
1155 #[test]
1161 fn build_constructs_the_record_without_writing_it_anywhere() {
1162 let (tmp, chats) = store();
1163 let spec = mock_agent(tmp.path(), REPLY, BTreeMap::new());
1164 let cfg = config(spec);
1165
1166 let chat = build(
1167 &cfg,
1168 tmp.path().to_owned(),
1169 "rework the config loader",
1170 None,
1171 None,
1172 )
1173 .expect("build");
1174
1175 assert!(
1176 !chats.path_of(&chat.id).is_file(),
1177 "build must not touch the filesystem"
1178 );
1179 assert!(
1180 chats.list().is_empty(),
1181 "no record must be resolvable until something calls `Chats::put`"
1182 );
1183 }
1184
1185 #[tokio::test]
1191 async fn a_chat_prefers_the_chatter_role_over_the_planner_role() {
1192 let (tmp, chats) = store();
1193 let planner_spec = mock_agent(tmp.path(), REPLY, env("from the planner"));
1194 let mut chatter_spec = mock_agent(tmp.path(), REPLY, env("from the chatter"));
1195 chatter_spec.id = "chatter-mock".to_owned();
1196
1197 let mut cfg = Config {
1198 agents: vec![planner_spec.clone(), chatter_spec.clone()],
1199 graph: Graph {
1200 language: "en".to_owned(),
1201 ..Graph::default()
1202 },
1203 ..Config::default()
1204 };
1205 cfg.roles.planner = Some(planner_spec.id.clone());
1206 cfg.roles.chatter = Some(chatter_spec.id.clone());
1207
1208 let chat = start(
1209 &chats,
1210 &cfg,
1211 tmp.path().to_owned(),
1212 "rework the drain",
1213 None,
1214 None,
1215 )
1216 .await
1217 .expect("start with chatter set");
1218 assert_eq!(chat.agent, chatter_spec.id, "chatter must win over planner");
1219
1220 cfg.roles.chatter = None;
1221 let fallback = start(
1222 &chats,
1223 &cfg,
1224 tmp.path().to_owned(),
1225 "rework the drain again",
1226 None,
1227 None,
1228 )
1229 .await
1230 .expect("start with chatter unset");
1231 assert_eq!(
1232 fallback.agent, planner_spec.id,
1233 "unset chatter must fall back to planner, unchanged from before this role existed"
1234 );
1235 }
1236
1237 #[test]
1238 fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1239 let reply = "here is a sketch\n\
1240 \n\
1241 ```rust\n\
1242 fn not_the_draft() {}\n\
1243 ```\n\
1244 \n\
1245 ```task\n\
1246 # first version\n\
1247 ```\n\
1248 \n\
1249 ```json\n\
1250 {\"also\": \"not it\"}\n\
1251 ```\n\
1252 \n\
1253 revised:\n\
1254 \n\
1255 ```task\n\
1256 # second version\n\
1257 ## Completion criteria\n\
1258 ```\n";
1259 assert_eq!(
1260 extract_draft(reply).as_deref(),
1261 Some("# second version\n## Completion criteria\n")
1262 );
1263 }
1264
1265 #[test]
1266 fn extract_draft_returns_none_when_there_is_no_task_block() {
1267 assert_eq!(extract_draft("which storage backend do you want?"), None);
1268 assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1269 assert_eq!(extract_draft("```task\n```\n"), None);
1272 }
1273
1274 #[tokio::test]
1275 async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1276 let (tmp, chats) = store();
1277 let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1278 let cfg = config(spec);
1279 let mut chat = start(
1280 &chats,
1281 &cfg,
1282 tmp.path().to_owned(),
1283 "add durations",
1284 None,
1285 None,
1286 )
1287 .await
1288 .expect("start");
1289 chat.draft = Some(good_draft());
1290 chats.put(&mut chat).expect("put");
1291
1292 say(&mut chat, &chats, &cfg, "the report module")
1293 .await
1294 .expect("say");
1295
1296 assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1297 assert_eq!(
1298 chats.get(&chat.id).expect("get").draft.as_deref(),
1299 Some(good_draft().as_str())
1300 );
1301 }
1302
1303 #[test]
1304 fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1305 let brief = briefing("rework the config loader", Path::new("/repo"));
1306 assert!(brief.contains(plan::TASK_FILE_SPEC));
1309 assert!(brief.contains("```task"));
1310 assert!(brief.contains("rework the config loader"));
1311 assert!(brief.contains("/repo"));
1312 assert!(brief.contains("completion criteria"));
1313 }
1314
1315 #[test]
1316 fn file_draft_refuses_a_bad_draft_with_every_problem() {
1317 let (tmp, chats) = store();
1318 let queue = Queue::at(tmp.path().join("queue"));
1319 let mut chat = Chat {
1320 schema: SCHEMA,
1321 id: "20260903-014455-ab12".to_owned(),
1322 repo: tmp.path().to_owned(),
1323 from: None,
1324 agent: "mock".to_owned(),
1325 status: ChatStatus::Open,
1326 turns: Vec::new(),
1327 draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1330 task: None,
1331 created_at: Timestamp::now(),
1332 updated_at: Timestamp::now(),
1333 seat: SeatState::new(SEAT, "mock", 7),
1334 };
1335
1336 let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1337 assert!(
1338 problems.len() >= 2,
1339 "expected every problem, got {problems:?}"
1340 );
1341 assert!(problems.iter().any(|p| p.contains("completion criteria")));
1342 assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1343
1344 let err = file_draft(&mut chat, &chats, &queue, 0)
1345 .expect_err("file_draft must refuse it too")
1346 .to_string();
1347 for p in &problems {
1348 assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1349 }
1350 assert_eq!(chat.status, ChatStatus::Open);
1351 assert!(chat.task.is_none());
1352 assert!(queue.list().is_empty());
1353 }
1354
1355 #[test]
1356 fn file_draft_queues_a_good_draft_and_records_the_task() {
1357 let (tmp, chats) = store();
1358 let queue = Queue::at(tmp.path().join("queue"));
1359 let mut chat = Chat {
1360 schema: SCHEMA,
1361 id: "20260903-014455-cd34".to_owned(),
1362 repo: tmp.path().to_owned(),
1363 from: None,
1364 agent: "mock".to_owned(),
1365 status: ChatStatus::Open,
1366 turns: Vec::new(),
1367 draft: Some(good_draft()),
1368 task: None,
1369 created_at: Timestamp::now(),
1370 updated_at: Timestamp::now(),
1371 seat: SeatState::new(SEAT, "mock", 7),
1372 };
1373
1374 let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1375
1376 assert_eq!(chat.status, ChatStatus::Filed);
1377 assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1378 assert_eq!(
1379 chats.get(&chat.id).expect("get").task.as_deref(),
1380 Some(id.as_str()),
1381 "the task id must survive on disk, or the phone shows an unfiled chat"
1382 );
1383
1384 let task = queue.get(&id).expect("queued task");
1385 assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1386 assert_eq!(task.instruction, good_draft());
1387 assert_eq!(task.priority, 5);
1388 assert_eq!(task.source, Source::Human);
1389 }
1390
1391 #[tokio::test]
1392 async fn say_appends_the_operator_turn_then_the_agent_turn() {
1393 let (tmp, chats) = store();
1394 let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1395 let cfg = config(spec);
1396 let mut chat = start(
1397 &chats,
1398 &cfg,
1399 tmp.path().to_owned(),
1400 "add durations",
1401 None,
1402 None,
1403 )
1404 .await
1405 .expect("start");
1406 assert_eq!(chat.turns.len(), 2);
1408 assert_eq!(chat.turns[0].who, Who::Operator);
1409 assert_eq!(chat.turns[1].who, Who::Agent);
1410
1411 say(&mut chat, &chats, &cfg, "the report module")
1412 .await
1413 .expect("say");
1414
1415 assert_eq!(chat.turns.len(), 4);
1416 assert_eq!(chat.turns[2].who, Who::Operator);
1417 assert_eq!(chat.turns[2].body, "the report module");
1418 assert_eq!(chat.turns[3].who, Who::Agent);
1419 assert_eq!(chat.turns[3].body, "which module?");
1420 assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
1421 }
1422
1423 #[tokio::test]
1424 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1425 let (tmp, chats) = store();
1426 let good = mock_agent(tmp.path(), REPLY, env("which module?"));
1427 let cfg = config(good);
1428 let mut chat = start(
1429 &chats,
1430 &cfg,
1431 tmp.path().to_owned(),
1432 "add durations",
1433 None,
1434 None,
1435 )
1436 .await
1437 .expect("start");
1438
1439 mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1443 let err = say(&mut chat, &chats, &cfg, "the report module")
1444 .await
1445 .expect_err("a turn with no answer is an error");
1446 assert!(err.to_string().contains("no answer"), "{err}");
1447
1448 let on_disk = chats.get(&chat.id).expect("get");
1449 assert_eq!(on_disk.turns.len(), 4);
1450 assert_eq!(
1451 on_disk.turns[2].body, "the report module",
1452 "the operator's message must survive the failure"
1453 );
1454 let note = &on_disk.turns[3];
1455 assert_eq!(note.who, Who::Agent);
1456 assert!(
1457 note.body.starts_with(MAGI_NOTE),
1458 "the failure must be visible in the transcript: {}",
1459 note.body
1460 );
1461 assert!(note.body.contains("your message is saved"));
1462 }
1463
1464 #[test]
1465 fn list_puts_open_chats_before_filed_ones() {
1466 let (tmp, chats) = store();
1467 let make = |id: &str, status: ChatStatus| {
1468 let mut c = Chat {
1469 schema: SCHEMA,
1470 id: id.to_owned(),
1471 repo: tmp.path().to_owned(),
1472 from: None,
1473 agent: "mock".to_owned(),
1474 status,
1475 turns: Vec::new(),
1476 draft: None,
1477 task: None,
1478 created_at: Timestamp::now(),
1479 updated_at: Timestamp::now(),
1480 seat: SeatState::new(SEAT, "mock", 7),
1481 };
1482 chats.put(&mut c).expect("put");
1483 };
1484 make("20260901-000000-0001", ChatStatus::Open);
1486 make("20260902-000000-0002", ChatStatus::Open);
1487 make("20260903-000000-0003", ChatStatus::Filed);
1488
1489 let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
1490 assert_eq!(
1491 ids,
1492 [
1493 "20260902-000000-0002",
1494 "20260901-000000-0001",
1495 "20260903-000000-0003"
1496 ]
1497 );
1498 assert_eq!(chats.count_open(), 2);
1499 }
1500}