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 async fn start(
339 store: &Chats,
340 cfg: &Config,
341 repo: PathBuf,
342 idea: &str,
343 agent: Option<&str>,
344 from: Option<&Chat>,
345) -> Result<Chat> {
346 let idea = idea.trim();
347 if idea.is_empty() {
348 bail!("an interview needs something to start from: say what you want to change");
349 }
350 let repo = repo.canonicalize().unwrap_or(repo);
354 let want = agent.or(cfg.roles.planner.as_deref());
358 let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
359
360 let now = Timestamp::now();
361 let id = new_id();
362 let mut chat = Chat {
363 schema: SCHEMA,
364 id,
365 repo,
366 from: from.map(|c| c.id.clone()),
367 agent: spec.id.clone(),
368 status: ChatStatus::Open,
369 turns: vec![Turn {
370 who: Who::Operator,
371 body: idea.to_owned(),
372 at: now,
373 }],
374 draft: None,
375 task: None,
376 created_at: now,
377 updated_at: now,
378 seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
379 };
380 store.put(&mut chat)?;
381
382 let mut prompt = briefing(idea, &chat.repo);
383 if let Some(source) = from {
384 prompt = format!("{}\n\n{prompt}", derived_background(source));
388 }
389 prompt.push_str(&language_note(&cfg.graph.language));
390 turn(&mut chat, store, cfg, &prompt).await?;
391 Ok(chat)
392}
393
394pub fn derived_background(from: &Chat) -> String {
403 format!(
404 "# Background: derived from another conversation\n\n\
405 This interview continues from a conversation about a *different* \
406 repository. Read it for context, but do not treat it as being about \
407 the repository named below in \"# Repository\" - that repository may \
408 have nothing to do with this one.\n\n\
409 Source repository: {}\n\n{}",
410 from.repo.display(),
411 transcript(from),
412 )
413}
414
415pub async fn say(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
427 if !chat.status.open() {
428 bail!(
429 "chat {} is {} and takes no more turns",
430 chat.short(),
431 chat.status.as_str()
432 );
433 }
434 let text = text.trim();
435 if text.is_empty() {
436 bail!("nothing to say");
437 }
438 let text = record(chat, store, text)?;
439 turn(chat, store, cfg, &text).await
440}
441
442pub fn record(chat: &mut Chat, store: &Chats, text: &str) -> Result<String> {
453 if !chat.status.open() {
454 bail!(
455 "chat {} is {} and takes no more turns",
456 chat.short(),
457 chat.status.as_str()
458 );
459 }
460 let text = text.trim();
461 if text.is_empty() {
462 bail!("nothing to say");
463 }
464 chat.turns.push(Turn {
465 who: Who::Operator,
466 body: text.to_owned(),
467 at: Timestamp::now(),
468 });
469 store.put(chat)?;
470 Ok(text.to_owned())
471}
472
473pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
479 turn(chat, store, cfg, text).await
480}
481
482async fn turn(chat: &mut Chat, store: &Chats, cfg: &Config, prompt: &str) -> Result<()> {
494 let spec = cfg
495 .agents
496 .iter()
497 .find(|a| a.id == chat.agent)
498 .with_context(|| {
499 format!(
500 "chat {} was interviewed by agent `{}`, which is no longer in \
501 the roster; restore it in magi.toml or start a new chat",
502 chat.short(),
503 chat.agent
504 )
505 })?;
506
507 let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
508 let body = if resuming {
509 prompt.to_owned()
510 } else {
511 format!("{}\n\n{prompt}", transcript(chat))
512 };
513
514 let artifacts = store.artifacts_of(&chat.id);
515 let stem = format!("turn-{}", chat.seat.turns + 1);
516 let cache_dir = cfg.cache_dir();
517 let inv = Invocation {
518 cwd: &chat.repo,
519 prompt: &body,
520 timeout: TURN_TIMEOUT,
521 allow_write: false,
526 sessions: cfg.graph.sessions,
527 artifacts: &artifacts,
528 stem: &stem,
529 run: &chat.id,
530 node: "chat",
531 cache_dir: cache_dir.as_deref(),
532 };
533
534 let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
535 let note = |why: String| Turn {
536 who: Who::Agent,
537 body: format!("{MAGI_NOTE}{why}"),
538 at: Timestamp::now(),
539 };
540 let (reply, failure) = match outcome {
541 Err(e) => (
542 note(format!("could not run agent `{}`: {e}", chat.agent)),
543 Some(format!("could not run agent `{}`: {e}", chat.agent)),
544 ),
545 Ok(out) if out.quota_exhausted() => {
546 let reset = out
547 .quota
548 .as_ref()
549 .and_then(|q| q.reset.clone())
550 .map_or_else(String::new, |r| format!(" (resets {r})"));
551 let why = format!(
552 "agent `{}` is out of quota{reset}; your message is saved, so \
553 say it again when the window reopens",
554 chat.agent
555 );
556 (note(why.clone()), Some(why))
557 }
558 Ok(out) if out.timed_out => {
559 let why = format!(
560 "agent `{}` did not answer within {}s; your message is saved",
561 chat.agent,
562 TURN_TIMEOUT.as_secs()
563 );
564 (note(why.clone()), Some(why))
565 }
566 Ok(out) if !out.usable() => {
567 let why = format!(
568 "agent `{}` produced no answer (exit {}); your message is saved",
569 chat.agent,
570 out.exit_code
571 .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
572 );
573 (note(why.clone()), Some(why))
574 }
575 Ok(out) => (
576 Turn {
577 who: Who::Agent,
578 body: out.text.trim().to_owned(),
579 at: Timestamp::now(),
580 },
581 None,
582 ),
583 };
584
585 if let Some(draft) = extract_draft(&reply.body) {
589 chat.draft = Some(draft);
590 }
591 chat.turns.push(reply);
592 store.put(chat)?;
593
594 match failure {
595 Some(why) => bail!("{why}"),
596 None => Ok(()),
597 }
598}
599
600fn transcript(chat: &Chat) -> String {
607 let mut out = String::from(
608 "You are mid-interview. This CLI cannot resume its own conversation, \
609 so here is everything said so far; answer only the last message.\n",
610 );
611 for t in &chat.turns {
612 let who = match t.who {
613 Who::Operator => "operator",
614 Who::Agent => "you",
615 };
616 out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
617 }
618 out
619}
620
621pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
627 if let Err(problems) = draft_problems(chat) {
628 bail!(
629 "this draft is not fileable yet:\n- {}",
630 problems.join("\n- ")
631 );
632 }
633 let body = chat
634 .draft
635 .clone()
636 .expect("draft_problems accepted a chat with a draft");
637
638 let title = queue::title_from(&body, 72);
642 let mut task = Task::new(title, body, chat.repo.clone(), Source::Human);
646 task.priority = priority;
647 queue.put(&mut task)?;
648
649 chat.task = Some(task.id.clone());
650 chat.status = ChatStatus::Filed;
651 store.put(chat)?;
652 Ok(task.id)
653}
654
655pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
667 let Some(body) = chat.draft.as_deref() else {
668 return Err(vec![
669 "this chat has no draft yet: the agent has not written a task file".to_owned(),
670 ]);
671 };
672 match plan::review_draft(body) {
673 Ok(()) => Ok(()),
674 Err(problems) => {
675 if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
676 Ok(())
677 } else {
678 Err(problems)
679 }
680 }
681 }
682}
683
684pub fn briefing(idea: &str, repo: &Path) -> String {
699 format!(
700 "You are the planning leader for magi, which runs a blind \
701 multi-agent implementation competition: several agents will implement \
702 the task file you write, in isolated worktrees, unaware of each other, \
703 and judges will rank the results without knowing who wrote what.\n\n\
704 Your job is not to implement anything. It is to interview the operator \
705 until the change is pinned down, and then write one task file.\n\n\
706 The operator is on a phone. Every message you send is read on a small \
707 screen, so keep it short: no preamble, no restating what they just \
708 said.\n\n\
709 # Repository\n\n{repo}\n\n\
710 Read it before you start asking. Questions the code already answers \
711 spend the operator's patience for nothing. Do not modify it: the \
712 competing agents do the implementation, and a repository you have \
713 already edited makes their diffs unjudgeable.\n\n\
714 # The idea\n\n{idea}\n\n\
715 # How to run the interview\n\n\
716 - Ask about what you cannot determine yourself: intent, scope, which \
717 of several defensible designs the operator wants, what must not \
718 change.\n\
719 - Ask about ONE thing per message and wait for the answer. This is a \
720 phone, not a form: a message with five questions in it gets one of \
721 them answered.\n\
722 - Do not produce the task file after one exchange.\n\
723 - Disagree when you have grounds. A leader that agrees with everything \
724 adds nothing to what the operator already typed.\n\
725 - Confirm the plan in your own words and get an explicit yes before \
726 writing.\n\n\
727 # How to deliver the task file\n\n\
728 When the operator agrees the plan is right, put the whole task file in \
729 your reply inside a fenced block tagged `task`, like this:\n\n\
730 ```task\n\
731 # <the task file>\n\
732 ```\n\n\
733 Nothing else goes in that block, and there is exactly one of them per \
734 message. magi extracts it and files it; a task file written to a file \
735 on disk, or pasted without the fence, is one magi cannot see. You may \
736 send a revised version later in the same conversation - the newest \
737 `task` block wins - and while you are still asking questions, send no \
738 `task` block at all.\n\n\
739 magi will refuse a task file with no completion criteria, so those are \
740 not optional.\n\n\
741 # Task file specification\n\n{spec}",
742 repo = repo.display(),
743 spec = plan::TASK_FILE_SPEC,
744 )
745}
746
747fn language_note(language: &str) -> String {
752 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
753 String::new()
754 } else {
755 format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
756 }
757}
758
759pub fn extract_draft(reply: &str) -> Option<String> {
772 let mut last: Option<String> = None;
773 let mut open: Option<(usize, Vec<&str>)> = None;
774 for line in reply.lines() {
775 let trimmed = line.trim_start();
776 let ticks = trimmed.chars().take_while(|c| *c == '`').count();
779 match &mut open {
780 Some((width, body)) => {
781 if ticks >= *width && trimmed[ticks..].trim().is_empty() {
782 last = Some(joined(body));
783 open = None;
784 } else {
785 body.push(line);
786 }
787 }
788 None => {
789 if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
790 open = Some((ticks, Vec::new()));
791 }
792 }
793 }
794 }
795 if let Some((_, body)) = open {
796 last = Some(joined(&body));
797 }
798 last.filter(|s| !s.trim().is_empty())
799}
800
801fn joined(lines: &[&str]) -> String {
804 if lines.is_empty() {
805 return String::new();
806 }
807 let mut out = lines.join("\n");
808 out.push('\n');
809 out
810}
811
812fn read_path(path: &Path) -> Result<Chat> {
813 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
814 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
815}
816
817fn short(id: &str) -> &str {
818 id.split('-').next_back().unwrap_or(id)
819}
820
821fn new_id() -> String {
822 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
823 let seed = crate::rng::entropy();
824 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
825}
826
827#[cfg(test)]
828mod tests {
829 use std::collections::BTreeMap;
830
831 use crate::config::{AgentKind, AgentSpec, Graph};
832
833 use super::*;
834
835 fn store() -> (tempfile::TempDir, Chats) {
838 let tmp = tempfile::tempdir().expect("tempdir");
839 let chats = Chats::at(tmp.path().join("chats"));
840 (tmp, chats)
841 }
842
843 fn good_draft() -> String {
846 "# Report per-node durations in `magi show`\n\
847 \n\
848 ## Context\n\
849 \n\
850 `magi show` prints a run's nodes but not how long any of them took, so \
851 the operator cannot see which seat is expensive. The data is already \
852 in `run.events`.\n\
853 \n\
854 ## Change\n\
855 \n\
856 Add a duration column to the node table in `src/report.rs`.\n\
857 \n\
858 ## Constraints\n\
859 \n\
860 Do not change the JSON shape of a run record.\n\
861 \n\
862 ## Completion criteria\n\
863 \n\
864 - [ ] `magi show <run>` prints a duration for every completed node.\n\
865 - [ ] A node with no end event prints nothing rather than zero.\n\
866 \n\
867 ## Out of scope\n\
868 \n\
869 The TUI's detail pane.\n"
870 .to_owned()
871 }
872
873 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
878 let path = dir.join("mock-chat-agent.sh");
879 std::fs::write(&path, script).expect("write mock");
880 AgentSpec {
881 id: "mock".to_owned(),
882 kind: AgentKind::Command,
883 model: None,
884 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
885 extra_args: Vec::new(),
886 env,
887 prompt_delivery: None,
888 }
889 }
890
891 fn config(spec: AgentSpec) -> Config {
895 Config {
896 agents: vec![spec],
897 graph: Graph {
898 language: "en".to_owned(),
899 ..Graph::default()
900 },
901 ..Config::default()
902 }
903 }
904
905 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
907
908 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
910
911 const ECHO: &str = "#!/bin/sh\ncat\n";
914
915 fn env(reply: &str) -> BTreeMap<String, String> {
916 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
917 }
918
919 #[test]
920 fn the_frozen_json_field_names_round_trip_through_disk() {
921 let (tmp, chats) = store();
922 let mut chat = Chat {
923 schema: SCHEMA,
924 id: "20260903-014455-ab12".to_owned(),
925 repo: tmp.path().to_owned(),
926 from: None,
927 agent: "sonnet".to_owned(),
928 status: ChatStatus::Open,
929 turns: vec![Turn {
930 who: Who::Operator,
931 body: "rework the config loader".to_owned(),
932 at: Timestamp::now(),
933 }],
934 draft: None,
935 task: None,
936 created_at: Timestamp::now(),
937 updated_at: Timestamp::now(),
938 seat: SeatState::new(SEAT, "sonnet", 7),
939 };
940 chats.put(&mut chat).expect("put");
941
942 let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
946 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
947 for field in [
948 "schema",
949 "id",
950 "repo",
951 "from",
952 "agent",
953 "status",
954 "turns",
955 "draft",
956 "task",
957 "created_at",
958 "updated_at",
959 ] {
960 assert!(v.get(field).is_some(), "missing field `{field}`");
961 }
962 assert_eq!(v["schema"], 1);
963 assert_eq!(v["status"], "open");
964 assert_eq!(v["turns"][0]["who"], "operator");
965 assert_eq!(v["turns"][0]["body"], "rework the config loader");
966 assert!(v["turns"][0].get("at").is_some());
967 assert!(v["draft"].is_null());
968 assert!(v["task"].is_null());
969 assert!(v["from"].is_null());
970
971 let back = chats.get(&chat.id).expect("get");
972 assert_eq!(back.id, chat.id);
973 assert_eq!(back.turns, chat.turns);
974 assert_eq!(back.status, ChatStatus::Open);
975 assert_eq!(back.from, None);
976 }
977
978 #[test]
982 fn a_chat_recorded_without_a_from_field_still_reads() {
983 let (tmp, chats) = store();
984 let path = chats.path_of("20260903-014455-ab12");
985 std::fs::create_dir_all(chats.root()).expect("chats dir");
986 std::fs::write(
987 &path,
988 serde_json::json!({
989 "schema": SCHEMA,
990 "id": "20260903-014455-ab12",
991 "repo": tmp.path(),
992 "agent": "sonnet",
993 "status": "open",
994 "turns": [],
995 "draft": null,
996 "task": null,
997 "created_at": Timestamp::now().to_string(),
998 "updated_at": Timestamp::now().to_string(),
999 "seat": SeatState::new(SEAT, "sonnet", 7),
1000 })
1001 .to_string(),
1002 )
1003 .expect("write pre-`from` chat");
1004
1005 let chat = chats.get("20260903-014455-ab12").expect("must still read");
1006 assert_eq!(chat.from, None);
1007 }
1008
1009 #[test]
1010 fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1011 let chat = Chat {
1012 schema: SCHEMA,
1013 id: "20260903-014455-ab12".to_owned(),
1014 repo: PathBuf::from("/repo/other"),
1015 from: None,
1016 agent: "sonnet".to_owned(),
1017 status: ChatStatus::Open,
1018 turns: vec![
1019 Turn {
1020 who: Who::Operator,
1021 body: "rework the queue drain".to_owned(),
1022 at: Timestamp::now(),
1023 },
1024 Turn {
1025 who: Who::Agent,
1026 body: "which part of the drain?".to_owned(),
1027 at: Timestamp::now(),
1028 },
1029 ],
1030 draft: None,
1031 task: None,
1032 created_at: Timestamp::now(),
1033 updated_at: Timestamp::now(),
1034 seat: SeatState::new(SEAT, "sonnet", 7),
1035 };
1036 let background = derived_background(&chat);
1037 assert!(background.contains("/repo/other"));
1038 assert!(background.contains("rework the queue drain"));
1039 assert!(background.contains("which part of the drain?"));
1040 assert!(background.contains("different"));
1041 }
1042
1043 #[tokio::test]
1044 async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1045 let (tmp, chats) = store();
1046 let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1047 let source_cfg = config(source_spec);
1048 let source = start(
1049 &chats,
1050 &source_cfg,
1051 tmp.path().to_owned(),
1052 "rework the queue drain",
1053 None,
1054 None,
1055 )
1056 .await
1057 .expect("start source");
1058 let before = source.clone();
1059
1060 let other_repo = tmp.path().join("other-repo");
1061 std::fs::create_dir_all(&other_repo).expect("other repo dir");
1062 let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1065 let derived_cfg = config(echo_spec);
1066 let derived = start(
1067 &chats,
1068 &derived_cfg,
1069 other_repo,
1070 "same idea, different repository",
1071 None,
1072 Some(&source),
1073 )
1074 .await
1075 .expect("start derived");
1076
1077 assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1078
1079 let prompt = &derived.turns.last().expect("agent reply").body;
1080 assert!(prompt.contains("Background: derived from another conversation"));
1081 assert!(prompt.contains(&source.repo.display().to_string()));
1082 assert!(prompt.contains("rework the queue drain"));
1083 assert!(prompt.contains("same idea, different repository"));
1084
1085 let reread = chats.get(&source.id).expect("source still on disk");
1087 assert_eq!(reread.status, before.status);
1088 assert_eq!(reread.turns, before.turns);
1089 assert_eq!(reread.draft, before.draft);
1090 }
1091
1092 #[test]
1093 fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1094 let reply = "here is a sketch\n\
1095 \n\
1096 ```rust\n\
1097 fn not_the_draft() {}\n\
1098 ```\n\
1099 \n\
1100 ```task\n\
1101 # first version\n\
1102 ```\n\
1103 \n\
1104 ```json\n\
1105 {\"also\": \"not it\"}\n\
1106 ```\n\
1107 \n\
1108 revised:\n\
1109 \n\
1110 ```task\n\
1111 # second version\n\
1112 ## Completion criteria\n\
1113 ```\n";
1114 assert_eq!(
1115 extract_draft(reply).as_deref(),
1116 Some("# second version\n## Completion criteria\n")
1117 );
1118 }
1119
1120 #[test]
1121 fn extract_draft_returns_none_when_there_is_no_task_block() {
1122 assert_eq!(extract_draft("which storage backend do you want?"), None);
1123 assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1124 assert_eq!(extract_draft("```task\n```\n"), None);
1127 }
1128
1129 #[tokio::test]
1130 async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1131 let (tmp, chats) = store();
1132 let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1133 let cfg = config(spec);
1134 let mut chat = start(
1135 &chats,
1136 &cfg,
1137 tmp.path().to_owned(),
1138 "add durations",
1139 None,
1140 None,
1141 )
1142 .await
1143 .expect("start");
1144 chat.draft = Some(good_draft());
1145 chats.put(&mut chat).expect("put");
1146
1147 say(&mut chat, &chats, &cfg, "the report module")
1148 .await
1149 .expect("say");
1150
1151 assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1152 assert_eq!(
1153 chats.get(&chat.id).expect("get").draft.as_deref(),
1154 Some(good_draft().as_str())
1155 );
1156 }
1157
1158 #[test]
1159 fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1160 let brief = briefing("rework the config loader", Path::new("/repo"));
1161 assert!(brief.contains(plan::TASK_FILE_SPEC));
1164 assert!(brief.contains("```task"));
1165 assert!(brief.contains("rework the config loader"));
1166 assert!(brief.contains("/repo"));
1167 assert!(brief.contains("completion criteria"));
1168 }
1169
1170 #[test]
1171 fn file_draft_refuses_a_bad_draft_with_every_problem() {
1172 let (tmp, chats) = store();
1173 let queue = Queue::at(tmp.path().join("queue"));
1174 let mut chat = Chat {
1175 schema: SCHEMA,
1176 id: "20260903-014455-ab12".to_owned(),
1177 repo: tmp.path().to_owned(),
1178 from: None,
1179 agent: "mock".to_owned(),
1180 status: ChatStatus::Open,
1181 turns: Vec::new(),
1182 draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1185 task: None,
1186 created_at: Timestamp::now(),
1187 updated_at: Timestamp::now(),
1188 seat: SeatState::new(SEAT, "mock", 7),
1189 };
1190
1191 let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1192 assert!(
1193 problems.len() >= 2,
1194 "expected every problem, got {problems:?}"
1195 );
1196 assert!(problems.iter().any(|p| p.contains("completion criteria")));
1197 assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1198
1199 let err = file_draft(&mut chat, &chats, &queue, 0)
1200 .expect_err("file_draft must refuse it too")
1201 .to_string();
1202 for p in &problems {
1203 assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1204 }
1205 assert_eq!(chat.status, ChatStatus::Open);
1206 assert!(chat.task.is_none());
1207 assert!(queue.list().is_empty());
1208 }
1209
1210 #[test]
1211 fn file_draft_queues_a_good_draft_and_records_the_task() {
1212 let (tmp, chats) = store();
1213 let queue = Queue::at(tmp.path().join("queue"));
1214 let mut chat = Chat {
1215 schema: SCHEMA,
1216 id: "20260903-014455-cd34".to_owned(),
1217 repo: tmp.path().to_owned(),
1218 from: None,
1219 agent: "mock".to_owned(),
1220 status: ChatStatus::Open,
1221 turns: Vec::new(),
1222 draft: Some(good_draft()),
1223 task: None,
1224 created_at: Timestamp::now(),
1225 updated_at: Timestamp::now(),
1226 seat: SeatState::new(SEAT, "mock", 7),
1227 };
1228
1229 let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1230
1231 assert_eq!(chat.status, ChatStatus::Filed);
1232 assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1233 assert_eq!(
1234 chats.get(&chat.id).expect("get").task.as_deref(),
1235 Some(id.as_str()),
1236 "the task id must survive on disk, or the phone shows an unfiled chat"
1237 );
1238
1239 let task = queue.get(&id).expect("queued task");
1240 assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1241 assert_eq!(task.instruction, good_draft());
1242 assert_eq!(task.priority, 5);
1243 assert_eq!(task.source, Source::Human);
1244 }
1245
1246 #[tokio::test]
1247 async fn say_appends_the_operator_turn_then_the_agent_turn() {
1248 let (tmp, chats) = store();
1249 let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1250 let cfg = config(spec);
1251 let mut chat = start(
1252 &chats,
1253 &cfg,
1254 tmp.path().to_owned(),
1255 "add durations",
1256 None,
1257 None,
1258 )
1259 .await
1260 .expect("start");
1261 assert_eq!(chat.turns.len(), 2);
1263 assert_eq!(chat.turns[0].who, Who::Operator);
1264 assert_eq!(chat.turns[1].who, Who::Agent);
1265
1266 say(&mut chat, &chats, &cfg, "the report module")
1267 .await
1268 .expect("say");
1269
1270 assert_eq!(chat.turns.len(), 4);
1271 assert_eq!(chat.turns[2].who, Who::Operator);
1272 assert_eq!(chat.turns[2].body, "the report module");
1273 assert_eq!(chat.turns[3].who, Who::Agent);
1274 assert_eq!(chat.turns[3].body, "which module?");
1275 assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
1276 }
1277
1278 #[tokio::test]
1279 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1280 let (tmp, chats) = store();
1281 let good = mock_agent(tmp.path(), REPLY, env("which module?"));
1282 let cfg = config(good);
1283 let mut chat = start(
1284 &chats,
1285 &cfg,
1286 tmp.path().to_owned(),
1287 "add durations",
1288 None,
1289 None,
1290 )
1291 .await
1292 .expect("start");
1293
1294 mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1298 let err = say(&mut chat, &chats, &cfg, "the report module")
1299 .await
1300 .expect_err("a turn with no answer is an error");
1301 assert!(err.to_string().contains("no answer"), "{err}");
1302
1303 let on_disk = chats.get(&chat.id).expect("get");
1304 assert_eq!(on_disk.turns.len(), 4);
1305 assert_eq!(
1306 on_disk.turns[2].body, "the report module",
1307 "the operator's message must survive the failure"
1308 );
1309 let note = &on_disk.turns[3];
1310 assert_eq!(note.who, Who::Agent);
1311 assert!(
1312 note.body.starts_with(MAGI_NOTE),
1313 "the failure must be visible in the transcript: {}",
1314 note.body
1315 );
1316 assert!(note.body.contains("your message is saved"));
1317 }
1318
1319 #[test]
1320 fn list_puts_open_chats_before_filed_ones() {
1321 let (tmp, chats) = store();
1322 let make = |id: &str, status: ChatStatus| {
1323 let mut c = Chat {
1324 schema: SCHEMA,
1325 id: id.to_owned(),
1326 repo: tmp.path().to_owned(),
1327 from: None,
1328 agent: "mock".to_owned(),
1329 status,
1330 turns: Vec::new(),
1331 draft: None,
1332 task: None,
1333 created_at: Timestamp::now(),
1334 updated_at: Timestamp::now(),
1335 seat: SeatState::new(SEAT, "mock", 7),
1336 };
1337 chats.put(&mut c).expect("put");
1338 };
1339 make("20260901-000000-0001", ChatStatus::Open);
1341 make("20260902-000000-0002", ChatStatus::Open);
1342 make("20260903-000000-0003", ChatStatus::Filed);
1343
1344 let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
1345 assert_eq!(
1346 ids,
1347 [
1348 "20260902-000000-0002",
1349 "20260901-000000-0001",
1350 "20260903-000000-0003"
1351 ]
1352 );
1353 assert_eq!(chats.count_open(), 2);
1354 }
1355}