1use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
53use std::time::Duration;
54
55use anyhow::{Context, Result, bail};
56use jiff::Timestamp;
57use serde::{Deserialize, Serialize};
58
59use crate::agent::{self, Invocation, SeatState};
60use crate::config::{AgentSpec, Config};
61use crate::queue::{Queue, Source, Task};
62
63pub const SCHEMA: u32 = 1;
65
66fn turn_timeout(cfg: &Config) -> Duration {
77 Duration::from_secs(cfg.graph.timeout_talk)
78}
79
80const SEAT: &str = "talk";
83
84const 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, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Sender {
102 Operator,
104 Persona,
106 Magi,
108}
109
110pub fn sender(turn: &Turn, message: Option<&Message>) -> Sender {
112 message
113 .and_then(|m| m.sender)
114 .or(turn.sender)
115 .unwrap_or_else(|| {
116 if turn.posted_by.is_some()
117 || (turn.who == Who::Agent && turn.body.starts_with(MAGI_NOTE))
118 {
119 Sender::Magi
120 } else if turn.who == Who::Operator {
121 Sender::Operator
122 } else {
123 Sender::Persona
124 }
125 })
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct Attachment {
138 pub id: String,
140 pub name: String,
142 pub mime: String,
145 pub bytes: u64,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct ReplyTarget {
153 pub turn: usize,
155 pub message: usize,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct Reply {
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub sender: Option<Sender>,
165 pub target: ReplyTarget,
167 pub who: Who,
169 pub excerpt: String,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct Message {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub display: Option<crate::prompt::NoticeDisplay>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub sender: Option<Sender>,
182 pub body: String,
184 pub attachments: Vec<Attachment>,
186 pub reply: Option<Reply>,
188}
189
190pub(crate) fn notice_display(
192 talk: &Talk,
193 turn: &Turn,
194 message: Option<&Message>,
195) -> Option<crate::prompt::NoticeDisplay> {
196 if let Some(display) = message.and_then(|m| m.display.as_ref()) {
197 return Some(display.clone());
198 }
199 let body = message.map_or(turn.body.as_str(), |m| m.body.as_str());
200 if sender(turn, message) == Sender::Magi
201 || talk.completion_notices.iter().any(|r| r.notice == body)
202 {
203 crate::prompt::legacy_notice_display(body)
204 } else {
205 None
206 }
207}
208
209fn resolve_reply(talk: &Talk, target: Option<ReplyTarget>) -> Result<Option<Reply>> {
210 let Some(target) = target else {
211 return Ok(None);
212 };
213 let turn = talk
214 .turns
215 .get(target.turn)
216 .context("invalid reply target")?;
217 let (body, attachments) = if turn.messages.is_empty() {
218 if target.message != 0 {
219 bail!("invalid reply target");
220 }
221 (&turn.body, &turn.attachments)
222 } else {
223 let message = turn
224 .messages
225 .get(target.message)
226 .context("invalid reply target")?;
227 (&message.body, &message.attachments)
228 };
229 let display = notice_display(talk, turn, turn.messages.get(target.message));
230 let body = display.as_ref().map_or(body.as_str(), |d| d.facts.as_str());
231 let excerpt = if body.trim().is_empty() {
232 format!("{} attachment(s)", attachments.len())
233 } else {
234 body.chars().take(240).collect()
235 };
236 Ok(Some(Reply {
237 sender: Some(if display.is_some() {
238 Sender::Magi
239 } else {
240 sender(turn, turn.messages.get(target.message))
241 }),
242 target,
243 who: turn.who,
244 excerpt,
245 }))
246}
247
248fn message_prompt(turn: &Turn, talk: &Talk, store: &Talks) -> String {
250 if turn.messages.is_empty() {
251 return turn.body.clone();
252 }
253 turn.messages
254 .iter()
255 .map(|message| match &message.reply {
256 Some(reply) => {
257 let mut quote = format!(
258 "[Reply to {}: {}]",
259 if reply.sender == Some(Sender::Magi) {
260 "MAGI".to_owned()
261 } else {
262 format!("{:?}", reply.who)
263 },
264 reply.excerpt.lines().collect::<Vec<_>>().join(" "),
265 );
266 if let Some(target) = talk.turns.get(reply.target.turn) {
267 let attachments: &[Attachment] = if target.messages.is_empty() {
268 &target.attachments
269 } else if let Some(message) = target.messages.get(reply.target.message) {
270 &message.attachments
271 } else {
272 &[]
273 };
274 for attachment in attachments {
275 if let Some(path) = store.attachment_path(&talk.id, attachment) {
276 quote.push_str(&format!(
277 "\nReplied-to image: {} ({})",
278 path.display(),
279 attachment.mime
280 ));
281 }
282 }
283 }
284 format!("{quote}\n{}", message.body)
285 }
286 None => message.body.clone(),
287 })
288 .collect::<Vec<_>>()
289 .join("\n\n")
290}
291
292fn messages_need_no_metadata(messages: &[Message]) -> bool {
294 messages.iter().all(|message| {
295 message.sender.is_none()
296 && message.display.is_none()
297 && message.reply.is_none()
298 && message.attachments.is_empty()
299 })
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct Turn {
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub sender: Option<Sender>,
309 pub who: Who,
311 pub body: String,
313 pub at: Timestamp,
315 #[serde(default)]
318 pub attachments: Vec<Attachment>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub usage: Option<TurnUsage>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub breaks: Option<Vec<usize>>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub messages: Vec<Message>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub posted_by: Option<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct TurnUsage {
353 pub context_tokens: u64,
355 pub agent: String,
357 #[serde(default)]
359 pub model: Option<String>,
360}
361
362#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
367pub struct ContextUsage {
368 pub tokens: Option<u64>,
370 pub window: Option<u64>,
372 pub percent: Option<u64>,
374 pub warn: bool,
376 pub since_switch: bool,
380 pub model: Option<String>,
382 #[serde(default)]
385 pub estimated: bool,
386}
387
388const CONTEXT_WARN_PERCENT: u64 = 80;
390
391const STANDING_PROMPT_FALLBACK_CHARS: u64 = 7000;
394
395pub fn estimate_context_tokens(talk: &Talk, standing_chars: u64) -> Option<u64> {
408 let mut counted = false;
409 let mut chars = standing_chars;
410 for t in talk.turns.iter().filter(|t| !t.body.starts_with(MAGI_NOTE)) {
411 counted = true;
412 chars += t.body.chars().count() as u64;
413 }
414 counted.then(|| (chars * 2).div_ceil(7))
416}
417
418pub fn context_usage(talk: &Talk, cfg: Option<&Config>) -> ContextUsage {
431 let current = cfg.and_then(|c| c.agents.iter().find(|a| a.id == talk.agent));
432 let model = current.and_then(|a| a.model.clone());
433 let window = cfg
434 .zip(model.as_deref())
435 .and_then(|(c, m)| c.context_window(m))
436 .filter(|w| *w > 0);
437 let usage = talk
438 .turns
439 .iter()
440 .rev()
441 .find(|t| t.who == Who::Agent && !t.body.starts_with(MAGI_NOTE))
442 .and_then(|t| t.usage.as_ref());
443 let measured = usage.map(|u| u.context_tokens);
444 let tokens = measured.or_else(|| {
445 let standing = cfg.map_or(STANDING_PROMPT_FALLBACK_CHARS, |c| {
446 briefing_for(
447 &talk.repo,
448 &c.graph.language,
449 c.talk.allow_write,
450 crate::persona::active(&c.talk.personas, &talk.persona).as_ref(),
451 c.talk.operator_name(),
452 talk.implementers,
453 )
454 .chars()
455 .count() as u64
456 });
457 estimate_context_tokens(talk, standing)
458 });
459 let estimated = measured.is_none() && tokens.is_some();
460 let since_switch =
461 usage.is_some_and(|u| u.agent != talk.agent || (current.is_some() && u.model != model));
462 let (percent, warn) = match (tokens, window) {
463 (Some(t), Some(w)) => (
464 Some(t.saturating_mul(100) / w),
465 t.saturating_mul(100) >= w.saturating_mul(CONTEXT_WARN_PERCENT),
466 ),
467 _ => (None, false),
468 };
469 ContextUsage {
470 tokens,
471 window,
472 percent,
473 warn,
474 since_switch,
475 model,
476 estimated,
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "lowercase")]
485pub enum TalkStatus {
486 Open,
489 Closed,
491}
492
493impl TalkStatus {
494 pub fn open(self) -> bool {
496 matches!(self, Self::Open)
497 }
498
499 pub fn as_str(self) -> &'static str {
501 match self {
502 Self::Open => "open",
503 Self::Closed => "closed",
504 }
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510#[serde(deny_unknown_fields)]
511pub struct Talk {
512 pub schema: u32,
514 pub id: String,
516 pub repo: PathBuf,
518 pub agent: String,
520 pub status: TalkStatus,
522 pub turns: Vec<Turn>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub posts_read_through: Option<usize>,
529 #[serde(default)]
532 pub pending: String,
533 #[serde(default)]
535 pub pending_attachments: Vec<Attachment>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub pending_breaks: Option<Vec<usize>>,
539 #[serde(default, skip_serializing_if = "Vec::is_empty")]
540 pub pending_messages: Vec<Message>,
542 #[serde(default)]
547 pub fallback: bool,
548 #[serde(default)]
551 pub persona: String,
552 #[serde(default)]
556 pub persona_dirty: bool,
557 #[serde(default = "solo_implementers")]
560 pub implementers: u8,
561 #[serde(default)]
564 pub implementers_dirty: bool,
565 #[serde(default)]
570 pub notified: Vec<String>,
571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
573 pub completion_notices: Vec<crate::chat_notify::CompletionReference>,
574 pub created_at: Timestamp,
576 pub updated_at: Timestamp,
578 seat: SeatState,
583}
584
585impl Talk {
586 pub fn short(&self) -> &str {
588 short(&self.id)
589 }
590}
591
592#[derive(Debug, Clone)]
594pub struct Talks {
595 root: PathBuf,
596 lock: Arc<Mutex<()>>,
605}
606
607impl Talks {
608 pub fn open() -> Self {
610 Self::at(crate::run::home().join("talks"))
611 }
612
613 pub fn at(root: PathBuf) -> Self {
616 Self {
617 root,
618 lock: Arc::new(Mutex::new(())),
619 }
620 }
621
622 fn guard(&self) -> Result<StoreGuard<'_>> {
637 let mutex = self.lock.lock().unwrap_or_else(PoisonError::into_inner);
638 std::fs::create_dir_all(&self.root)
639 .with_context(|| format!("create {}", self.root.display()))?;
640 let path = self.root.join(".store.turn");
641 let deadline = std::time::Instant::now() + TAKEOVER_LOCK_TTL * 2;
642 loop {
643 if let Some(file) = TurnLock::take(&path)? {
644 return Ok(StoreGuard {
645 _file: file,
646 _mutex: mutex,
647 });
648 }
649 if std::time::Instant::now() >= deadline {
650 bail!("the talk store is locked by another process");
651 }
652 std::thread::sleep(Duration::from_millis(10));
653 }
654 }
655
656 pub fn root(&self) -> &Path {
658 &self.root
659 }
660
661 pub fn path_of(&self, id: &str) -> PathBuf {
663 self.root.join(format!("{id}.json"))
664 }
665
666 pub fn artifacts_of(&self, id: &str) -> PathBuf {
669 self.root.join(format!("{id}.artifacts"))
670 }
671
672 pub fn attachments_dir(&self, id: &str) -> PathBuf {
676 self.artifacts_of(id).join("attachments")
677 }
678
679 pub fn put_attachment(
688 &self,
689 id: &str,
690 mime: &str,
691 name: &str,
692 data: &[u8],
693 ) -> Result<Attachment> {
694 let dir = self.attachments_dir(id);
695 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
696 let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
697 let att = Attachment {
698 id: new_attachment_id(),
699 name: name.to_owned(),
700 mime: mime.to_owned(),
701 bytes: data.len() as u64,
702 };
703 std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
704 .with_context(|| format!("write attachment {}", att.id))?;
705 std::fs::write(
706 dir.join(format!("{}.json", att.id)),
707 serde_json::to_string(&att).context("serialize attachment")?,
708 )
709 .with_context(|| format!("write attachment metadata {}", att.id))?;
710 Ok(att)
711 }
712
713 pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
722 if !valid_attachment_id(att_id) {
723 return Ok(None);
724 }
725 let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
726 if !meta_path.is_file() {
727 return Ok(None);
728 }
729 let att = serde_json::from_str(
730 &std::fs::read_to_string(&meta_path)
731 .with_context(|| format!("read {}", meta_path.display()))?,
732 )
733 .with_context(|| format!("parse {}", meta_path.display()))?;
734 Ok(Some(att))
735 }
736
737 pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
741 let Some(att) = self.attachment_meta(id, att_id)? else {
742 return Ok(None);
743 };
744 let ext = attachment_ext(&att.mime).with_context(|| {
745 format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
746 })?;
747 let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
748 let data =
749 std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
750 Ok(Some((att, data)))
751 }
752
753 fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
770 let ext = attachment_ext(&att.mime)?;
771 let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
772 std::path::absolute(&path).ok()
773 }
774
775 pub fn put(&self, t: &mut Talk) -> Result<()> {
784 std::fs::create_dir_all(&self.root)
785 .with_context(|| format!("create {}", self.root.display()))?;
786 t.updated_at = Timestamp::now();
787 let mut persisted = t.clone();
790 for turn in &mut persisted.turns {
791 if turn.attachments.is_empty() && messages_need_no_metadata(&turn.messages) {
792 turn.messages.clear();
793 }
794 }
795 if persisted.pending_attachments.is_empty()
796 && messages_need_no_metadata(&persisted.pending_messages)
797 {
798 persisted.pending_messages.clear();
799 }
800 let body = serde_json::to_string_pretty(&persisted).context("serialize talk")?;
801 let path = self.path_of(&t.id);
802 let tmp = path.with_extension("json.tmp");
803 write_atomic(&tmp, &path, &body)
804 }
805
806 pub fn get(&self, id: &str) -> Result<Talk> {
808 let resolved = self.resolve_id(id)?;
809 read_path(&self.path_of(&resolved))
810 }
811
812 pub fn list(&self) -> Vec<Talk> {
815 self.list_counting_unreadable().0
816 }
817
818 pub fn list_counting_unreadable(&self) -> (Vec<Talk>, usize) {
821 let mut unreadable = 0;
822 let mut all: Vec<Talk> = std::fs::read_dir(&self.root)
823 .into_iter()
824 .flatten()
825 .flatten()
826 .map(|e| e.path())
827 .filter(|p| p.extension().is_some_and(|x| x == "json"))
828 .filter_map(|p| {
829 let talk = read_path(&p).ok();
830 if talk.is_none() {
831 unreadable += 1;
832 }
833 talk
834 })
835 .collect();
836 all.sort_unstable_by(|a, b| {
837 let rank = |t: &Talk| u8::from(!t.status.open());
838 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
839 });
840 (all, unreadable)
841 }
842
843 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
845 if self.path_of(prefix).is_file() {
846 return Ok(prefix.to_owned());
847 }
848 let hits: Vec<String> = self
849 .list()
850 .into_iter()
851 .map(|t| t.id)
852 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
853 .collect();
854 match hits.len() {
855 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
856 0 => bail!("no talk matches `{prefix}`"),
857 _ => bail!(
858 "`{prefix}` matches {} talks: {}",
859 hits.len(),
860 hits.join(", ")
861 ),
862 }
863 }
864
865 pub fn revision(&self) -> u64 {
868 std::fs::read_dir(&self.root)
869 .into_iter()
870 .flatten()
871 .flatten()
872 .filter(|e| e.path().extension().is_none_or(|x| x != "turn"))
873 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
874 .filter_map(|e| e.metadata().ok())
875 .filter_map(|m| m.modified().ok())
876 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
877 .map(|d| d.as_millis() as u64)
878 .max()
879 .unwrap_or(0)
880 }
881
882 pub fn count_open(&self) -> usize {
884 self.list().iter().filter(|t| t.status.open()).count()
885 }
886
887 pub fn turn_path(&self, id: &str) -> PathBuf {
890 self.root.join(format!("{id}.turn"))
891 }
892
893 pub fn claim_turn(&self, id: &str) -> Result<Option<TurnLease>> {
902 self.claim_turn_at(id, Timestamp::now())
903 }
904
905 fn claim_turn_at(&self, id: &str, now: Timestamp) -> Result<Option<TurnLease>> {
906 std::fs::create_dir_all(&self.root)
907 .with_context(|| format!("create {}", self.root.display()))?;
908 let path = self.turn_path(id);
909 let token = fresh_token();
910 if create_turn(&path, &token, now)? {
911 return Ok(Some(TurnLease {
912 talk: id.to_owned(),
913 path,
914 token,
915 }));
916 }
917 if lease_blocks(&path, now) {
918 return Ok(None);
919 }
920 let Some(_lock) = TurnLock::take(&path)? else {
925 return Ok(None);
926 };
927 if lease_blocks(&path, now) {
928 return Ok(None);
929 }
930 let _ = std::fs::remove_file(&path);
931 Ok(create_turn(&path, &token, now)?.then_some(TurnLease {
932 talk: id.to_owned(),
933 path,
934 token,
935 }))
936 }
937
938 pub fn turn_held(&self, id: &str) -> bool {
940 read_turn(&self.turn_path(id)).is_some_and(|r| r.fresh(Timestamp::now()))
941 }
942
943 pub fn remove(&self, id: &str) -> Result<()> {
956 let _guard = self.guard()?;
957 let resolved = self.resolve_id(id)?;
958 let path = self.path_of(&resolved);
959 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
960 let artifacts = self.artifacts_of(&resolved);
961 if artifacts.is_dir() {
962 std::fs::remove_dir_all(&artifacts)
963 .with_context(|| format!("remove {}", artifacts.display()))?;
964 }
965 let _ = std::fs::remove_file(self.turn_path(&resolved));
966 Ok(())
967 }
968}
969
970struct StoreGuard<'a> {
973 _file: TurnLock,
974 _mutex: MutexGuard<'a, ()>,
975}
976
977#[derive(Debug, Serialize, Deserialize)]
980struct TurnRecord {
981 token: String,
982 pid: u32,
983 beat_at: Timestamp,
984}
985
986impl TurnRecord {
987 fn fresh(&self, now: Timestamp) -> bool {
988 now.as_second() - self.beat_at.as_second() <= crate::ask::LEASE_TTL.as_secs() as i64
989 }
990}
991
992fn fresh_token() -> String {
996 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
997 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
998 let seed = crate::rng::entropy() ^ n.wrapping_mul(0x9E37_79B9_7F4A_7C15);
999 crate::rng::SplitMix64::new(seed).uuid_v4()
1000}
1001
1002fn read_turn(path: &Path) -> Option<TurnRecord> {
1003 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
1004}
1005
1006const TAKEOVER_LOCK_TTL: Duration = Duration::from_secs(10);
1008
1009const TICKET_TTL: Duration = Duration::from_secs(10);
1012
1013const TICKET_GENERATIONS: u32 = 16;
1015
1016const TICKET_SWEEP_AGE: Duration = Duration::from_secs(3600);
1018
1019fn create_exclusive(path: &Path, body: &str) -> Result<bool> {
1021 use std::io::Write as _;
1022 match std::fs::OpenOptions::new()
1023 .write(true)
1024 .create_new(true)
1025 .open(path)
1026 {
1027 Ok(mut f) => {
1028 if let Err(e) = f.write_all(body.as_bytes()) {
1029 drop(f);
1030 let _ = std::fs::remove_file(path);
1031 return Err(e).with_context(|| format!("write {}", path.display()));
1032 }
1033 Ok(true)
1034 }
1035 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1036 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1037 }
1038}
1039
1040fn create_turn(path: &Path, token: &str, now: Timestamp) -> Result<bool> {
1041 let record = TurnRecord {
1042 token: token.to_owned(),
1043 pid: std::process::id(),
1044 beat_at: now,
1045 };
1046 let body = serde_json::to_string(&record).context("serialize turn lease")?;
1047 let tmp = path.with_extension(format!("turn.{token}.new"));
1050 publish_exclusive(path, &tmp, &body)
1051}
1052
1053fn lease_blocks(path: &Path, now: Timestamp) -> bool {
1057 if read_turn(path).is_some_and(|r| r.fresh(now)) {
1058 return true;
1059 }
1060 std::fs::metadata(path).is_ok_and(|m| {
1061 m.len() == 0
1062 && m.modified()
1063 .ok()
1064 .and_then(|t| t.elapsed().ok())
1065 .is_some_and(|age| age <= TAKEOVER_LOCK_TTL)
1066 })
1067}
1068
1069fn link_unsupported(e: &std::io::Error) -> bool {
1072 e.kind() == std::io::ErrorKind::Unsupported || (cfg!(windows) && e.raw_os_error() == Some(1))
1073}
1074
1075fn create_in_place(path: &Path, body: &str) -> Result<bool> {
1093 use std::io::Write as _;
1094 let mut f = match std::fs::OpenOptions::new()
1095 .write(true)
1096 .create_new(true)
1097 .open(path)
1098 {
1099 Ok(f) => f,
1100 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
1101 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1102 };
1103 f.write_all(body.as_bytes())
1104 .with_context(|| format!("write {}", path.display()))?;
1105 Ok(std::fs::read_to_string(path).is_ok_and(|t| t == body))
1108}
1109
1110fn publish_exclusive(path: &Path, tmp: &Path, body: &str) -> Result<bool> {
1111 std::fs::write(tmp, body).with_context(|| format!("write {}", tmp.display()))?;
1112 #[cfg(test)]
1113 let linked = if failpoint::no_link_forced() {
1114 Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
1115 } else {
1116 std::fs::hard_link(tmp, path)
1117 };
1118 #[cfg(not(test))]
1119 let linked = std::fs::hard_link(tmp, path);
1120 let out = match linked {
1121 Ok(()) => Ok(true),
1122 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1123 Err(e) if link_unsupported(&e) => create_in_place(path, body),
1124 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1125 };
1126 let _ = std::fs::remove_file(tmp);
1127 out
1128}
1129
1130fn remove_if_carries(path: &Path, expected: &str) -> bool {
1141 let gone = path.with_extension(format!("gone.{}", fresh_token()));
1142 if std::fs::rename(path, &gone).is_err() {
1143 return false;
1144 }
1145 let found = std::fs::read_to_string(&gone);
1146 if found.as_ref().is_ok_and(|t| t == expected) {
1147 let _ = std::fs::remove_file(&gone);
1148 return true;
1149 }
1150 if let Ok(body) = found {
1151 let back = path.with_extension(format!("back.{}", fresh_token()));
1152 match publish_exclusive(path, &back, &body) {
1153 Ok(true) => {}
1154 Ok(false) | Err(_) => tracing::warn!(
1155 "{} was replaced while a stale removal had it aside; \
1156 the displaced file is dropped",
1157 path.display()
1158 ),
1159 }
1160 }
1161 let _ = std::fs::remove_file(&gone);
1162 false
1163}
1164
1165struct TurnLock {
1169 path: PathBuf,
1170 token: String,
1171}
1172
1173impl TurnLock {
1174 fn token() -> String {
1175 fresh_token()
1176 }
1177
1178 fn publish(path: &Path, token: &str) -> Result<bool> {
1182 let tmp = path.with_extension(format!("lock.{token}.new"));
1183 publish_exclusive(path, &tmp, token)
1184 }
1185
1186 fn take(lease: &Path) -> Result<Option<Self>> {
1187 let path = lease.with_extension("turn.lock");
1188 let token = Self::token();
1189 if Self::publish(&path, &token)? {
1190 return Ok(Some(Self { path, token }));
1191 }
1192 let Ok(seen) = std::fs::read_to_string(&path) else {
1193 return Ok(None);
1194 };
1195 let key = if !seen.is_empty() && seen.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1198 {
1199 seen.as_str()
1200 } else {
1201 "invalid"
1202 };
1203 let aged = std::fs::metadata(&path)
1204 .and_then(|m| m.modified())
1205 .ok()
1206 .and_then(|t| t.elapsed().ok())
1207 .is_some_and(|age| age > TAKEOVER_LOCK_TTL);
1208 if !aged {
1209 return Ok(None);
1210 }
1211 let mut won = false;
1223 for n in 0..TICKET_GENERATIONS {
1224 let ticket = path.with_extension(format!("lock.{key}.break.{n}"));
1225 if create_exclusive(&ticket, "")? {
1226 won = true;
1227 break;
1228 }
1229 let stale = std::fs::metadata(&ticket)
1230 .and_then(|m| m.modified())
1231 .ok()
1232 .and_then(|t| t.elapsed().ok())
1233 .is_some_and(|age| age > TICKET_TTL);
1234 if !stale {
1235 return Ok(None);
1236 }
1237 }
1238 if !won {
1239 Self::sweep_tickets(&path);
1244 return Ok(None);
1245 }
1246 Self::sweep_tickets(&path);
1247 if !remove_if_carries(&path, &seen) {
1253 return Ok(None);
1254 }
1255 if Self::publish(&path, &token)? {
1256 return Ok(Some(Self { path, token }));
1257 }
1258 Ok(None)
1259 }
1260
1261 fn sweep_tickets(path: &Path) {
1264 let (Some(dir), Some(name)) = (path.parent(), path.file_name().and_then(|n| n.to_str()))
1265 else {
1266 return;
1267 };
1268 let prefix = format!("{name}.");
1269 let Ok(entries) = std::fs::read_dir(dir) else {
1270 return;
1271 };
1272 for entry in entries.flatten() {
1273 let file = entry.file_name();
1274 let Some(file) = file.to_str() else { continue };
1275 if !(file.starts_with(&prefix) && file.contains(".break.")) {
1276 continue;
1277 }
1278 let old = entry
1279 .metadata()
1280 .and_then(|m| m.modified())
1281 .ok()
1282 .and_then(|t| t.elapsed().ok())
1283 .is_some_and(|age| age > TICKET_SWEEP_AGE);
1284 if old {
1285 let _ = std::fs::remove_file(entry.path());
1286 }
1287 }
1288 }
1289
1290 fn take_patiently(lease: &Path) -> Option<Self> {
1292 for _ in 0..50 {
1293 match Self::take(lease) {
1294 Ok(Some(lock)) => return Some(lock),
1295 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
1296 Err(_) => return None,
1297 }
1298 }
1299 None
1300 }
1301}
1302
1303impl Drop for TurnLock {
1304 fn drop(&mut self) {
1305 remove_if_carries(&self.path, &self.token);
1307 }
1308}
1309
1310pub const TURN_BEAT: Duration = Duration::from_secs(20);
1313
1314#[derive(Debug)]
1319pub struct TurnLease {
1320 talk: String,
1321 path: PathBuf,
1322 token: String,
1323}
1324
1325impl TurnLease {
1326 pub fn holds(&self) -> bool {
1328 read_turn(&self.path).is_some_and(|r| r.token == self.token)
1329 }
1330
1331 pub fn beat(&self) -> Result<bool> {
1335 let _lock = TurnLock::take_patiently(&self.path)
1336 .with_context(|| format!("lock {} to renew it", self.path.display()))?;
1337 let Some(mut record) = read_turn(&self.path).filter(|r| r.token == self.token) else {
1338 return Ok(false);
1339 };
1340 record.beat_at = Timestamp::now();
1341 let body = serde_json::to_string(&record).context("serialize turn lease")?;
1342 let tmp = self.path.with_extension(format!("turn.{}.tmp", self.token));
1343 write_atomic(&tmp, &self.path, &body)?;
1344 Ok(true)
1345 }
1346
1347 pub async fn beating<T>(&self, fut: impl std::future::Future<Output = T>) -> Result<T> {
1352 self.beating_every(TURN_BEAT, fut).await
1353 }
1354
1355 async fn beating_every<T>(
1356 &self,
1357 period: Duration,
1358 fut: impl std::future::Future<Output = T>,
1359 ) -> Result<T> {
1360 tokio::pin!(fut);
1361 loop {
1362 match tokio::time::timeout(period, &mut fut).await {
1363 Ok(out) => return Ok(out),
1364 Err(_) => match self.beat() {
1365 Ok(true) => {}
1366 Ok(false) => bail!(
1367 "the turn lease {} was taken over; this turn is stopped",
1368 self.path.display()
1369 ),
1370 Err(e) => tracing::warn!("{e:#}"),
1371 },
1372 }
1373 }
1374 }
1375}
1376
1377impl Drop for TurnLease {
1378 fn drop(&mut self) {
1379 if let Some(_lock) = TurnLock::take_patiently(&self.path) {
1382 if read_turn(&self.path).is_some_and(|r| r.token == self.token) {
1383 let _ = std::fs::remove_file(&self.path);
1384 }
1385 }
1386 }
1387}
1388
1389pub fn begin(store: &Talks, cfg: &Config, repo: PathBuf, agent: Option<&str>) -> Result<Talk> {
1399 begin_with(store, cfg, repo, agent, &Preferred::default())
1400}
1401
1402#[derive(Debug, Default, Clone)]
1406pub struct Preferred {
1407 pub agent: Option<String>,
1409 pub persona: Option<String>,
1411 pub implementers: Option<i64>,
1413}
1414
1415pub fn begin_with(
1422 store: &Talks,
1423 cfg: &Config,
1424 repo: PathBuf,
1425 agent: Option<&str>,
1426 preferred: &Preferred,
1427) -> Result<Talk> {
1428 let repo = repo.canonicalize().unwrap_or(repo);
1431 let head = || -> Result<AgentSpec> {
1434 Ok(agent::pick_chain(
1435 &cfg.agents,
1436 cfg.roles.chatter.as_ref(),
1437 &agent::installed,
1438 "chatter",
1439 )?
1440 .remove(0))
1441 };
1442 let mut fallback = agent.is_none();
1443 let spec = match agent {
1444 Some(id) => agent::pick(&cfg.agents, Some(id), &agent::installed)?,
1445 None => {
1446 match preferred
1447 .agent
1448 .as_deref()
1449 .map(str::trim)
1450 .filter(|id| !id.is_empty())
1451 {
1452 Some(id) => match agent::pick(&cfg.agents, Some(id), &agent::installed) {
1453 Ok(spec) => {
1454 fallback = head().is_ok_and(|head| spec.id == head.id);
1455 spec
1456 }
1457 Err(_) => head()?,
1458 },
1459 None => head()?,
1460 }
1461 }
1462 };
1463 let persona = preferred
1464 .persona
1465 .as_deref()
1466 .and_then(|id| crate::persona::find(&cfg.talk.personas, id))
1467 .filter(|p| !p.is_default())
1468 .map(|p| p.id)
1469 .unwrap_or_default();
1470 let implementers = preferred
1471 .implementers
1472 .and_then(|n| u8::try_from(n).ok())
1473 .and_then(|n| check_implementers(n, cfg).ok())
1474 .unwrap_or(1);
1475
1476 let now = Timestamp::now();
1477 let mut talk = Talk {
1478 posts_read_through: Some(0),
1479 pending_breaks: None,
1480 pending_messages: Vec::new(),
1481 schema: SCHEMA,
1482 id: new_id(),
1483 repo,
1484 agent: spec.id.clone(),
1485 status: TalkStatus::Open,
1486 turns: Vec::new(),
1487 pending: String::new(),
1488 pending_attachments: Vec::new(),
1489 fallback,
1490 persona,
1491 persona_dirty: false,
1492 implementers,
1493 implementers_dirty: false,
1494 notified: Vec::new(),
1495 completion_notices: Vec::new(),
1496 created_at: now,
1497 updated_at: now,
1498 seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
1499 };
1500 store.put(&mut talk)?;
1501 Ok(talk)
1502}
1503
1504pub fn record(
1511 talk: &mut Talk,
1512 store: &Talks,
1513 text: &str,
1514 attachments: Vec<Attachment>,
1515) -> Result<String> {
1516 record_reply(talk, store, text, attachments, None)
1517}
1518
1519pub fn record_reply(
1521 talk: &mut Talk,
1522 store: &Talks,
1523 text: &str,
1524 attachments: Vec<Attachment>,
1525 target: Option<ReplyTarget>,
1526) -> Result<String> {
1527 let _guard = store.guard()?;
1535 let Ok(fresh) = store.get(&talk.id) else {
1540 bail!("talk {} was deleted", talk.short());
1541 };
1542 let reply = resolve_reply(&fresh, target)?;
1543 talk.turns = fresh.turns;
1544 talk.status = fresh.status;
1545 talk.pending = fresh.pending;
1548 talk.pending_breaks = fresh.pending_breaks;
1549 talk.pending_messages = fresh.pending_messages;
1550 talk.pending_attachments = fresh.pending_attachments;
1551 talk.notified = fresh.notified;
1552 if !talk.status.open() {
1553 bail!(
1554 "talk {} is {} and takes no more turns",
1555 talk.short(),
1556 talk.status.as_str()
1557 );
1558 }
1559 if text.trim().is_empty() && attachments.is_empty() {
1561 bail!("nothing to say");
1562 }
1563 talk.turns.push(Turn {
1564 sender: Some(Sender::Operator),
1565 who: Who::Operator,
1566 body: text.to_owned(),
1567 at: Timestamp::now(),
1568 messages: vec![Message {
1569 display: None,
1570 sender: Some(Sender::Operator),
1571 body: text.to_owned(),
1572 attachments: attachments.clone(),
1573 reply,
1574 }],
1575 attachments,
1576 usage: None,
1577 breaks: Some(Vec::new()),
1578 posted_by: None,
1579 });
1580 store.put(talk)?;
1581 Ok(text.to_owned())
1582}
1583
1584pub fn queue(
1586 talk: &mut Talk,
1587 store: &Talks,
1588 text: &str,
1589 attachments: Vec<Attachment>,
1590) -> Result<()> {
1591 queue_reply(talk, store, text, attachments, None)
1592}
1593
1594pub fn queue_reply(
1596 talk: &mut Talk,
1597 store: &Talks,
1598 text: &str,
1599 attachments: Vec<Attachment>,
1600 target: Option<ReplyTarget>,
1601) -> Result<()> {
1602 queue_keyed(
1603 talk,
1604 store,
1605 text,
1606 attachments,
1607 None,
1608 &|_| false,
1609 (target, Sender::Operator, None),
1610 )
1611 .map(|_| ())
1612}
1613
1614pub fn queue_once(talk: &mut Talk, store: &Talks, text: &str, key: &str) -> Result<bool> {
1619 queue_once_matching(talk, store, text, key, &|_| false, None)
1620}
1621
1622pub(crate) fn queue_once_matching(
1625 talk: &mut Talk,
1626 store: &Talks,
1627 text: &str,
1628 key: &str,
1629 previous: &dyn Fn(&str) -> bool,
1630 reference: Option<&crate::chat_notify::CompletionReference>,
1631) -> Result<bool> {
1632 queue_keyed(
1633 talk,
1634 store,
1635 text,
1636 Vec::new(),
1637 Some((key, reference)),
1638 previous,
1639 (None, Sender::Magi, None),
1640 )
1641}
1642
1643pub(crate) fn queue_notice(
1645 talk: &mut Talk,
1646 store: &Talks,
1647 text: &str,
1648 key: &str,
1649 previous: &dyn Fn(&str) -> bool,
1650 reference: Option<&crate::chat_notify::CompletionReference>,
1651 display: crate::prompt::NoticeDisplay,
1652) -> Result<bool> {
1653 queue_keyed(
1654 talk,
1655 store,
1656 text,
1657 Vec::new(),
1658 Some((key, reference)),
1659 previous,
1660 (None, Sender::Magi, Some(display)),
1661 )
1662}
1663
1664pub fn queue_magi(talk: &mut Talk, store: &Talks, text: &str) -> Result<()> {
1666 queue_keyed(
1667 talk,
1668 store,
1669 text,
1670 Vec::new(),
1671 None,
1672 &|_| false,
1673 (None, Sender::Magi, None),
1674 )
1675 .map(|_| ())
1676}
1677
1678fn queue_keyed(
1679 talk: &mut Talk,
1680 store: &Talks,
1681 text: &str,
1682 attachments: Vec<Attachment>,
1683 key: Option<(&str, Option<&crate::chat_notify::CompletionReference>)>,
1684 previous: &dyn Fn(&str) -> bool,
1685 source: (
1686 Option<ReplyTarget>,
1687 Sender,
1688 Option<crate::prompt::NoticeDisplay>,
1689 ),
1690) -> Result<bool> {
1691 if text.trim().is_empty() && attachments.is_empty() {
1693 bail!("nothing to say");
1694 }
1695 let _guard = store.guard()?;
1696 let mut fresh = store
1697 .get(&talk.id)
1698 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1699 if !fresh.status.open() {
1700 bail!(
1701 "talk {} is {} and takes no more turns",
1702 fresh.short(),
1703 fresh.status.as_str()
1704 );
1705 }
1706 if let Some((key, reference)) = key {
1707 if fresh.notified.iter().any(|k| k == key || previous(k)) {
1708 *talk = fresh;
1709 return Ok(false);
1710 }
1711 fresh.notified.push(key.to_owned());
1712 if let Some(reference) = reference {
1713 fresh.completion_notices.push(reference.clone());
1714 }
1715 }
1716 let (target, provenance, display) = source;
1717 let reply = resolve_reply(&fresh, target)?;
1718 if fresh.pending_messages.is_empty()
1719 && (!fresh.pending.is_empty() || !fresh.pending_attachments.is_empty())
1720 {
1721 fresh.pending_messages.push(Message {
1722 display: None,
1723 sender: None,
1724 body: fresh.pending.clone(),
1725 attachments: fresh.pending_attachments.clone(),
1726 reply: None,
1727 });
1728 }
1729 fresh.pending_messages.push(Message {
1730 display,
1731 sender: Some(provenance),
1732 body: text.to_owned(),
1733 attachments: attachments.clone(),
1734 reply,
1735 });
1736 if !text.is_empty() {
1737 if fresh.pending.is_empty() {
1738 fresh.pending = text.to_owned();
1739 fresh.pending_breaks = Some(Vec::new());
1740 } else {
1741 fresh.pending.push_str("\n\n");
1742 if let Some(b) = fresh.pending_breaks.as_mut() {
1743 b.push(fresh.pending.len());
1744 }
1745 fresh.pending.push_str(text);
1746 }
1747 }
1748 fresh.pending_attachments.extend(attachments);
1749 store.put(&mut fresh)?;
1750 *talk = fresh;
1751 Ok(true)
1752}
1753
1754pub fn post(store: &Talks, talk_id: &str, body: &str, by: &str) -> Result<()> {
1759 let body = body.trim();
1760 if body.is_empty() {
1761 bail!("nothing to post");
1762 }
1763 let _guard = store.guard()?;
1764 let mut fresh = store
1765 .get(talk_id)
1766 .with_context(|| format!("talk {talk_id} does not exist"))?;
1767 if !fresh.status.open() {
1768 bail!(
1769 "talk {} is closed and takes no more messages",
1770 fresh.short()
1771 );
1772 }
1773 fresh.turns.push(Turn {
1774 sender: Some(Sender::Magi),
1775 who: Who::Agent,
1776 body: body.to_owned(),
1777 at: Timestamp::now(),
1778 attachments: Vec::new(),
1779 usage: None,
1780 breaks: None,
1781 messages: Vec::new(),
1782 posted_by: Some(by.to_owned()),
1783 });
1784 store.put(&mut fresh)
1785}
1786
1787fn posts_read_through(talk: &Talk) -> usize {
1789 talk.posts_read_through.unwrap_or_else(|| {
1790 talk.turns
1791 .iter()
1792 .rposition(|t| t.who != Who::Operator && t.posted_by.is_none())
1793 .map_or(0, |i| i + 1)
1794 })
1795}
1796
1797pub fn posts_since_last_reply(talk: &Talk) -> Option<String> {
1799 let posts: Vec<&Turn> = talk
1800 .turns
1801 .iter()
1802 .skip(posts_read_through(talk))
1803 .filter(|t| t.posted_by.is_some())
1804 .collect();
1805 if posts.is_empty() {
1806 return None;
1807 }
1808 let mut out = String::from("# Posted by a task\n\n");
1809 for t in posts {
1810 out.push_str(&format!(
1811 "Task seat {} posted to this conversation: {}\n\n",
1812 short(t.posted_by.as_deref().unwrap_or_default()),
1813 t.body
1814 ));
1815 }
1816 Some(out)
1817}
1818
1819pub fn drain(talk: &mut Talk, store: &Talks) -> Result<Option<String>> {
1821 let _guard = store.guard()?;
1822 let mut fresh = store
1823 .get(&talk.id)
1824 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1825 if !fresh.status.open() || (fresh.pending.is_empty() && fresh.pending_attachments.is_empty()) {
1826 *talk = fresh;
1827 return Ok(None);
1828 }
1829 let text = std::mem::take(&mut fresh.pending);
1830 let attachments = std::mem::take(&mut fresh.pending_attachments);
1831 let breaks = fresh.pending_breaks.take();
1832 let messages = std::mem::take(&mut fresh.pending_messages);
1833 fresh.turns.push(Turn {
1834 sender: Some(Sender::Operator),
1835 who: Who::Operator,
1836 body: text.clone(),
1837 at: Timestamp::now(),
1838 attachments,
1839 usage: None,
1840 breaks,
1841 messages,
1842 posted_by: None,
1843 });
1844 store.put(&mut fresh)?;
1845 *talk = fresh;
1846 Ok(Some(text))
1847}
1848
1849pub async fn say(
1852 lease: &TurnLease,
1853 talk: &mut Talk,
1854 store: &Talks,
1855 cfg: &Config,
1856 text: &str,
1857 attachments: Vec<Attachment>,
1858) -> Result<()> {
1859 check_lease(lease, talk)?;
1860 let text = record(talk, store, text, attachments)?;
1861 respond(lease, talk, store, cfg, &text).await
1862}
1863
1864pub async fn respond(
1870 lease: &TurnLease,
1871 talk: &mut Talk,
1872 store: &Talks,
1873 cfg: &Config,
1874 text: &str,
1875) -> Result<()> {
1876 check_lease(lease, talk)?;
1877 lease
1878 .beating(turn(talk, store, cfg, text))
1879 .await
1880 .and_then(|done| done)
1881}
1882
1883fn check_lease(lease: &TurnLease, talk: &Talk) -> Result<()> {
1884 if lease.talk != talk.id {
1885 bail!("the turn lease is for talk {}, not {}", lease.talk, talk.id);
1886 }
1887 if !lease.holds() {
1890 bail!("the turn lease for talk {} is no longer held", talk.short());
1891 }
1892 Ok(())
1893}
1894
1895pub fn close(talk: &mut Talk, store: &Talks) -> Result<()> {
1913 let _guard = store.guard()?;
1914 let mut fresh = store
1915 .get(&talk.id)
1916 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1917 fresh.status = TalkStatus::Closed;
1918 fresh.pending.clear();
1920 fresh.pending_breaks = None;
1921 fresh.pending_messages.clear();
1922 fresh.pending_attachments.clear();
1923 store.put(&mut fresh)?;
1924 *talk = fresh;
1925 Ok(())
1926}
1927
1928pub fn reopen(talk: &mut Talk, store: &Talks) -> Result<()> {
1939 let _guard = store.guard()?;
1940 let mut fresh = store
1941 .get(&talk.id)
1942 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1943 fresh.status = TalkStatus::Open;
1944 store.put(&mut fresh)?;
1945 *talk = fresh;
1946 Ok(())
1947}
1948
1949pub fn switch_agent(talk: &mut Talk, store: &Talks, spec: &AgentSpec) -> Result<bool> {
1962 let _guard = store.guard()?;
1963 let mut fresh = store
1964 .get(&talk.id)
1965 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1966 if fresh.agent == spec.id {
1967 *talk = fresh;
1968 return Ok(false);
1969 }
1970 let from = std::mem::replace(&mut fresh.agent, spec.id.clone());
1971 fresh.seat = SeatState::new(SEAT, &spec.id, crate::rng::entropy());
1972 fresh.fallback = false;
1974 fresh.turns.push(Turn {
1975 sender: Some(Sender::Magi),
1976 breaks: None,
1977 messages: Vec::new(),
1978 posted_by: None,
1979 who: Who::Agent,
1980 body: format!("{MAGI_NOTE}agent changed from {from} to {}", spec.id),
1981 at: Timestamp::now(),
1982 attachments: Vec::new(),
1983 usage: None,
1984 });
1985 store.put(&mut fresh)?;
1986 *talk = fresh;
1987 Ok(true)
1988}
1989
1990pub fn switch_persona(talk: &mut Talk, store: &Talks, id: &str) -> Result<bool> {
1998 let id = if id.trim() == crate::persona::DEFAULT_ID {
1999 ""
2000 } else {
2001 id.trim()
2002 };
2003 let _guard = store.guard()?;
2004 let mut fresh = store
2005 .get(&talk.id)
2006 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2007 if fresh.persona == id {
2008 *talk = fresh;
2009 return Ok(false);
2010 }
2011 let from = std::mem::replace(&mut fresh.persona, id.to_owned());
2012 fresh.persona_dirty = true;
2013 let label = |p: &str| {
2014 if p.is_empty() {
2015 crate::persona::DEFAULT_ID.to_owned()
2016 } else {
2017 p.to_owned()
2018 }
2019 };
2020 fresh.turns.push(Turn {
2021 sender: Some(Sender::Magi),
2022 breaks: None,
2023 messages: Vec::new(),
2024 posted_by: None,
2025 who: Who::Agent,
2026 body: format!(
2027 "{MAGI_NOTE}persona changed from {} to {}",
2028 label(&from),
2029 label(id)
2030 ),
2031 at: Timestamp::now(),
2032 attachments: Vec::new(),
2033 usage: None,
2034 });
2035 store.put(&mut fresh)?;
2036 *talk = fresh;
2037 Ok(true)
2038}
2039
2040pub fn clear_pending(talk: &mut Talk, store: &Talks) -> Result<()> {
2042 let _guard = store.guard()?;
2043 let mut fresh = store
2044 .get(&talk.id)
2045 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2046 fresh.pending.clear();
2047 fresh.pending_breaks = None;
2048 fresh.pending_messages.clear();
2049 fresh.pending_attachments.clear();
2050 store.put(&mut fresh)?;
2051 *talk = fresh;
2052 Ok(())
2053}
2054
2055pub fn clear_pending_if_matches(
2057 talk: &mut Talk,
2058 store: &Talks,
2059 expected_text: &str,
2060 expected_attachments: &[String],
2061) -> Result<bool> {
2062 let _guard = store.guard()?;
2063 let mut fresh = store
2064 .get(&talk.id)
2065 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2066 if !pending_matches(&fresh, expected_text, expected_attachments) {
2067 *talk = fresh;
2068 return Ok(false);
2069 }
2070 fresh.pending.clear();
2071 fresh.pending_breaks = None;
2072 fresh.pending_messages.clear();
2073 fresh.pending_attachments.clear();
2074 store.put(&mut fresh)?;
2075 *talk = fresh;
2076 Ok(true)
2077}
2078
2079pub fn edit_pending_text(
2083 talk: &mut Talk,
2084 store: &Talks,
2085 text: &str,
2086 expected_text: &str,
2087 expected_attachments: &[String],
2088) -> Result<bool> {
2089 edit_pending_message(talk, store, text, expected_text, expected_attachments, None)
2090}
2091
2092pub fn edit_pending_message(
2094 talk: &mut Talk,
2095 store: &Talks,
2096 text: &str,
2097 expected_text: &str,
2098 expected_attachments: &[String],
2099 message: Option<usize>,
2100) -> Result<bool> {
2101 let _guard = store.guard()?;
2102 let mut fresh = store
2103 .get(&talk.id)
2104 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2105 if !pending_matches(&fresh, expected_text, expected_attachments) {
2106 *talk = fresh;
2107 return Ok(false);
2108 }
2109 if fresh
2110 .pending_messages
2111 .iter()
2112 .enumerate()
2113 .any(|(i, m)| m.sender == Some(Sender::Magi) && message.is_none_or(|index| index == i))
2114 {
2115 bail!("MAGI messages cannot be edited");
2116 }
2117 if let Some(index) = message {
2118 let edited = fresh
2119 .pending_messages
2120 .get_mut(index)
2121 .context("invalid queued message")?;
2122 if text.trim().is_empty() && edited.attachments.is_empty() {
2123 fresh.pending_messages.remove(index);
2124 } else {
2125 edited.body = text.to_owned();
2126 }
2127 fresh.pending.clear();
2128 fresh.pending_breaks = Some(Vec::new());
2129 fresh.pending_attachments.clear();
2130 for message in &fresh.pending_messages {
2131 if !message.body.is_empty() {
2132 if !fresh.pending.is_empty() {
2133 fresh.pending.push_str("\n\n");
2134 fresh
2135 .pending_breaks
2136 .as_mut()
2137 .expect("initialized")
2138 .push(fresh.pending.len());
2139 }
2140 fresh.pending.push_str(&message.body);
2141 }
2142 fresh
2143 .pending_attachments
2144 .extend(message.attachments.clone());
2145 }
2146 } else {
2147 if fresh.pending_messages.len() > 1
2148 && fresh.pending_messages.iter().any(|m| m.reply.is_some())
2149 {
2150 bail!("edit queued replies individually");
2151 }
2152 let reply = fresh.pending_messages.first().and_then(|m| m.reply.clone());
2153 fresh.pending_messages = vec![Message {
2154 display: None,
2155 sender: Some(Sender::Operator),
2156 body: text.to_owned(),
2157 attachments: fresh.pending_attachments.clone(),
2158 reply,
2159 }];
2160 fresh.pending = text.to_owned();
2161 fresh.pending_breaks = Some(Vec::new());
2162 }
2163 store.put(&mut fresh)?;
2164 *talk = fresh;
2165 Ok(true)
2166}
2167
2168fn pending_matches(talk: &Talk, expected_text: &str, expected_attachments: &[String]) -> bool {
2169 talk.pending == expected_text
2170 && talk
2171 .pending_attachments
2172 .iter()
2173 .map(|attachment| &attachment.id)
2174 .eq(expected_attachments.iter())
2175}
2176
2177async fn turn(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
2184 let text = talk
2188 .turns
2189 .last()
2190 .filter(|t| t.who == Who::Operator && t.body == text)
2191 .map(|turn| message_prompt(turn, talk, store))
2192 .unwrap_or_else(|| text.to_owned());
2193 let evidence = crate::chat_notify::response_evidence(talk).await;
2194 let text = format!("{text}{evidence}");
2195 let prompt_turns = talk.turns.len();
2196 talk.posts_read_through = Some(posts_read_through(talk));
2197 let spec = cfg
2198 .agents
2199 .iter()
2200 .find(|a| a.id == talk.agent)
2201 .with_context(|| {
2202 format!(
2203 "talk {} was opened with agent `{}`, which is no longer in \
2204 the roster; restore it in magi.toml or start a new \
2205 conversation",
2206 talk.short(),
2207 talk.agent
2208 )
2209 })?;
2210
2211 let last_note = attachment_note(
2215 store,
2216 &talk.id,
2217 talk.turns
2218 .last()
2219 .map_or(&[][..], |t| t.attachments.as_slice()),
2220 );
2221
2222 let attachment_paths: Vec<PathBuf> = talk
2228 .turns
2229 .iter()
2230 .flat_map(|t| t.attachments.iter())
2231 .filter_map(|a| store.attachment_path(&talk.id, a))
2232 .collect();
2233
2234 let questions =
2244 crate::run::try_home().map(|home| crate::ask::Questions::at(home.join("questions")));
2245 let consulted = questions
2246 .as_ref()
2247 .is_some_and(|q| crate::consult::pending_consults(q, &talk.id));
2248 let consult_roots: Vec<PathBuf> = match &questions {
2249 Some(q) if consulted => vec![q.root().to_path_buf()],
2250 _ => Vec::new(),
2251 };
2252
2253 let (allow_write, unsandboxed) = turn_access(cfg.talk.allow_write, consulted);
2254
2255 let artifacts = store.artifacts_of(&talk.id);
2256 let operator_turns = talk.turns.iter().filter(|t| t.who == Who::Operator).count();
2259 let stem = format!("turn-{}", operator_turns.max(1));
2260 let cache_dir = cfg.cache_dir();
2263
2264 let mut chain = vec![spec.clone()];
2267 if let Some(choice) = cfg.roles.chatter.as_ref()
2268 && talk.fallback
2269 {
2270 for id in choice.ids() {
2271 if id == talk.agent || chain.iter().any(|s| s.id == id) {
2272 continue;
2273 }
2274 match agent::pick(&cfg.agents, Some(id), &agent::installed) {
2275 Ok(s) => chain.push(s),
2276 Err(e) => tracing::warn!("[roles] chatter: skipping `{id}`: {e:#}"),
2277 }
2278 }
2279 }
2280
2281 let persona = crate::persona::active(&cfg.talk.personas, &talk.persona);
2282 let operator_name = cfg.talk.operator_name();
2283 let persona_update = if talk.persona_dirty {
2284 format!(
2285 "{}\n\n",
2286 crate::persona::update_block_for(persona.as_ref(), operator_name)
2287 )
2288 } else {
2289 String::new()
2290 };
2291
2292 let filing_update = if talk.implementers_dirty {
2295 format!("{}\n\n", filing_update_block(talk.implementers))
2296 } else {
2297 String::new()
2298 };
2299
2300 let mut outcome = None;
2301 let mut fell_back_from: Option<String> = None;
2302 let mut first_try: Option<(String, SeatState)> = None;
2305 for (n, spec) in chain.iter().enumerate() {
2306 if n > 0 {
2307 if first_try.is_none() {
2308 first_try = Some((talk.agent.clone(), talk.seat.clone()));
2309 }
2310 tracing::warn!("chat: falling back from `{}` to `{}`", talk.agent, spec.id);
2311 fell_back_from.get_or_insert_with(|| talk.agent.clone());
2314 talk.agent = spec.id.clone();
2315 talk.seat = SeatState::new(SEAT, &spec.id, crate::rng::entropy());
2316 }
2317 let resuming = agent::has_session(spec.kind, &talk.seat, cfg.graph.sessions);
2318 let first_ever = talk.turns.len() <= 1;
2319 let body = if talk.seat.turns == 0 && first_ever {
2320 format!(
2321 "{}\n\n# Operator\n\n{text}{last_note}",
2322 briefing_for(
2323 &talk.repo,
2324 &cfg.graph.language,
2325 cfg.talk.allow_write,
2326 persona.as_ref(),
2327 operator_name,
2328 talk.implementers
2329 )
2330 )
2331 } else if talk.seat.turns == 0 {
2332 format!(
2335 "{}\n\n{}\n\n# Operator\n\n{text}{last_note}",
2336 briefing_for(
2337 &talk.repo,
2338 &cfg.graph.language,
2339 cfg.talk.allow_write,
2340 persona.as_ref(),
2341 operator_name,
2342 talk.implementers
2343 ),
2344 transcript(talk, store)
2345 )
2346 } else if resuming {
2347 let posts = posts_since_last_reply(talk).unwrap_or_default();
2348 format!("{persona_update}{filing_update}{posts}{text}{last_note}")
2349 } else {
2350 let mut standing = match (&persona, talk.persona_dirty) {
2354 (Some(p), false) => format!("{}\n", crate::persona::section_for(p, operator_name)),
2355 _ => persona_update.clone(),
2356 };
2357 if let (None, Some(n)) = (&persona, operator_name) {
2360 standing.push_str(&format!(
2361 "# Addressing the operator\n{}\n",
2362 crate::persona::addressing(n)
2363 ));
2364 }
2365 if talk.implementers_dirty || talk.implementers != 1 {
2366 standing.push_str(&format!("{}\n\n", filing_update_block(talk.implementers)));
2367 }
2368 format!("{}\n\n{standing}{text}{last_note}", transcript(talk, store))
2369 };
2370 let current_approvals = questions
2373 .as_ref()
2374 .map(|store| {
2375 store
2376 .list()
2377 .iter()
2378 .filter(|q| {
2379 q.status.open()
2380 && q.node == crate::land::APPROVAL_NODE
2381 && q.consult.as_ref().is_some_and(|c| c.talk == talk.id)
2382 })
2383 .map(crate::prompt::chat_consult)
2384 .collect::<Vec<_>>()
2385 .join("\n\n")
2386 })
2387 .unwrap_or_default();
2388 let body = if current_approvals.is_empty() {
2389 body
2390 } else {
2391 format!("{current_approvals}\n\n# Current operator message\n\n{body}")
2392 };
2393 let attempt_stem = if n == 0 {
2394 stem.clone()
2395 } else {
2396 format!("{stem}-{}", spec.id)
2397 };
2398 let inv = Invocation {
2399 cwd: &talk.repo,
2400 prompt: &body,
2401 timeout: turn_timeout(cfg),
2402 allow_write,
2407 unsandboxed,
2408 sessions: cfg.graph.sessions,
2409 artifacts: &artifacts,
2410 stem: &attempt_stem,
2411 run: &talk.id,
2414 node: crate::queue::CHAT_NODE,
2415 cache_dir: cache_dir.as_deref(),
2416 attachments: &attachment_paths,
2417 writable: &consult_roots,
2418 };
2419 let result = agent::invoke(spec, &mut talk.seat, &inv).await;
2420 let advance = agent::chain_advances(&result);
2421 if n == 0 || !advance {
2422 outcome = Some(result);
2423 } else {
2424 tracing::warn!("chat: fallback agent `{}` also failed", spec.id);
2426 }
2427 if !advance {
2428 break;
2429 }
2430 }
2431 if outcome.as_ref().is_some_and(agent::chain_advances) {
2432 if let Some((id, seat)) = first_try {
2435 talk.agent = id;
2436 talk.seat = seat;
2437 fell_back_from = None;
2438 }
2439 }
2440 let outcome = outcome.expect("a chain holds at least one agent");
2441 let note = |why: String| Turn {
2442 sender: Some(Sender::Magi),
2443 breaks: None,
2444 messages: Vec::new(),
2445 posted_by: None,
2446 who: Who::Agent,
2447 body: format!("{MAGI_NOTE}{why}"),
2448 at: Timestamp::now(),
2449 attachments: Vec::new(),
2450 usage: None,
2451 };
2452 let (reply, failure) = match outcome {
2453 Err(e) => (
2454 note(format!("could not run agent `{}`: {e}", talk.agent)),
2455 Some(format!("could not run agent `{}`: {e}", talk.agent)),
2456 ),
2457 Ok(out) if out.quota_exhausted() => {
2458 let reset = out
2459 .quota
2460 .as_ref()
2461 .and_then(|q| q.reset.clone())
2462 .map_or_else(String::new, |r| format!(" (resets {r})"));
2463 let why = format!(
2464 "agent `{}` is out of quota{reset}; your message is saved, so \
2465 say it again when the window reopens",
2466 talk.agent
2467 );
2468 (note(why.clone()), Some(why))
2469 }
2470 Ok(out) if out.timed_out => {
2471 let why = format!(
2472 "agent `{}` did not answer within {}s; your message is saved",
2473 talk.agent,
2474 turn_timeout(cfg).as_secs()
2475 );
2476 (note(why.clone()), Some(why))
2477 }
2478 Ok(out) if !out.usable() => {
2479 let why = format!(
2480 "agent `{}` produced no answer (exit {}); your message is saved",
2481 talk.agent,
2482 out.exit_code
2483 .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
2484 );
2485 (note(why.clone()), Some(why))
2486 }
2487 Ok(out) => (
2488 Turn {
2489 sender: Some(Sender::Persona),
2490 breaks: None,
2491 messages: Vec::new(),
2492 posted_by: None,
2493 who: Who::Agent,
2494 body: out.text.trim().to_owned(),
2495 at: Timestamp::now(),
2496 attachments: Vec::new(),
2497 usage: out.context_tokens.map(|context_tokens| TurnUsage {
2500 context_tokens,
2501 agent: talk.agent.clone(),
2502 model: cfg
2503 .agents
2504 .iter()
2505 .find(|a| a.id == talk.agent)
2506 .and_then(|a| a.model.clone()),
2507 }),
2508 },
2509 None,
2510 ),
2511 };
2512
2513 let _guard = store.guard()?;
2525 let Ok(fresh) = store.get(&talk.id) else {
2531 return Ok(());
2532 };
2533 talk.status = fresh.status;
2534 talk.pending = fresh.pending;
2538 talk.pending_breaks = fresh.pending_breaks;
2539 talk.pending_messages = fresh.pending_messages;
2540 talk.pending_attachments = fresh.pending_attachments;
2541 talk.notified = fresh.notified;
2542 if fresh.turns.len() > talk.turns.len() {
2544 let posted: Vec<Turn> = fresh.turns[talk.turns.len()..]
2545 .iter()
2546 .filter(|t| t.posted_by.is_some())
2547 .cloned()
2548 .collect();
2549 talk.turns.extend(posted);
2550 }
2551 if let Some(from) = fell_back_from.filter(|_| failure.is_none()) {
2552 talk.turns.push(note(format!(
2555 "agent changed from {from} to {} (fallback)",
2556 talk.agent
2557 )));
2558 }
2559 if failure.is_none() {
2562 talk.persona_dirty = false;
2563 talk.implementers_dirty = false;
2564 talk.posts_read_through = Some(prompt_turns);
2565 }
2566 talk.turns.push(reply);
2567 if let Err(put_err) = store.put(talk) {
2568 let lost = talk.turns.pop().expect("just pushed above");
2577 let stash = stash_lost_turn(store, &talk.id, &stem, &lost);
2578 let why = match &stash {
2579 Ok(path) => format!(
2580 "agent `{}` answered, but the reply could not be saved to \
2581 this conversation ({put_err:#}); the raw text was kept at \
2582 {} - your message is saved, ask again",
2583 talk.agent,
2584 path.display()
2585 ),
2586 Err(stash_err) => format!(
2587 "agent `{}` answered, but the reply could not be saved to \
2588 this conversation ({put_err:#}), and it could not be kept \
2589 anywhere else either ({stash_err:#}); your message is \
2590 saved, ask again",
2591 talk.agent
2592 ),
2593 };
2594 talk.turns.push(note(why.clone()));
2595 return match store.put(talk) {
2602 Ok(()) => bail!("{why}"),
2603 Err(note_err) => {
2604 talk.turns.pop();
2624 Err(note_err).context(why)
2625 }
2626 };
2627 }
2628
2629 match failure {
2630 Some(why) => bail!("{why}"),
2631 None => Ok(()),
2632 }
2633}
2634
2635fn transcript(talk: &Talk, store: &Talks) -> String {
2638 let mut out = String::from(
2639 "This conversation cannot resume on the CLI's side, so here is \
2640 everything said so far; answer only the last message.\n",
2641 );
2642 for t in &talk.turns {
2643 let who = match t.who {
2644 Who::Operator => "operator",
2645 Who::Agent if t.body.starts_with(MAGI_NOTE) => "magi",
2646 Who::Agent => "you",
2647 };
2648 out.push_str(&format!(
2649 "\n## {who}\n\n{}\n",
2650 message_prompt(t, talk, store).trim()
2651 ));
2652 out.push_str(&attachment_note(store, &talk.id, &t.attachments));
2653 }
2654 out
2655}
2656
2657fn attachment_note(store: &Talks, talk_id: &str, attachments: &[Attachment]) -> String {
2662 if attachments.is_empty() {
2663 return String::new();
2664 }
2665 let mut out = String::from(
2666 "\n\nThe operator attached the image(s) below to this message. Open \
2667 and look at each one before you answer.\n",
2668 );
2669 for att in attachments {
2670 if let Some(path) = store.attachment_path(talk_id, att) {
2671 out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
2672 }
2673 }
2674 out.push('\n');
2675 out
2676}
2677
2678pub(crate) fn turn_access(talk_allow_write: bool, consulted: bool) -> (bool, bool) {
2689 (talk_allow_write || consulted, talk_allow_write)
2690}
2691
2692pub fn briefing(repo: &Path, language: &str, allow_write: bool) -> String {
2708 briefing_with(repo, language, allow_write, None, None)
2709}
2710
2711pub fn briefing_with(
2716 repo: &Path,
2717 language: &str,
2718 allow_write: bool,
2719 persona: Option<&crate::persona::Persona>,
2720 operator_name: Option<&str>,
2721) -> String {
2722 briefing_for(repo, language, allow_write, persona, operator_name, 1)
2723}
2724
2725pub const MAX_IMPLEMENTERS: u8 = 3;
2727
2728fn solo_implementers() -> u8 {
2729 1
2730}
2731
2732fn filing_flag(n: u8) -> String {
2735 if n <= 1 {
2736 "--solo".to_owned()
2737 } else {
2738 format!("--implementers {n}")
2739 }
2740}
2741
2742pub fn filing_update_block(n: u8) -> String {
2745 let flag = filing_flag(n);
2746 if n <= 1 {
2747 format!(
2748 "# Task filing update\n\nThe operator changed how many implementers tasks use. \
2749 From now on file with `magi task add {flag} --repo <repo> <instruction>` \
2750 (one implementer, straight into review).\n"
2751 )
2752 } else {
2753 format!(
2754 "# Task filing update\n\nThe operator changed how many implementers tasks use. \
2755 From now on file with `magi task add {flag} --repo <repo> <instruction>` \
2756 ({n} independent implementations compete). Do not pass `--solo` \
2757 together with `--implementers`; drop any earlier `--solo`.\n"
2758 )
2759 }
2760}
2761
2762pub fn check_implementers(n: u8, cfg: &crate::config::Config) -> std::result::Result<u8, String> {
2765 if !(1..=MAX_IMPLEMENTERS).contains(&n) {
2766 return Err(format!(
2767 "implementers must be 1..={MAX_IMPLEMENTERS}, got {n}"
2768 ));
2769 }
2770 if n > 1 {
2771 let mut cfg = cfg.clone();
2772 crate::queue::RunOverrides {
2773 candidates: Some(usize::from(n)),
2774 ..Default::default()
2775 }
2776 .apply(&mut cfg);
2777 cfg.resolve_roles()
2778 .map_err(|e| format!("{n} implementers is not allowed here: {e:#}"))?;
2779 }
2780 Ok(n)
2781}
2782
2783pub fn switch_implementers(talk: &mut Talk, store: &Talks, n: u8) -> Result<bool> {
2786 let _guard = store.guard()?;
2787 let mut fresh = store
2788 .get(&talk.id)
2789 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2790 if fresh.implementers == n {
2791 *talk = fresh;
2792 return Ok(false);
2793 }
2794 let from = std::mem::replace(&mut fresh.implementers, n);
2795 fresh.implementers_dirty = true;
2796 fresh.turns.push(Turn {
2797 sender: Some(Sender::Magi),
2798 breaks: None,
2799 messages: Vec::new(),
2800 posted_by: None,
2801 who: Who::Agent,
2802 body: format!("{MAGI_NOTE}implementers changed from {from} to {n}"),
2803 at: Timestamp::now(),
2804 attachments: Vec::new(),
2805 usage: None,
2806 });
2807 store.put(&mut fresh)?;
2808 *talk = fresh;
2809 Ok(true)
2810}
2811
2812pub fn briefing_for(
2815 repo: &Path,
2816 language: &str,
2817 allow_write: bool,
2818 persona: Option<&crate::persona::Persona>,
2819 operator_name: Option<&str>,
2820 implementers: u8,
2821) -> String {
2822 let flag = filing_flag(implementers);
2823 let shape = if implementers <= 1 {
2824 "Use --solo: it runs the task through one implementer \
2825 straight into review instead of the usual multi-agent competition, \
2826 which is the right shape for a change this conversation has already \
2827 settled, rather than one still worth several independent takes."
2828 .to_owned()
2829 } else {
2830 format!(
2831 "Use --implementers {implementers}: it has {implementers} \
2832 implementers work on the task independently and compete, which \
2833 is the right shape when several independent takes are worth \
2834 having. Never add --solo to it; the two do not go together."
2835 )
2836 };
2837 let write_policy = if allow_write {
2838 "Write access is enabled for this conversation (`allow_write = \
2839 true`), so you may write files - but only a small, \
2840 already-decided edit the operator names outright in this \
2841 conversation, not an implementation. This is a permission on the \
2842 conversation as a whole, not a property of whichever repository \
2843 it happened to start in: if the operator names a different \
2844 repository for that small edit, the policy allows it there too. \
2845 Your own tool may still confine writes to the repository this \
2846 conversation started in regardless - if a write elsewhere is \
2847 refused, say so plainly rather than working around it. Once you \
2848 have made an edit, say plainly what you edited. Anything bigger, \
2849 or anything still open-ended, still goes through the queue below \
2850 rather than being done here."
2851 } else {
2852 "Do not write files. Implementing a change is not this \
2853 conversation's job; a separate, blind competition of agents does \
2854 that, and a repository this conversation has already edited would \
2855 make their diffs unjudgeable."
2856 };
2857 let mut out = format!(
2858 "You are magi's standing conversation partner for its operator, who \
2859 usually has this open on a phone. Keep replies short: no preamble, \
2860 no restating what they just said.\n\n\
2861 # Repository\n\n{repo}\n\n\
2862 You may look around: read files, run shell commands, search history, \
2863 run tests - whatever answers the question. {write_policy}\n\n\
2864 A short, command-shaped message (\"list\", \"info <id>\", \"show \
2865 3cbf\") is almost always the operator asking you to look something \
2866 up, not an instruction to file - answer it yourself with `magi \
2867 list`, `magi show <id>`, `magi task list`, or the like, the same way \
2868 you would answer any other question in this conversation.\n\n\
2869 # When the operator wants something done\n\n\
2870 Run:\n\n\
2871 magi task add {flag} --repo {repo} <instruction>\n\n\
2872 and tell the operator the task id it prints, so they can follow it \
2873 from the Queue. If it refuses with a duplicate warning (the \
2874 instruction names a branch, commit or pull request that an \
2875 unfinished task, run or PR already owns), do not repeat it with \
2876 --force yourself: tell the operator what it matched and let them \
2877 decide. Write <instruction> so that an implementer who has \
2878 never seen this conversation can act on it alone - it is everything \
2879 they get. {shape}\n\n\
2880 If the operator asks for something in a different repository, \
2881 --repo does not have to be a full path: --repo owner/repo (or just \
2882 repo, when that is unambiguous) is resolved against local checkouts \
2883 the same way `magi repos` lists them. If the command fails because \
2884 nothing matches or more than one checkout shares that name, ask the \
2885 operator which repository they mean (or run `magi repos` yourself \
2886 to see the candidates) rather than guessing.\n\n\
2887 You cannot wait, and you cannot send the operator a message on your \
2888 own: you only speak when they write. A task you file reports back \
2889 into this conversation automatically when it finishes, is held or \
2890 fails, as a message headed \"{notice}\".\n\n\
2891 For a background command you run yourself, register it with \
2892 `magi talk watch --timeout 900 -- <executable> <arguments...>`, for \
2893 example `magi talk watch -- cargo test --locked`. It uses your \
2894 current directory; arguments are passed literally. For shell syntax, \
2895 explicitly call `sh -c`. Only after watch registration succeeds may \
2896 you promise a completion report. Watches default to 900 seconds, \
2897 accept at most 3600 seconds, and share four concurrent slots. Their \
2898 automatic reports resume this conversation through its own turn. \
2899 Never promise to report back on work that is neither a filed task \
2900 nor a successfully registered watch.\n\n\
2901 The current state of the code is whatever origin/main holds, not \
2902 whatever a working tree shows: a primary checkout often lags \
2903 upstream, sits on a detached HEAD and carries uncommitted changes. \
2904 Before answering about code, run `git fetch origin` in that \
2905 repository if it is cheap, then read through \
2906 `git show origin/main:<path>` or `git grep <pattern> origin/main`. \
2907 If the working tree differs, say so; if the fetch fails, say that \
2908 too, so the operator knows the answer may be stale.\n\n\
2909 If the operator attached an image (a screenshot, say) that the task \
2910 is about, pass it with `--attach <path>`, using the absolute path \
2911 the turn's attachment note gives; repeat the flag for several. \
2912 `magi task add {flag} --attach <path> <instruction>` copies the \
2913 file into the task, so the implementer receives it. Do not paste the \
2914 path into <instruction> instead: deleting this conversation deletes \
2915 its attachments, and then that path reaches no one.\n",
2916 repo = repo.display(),
2917 notice = crate::prompt::CHAT_TASK_NOTICE_HEADING,
2918 );
2919 out.push_str(&language_note(language));
2920 match (persona, operator_name) {
2921 (Some(p), n) => out.push_str(&crate::persona::section_for(p, n)),
2922 (None, Some(n)) => {
2923 out.push_str("\n# Addressing the operator\n");
2924 out.push_str(&crate::persona::addressing(n));
2925 }
2926 (None, None) => {}
2927 }
2928 out
2929}
2930
2931fn language_note(language: &str) -> String {
2934 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
2935 String::new()
2936 } else {
2937 format!("\nHold this conversation in {language}.\n")
2938 }
2939}
2940
2941pub fn tasks_of(queue: &Queue, talk_id: &str) -> Vec<Task> {
2948 let mut tasks: Vec<Task> = queue
2949 .list()
2950 .into_iter()
2951 .filter(|t| matches!(&t.source, Source::Agent { run, .. } if run == talk_id))
2952 .collect();
2953 tasks.sort_unstable_by(|a, b| a.id.cmp(&b.id));
2954 tasks
2955}
2956
2957fn read_path(path: &Path) -> Result<Talk> {
2958 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
2959 let mut talk: Talk =
2960 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2961 for turn in &mut talk.turns {
2962 if turn.who == Who::Operator && turn.messages.is_empty() && turn.attachments.is_empty() {
2963 turn.messages = plain_messages(&turn.body, turn.breaks.as_deref());
2964 }
2965 }
2966 if talk.pending_messages.is_empty() && talk.pending_attachments.is_empty() {
2967 talk.pending_messages = plain_messages(&talk.pending, talk.pending_breaks.as_deref());
2968 }
2969 Ok(talk)
2970}
2971
2972fn plain_messages(body: &str, breaks: Option<&[usize]>) -> Vec<Message> {
2974 let Some(breaks) = breaks else {
2975 return Vec::new();
2976 };
2977 if body.is_empty() {
2978 return Vec::new();
2979 }
2980 let mut messages = Vec::new();
2981 let mut start = 0;
2982 for end in breaks
2983 .iter()
2984 .copied()
2985 .chain(std::iter::once(body.len() + 2))
2986 {
2987 let Some(text) = end.checked_sub(2).and_then(|end| body.get(start..end)) else {
2988 return Vec::new();
2989 };
2990 messages.push(Message {
2991 display: None,
2992 sender: None,
2993 body: text.to_owned(),
2994 attachments: Vec::new(),
2995 reply: None,
2996 });
2997 start = end;
2998 }
2999 messages
3000}
3001
3002const PUT_RETRIES: u32 = 5;
3005
3006fn write_atomic(tmp: &Path, path: &Path, body: &str) -> Result<()> {
3018 let mut last_err = None;
3019 for attempt in 0..PUT_RETRIES {
3020 if attempt > 0 {
3021 std::thread::sleep(Duration::from_millis(20 * u64::from(attempt)));
3022 }
3023 match try_write_atomic(tmp, path, body) {
3024 Ok(()) => return Ok(()),
3025 Err(e) => last_err = Some(e),
3026 }
3027 }
3028 Err(last_err.expect("the loop above always runs at least once"))
3029}
3030
3031fn try_write_atomic(tmp: &Path, path: &Path, body: &str) -> Result<()> {
3032 #[cfg(test)]
3033 if failpoint::take_forced_put_failure() {
3034 bail!("simulated write failure (test)");
3035 }
3036 std::fs::write(tmp, body).with_context(|| format!("write {}", tmp.display()))?;
3037 std::fs::rename(tmp, path).with_context(|| format!("replace {}", path.display()))?;
3038 Ok(())
3039}
3040
3041fn stash_lost_turn(store: &Talks, id: &str, stem: &str, reply: &Turn) -> Result<PathBuf> {
3046 let dir = store.artifacts_of(id);
3047 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
3048 let path = dir.join(format!("{stem}-lost.txt"));
3049 std::fs::write(&path, &reply.body).with_context(|| format!("write {}", path.display()))?;
3050 Ok(path)
3051}
3052
3053#[cfg(test)]
3060mod failpoint {
3061 use std::cell::Cell;
3062
3063 thread_local! {
3064 static FORCE_PUT_FAILURES: Cell<u32> = const { Cell::new(0) };
3065 static FORCE_NO_LINK: Cell<bool> = const { Cell::new(false) };
3066 }
3067
3068 pub(super) fn force_no_link(on: bool) {
3071 FORCE_NO_LINK.with(|c| c.set(on));
3072 }
3073
3074 pub(super) fn no_link_forced() -> bool {
3075 FORCE_NO_LINK.with(Cell::get)
3076 }
3077
3078 pub(super) fn force_put_failures(count: u32) {
3081 FORCE_PUT_FAILURES.with(|c| c.set(count));
3082 }
3083
3084 pub(super) fn take_forced_put_failure() -> bool {
3087 FORCE_PUT_FAILURES.with(|c| {
3088 let n = c.get();
3089 if n == 0 {
3090 false
3091 } else {
3092 c.set(n - 1);
3093 true
3094 }
3095 })
3096 }
3097}
3098
3099fn short(id: &str) -> &str {
3100 id.split('-').next_back().unwrap_or(id)
3101}
3102
3103fn new_id() -> String {
3104 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3105 let seed = crate::rng::entropy();
3106 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3107}
3108
3109fn attachment_ext(mime: &str) -> Option<&'static str> {
3114 match mime {
3115 "image/png" => Some("png"),
3116 "image/jpeg" => Some("jpg"),
3117 "image/gif" => Some("gif"),
3118 "image/webp" => Some("webp"),
3119 _ => None,
3120 }
3121}
3122
3123pub fn valid_attachment_id(id: &str) -> bool {
3128 id.len() == 32
3129 && id
3130 .bytes()
3131 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
3132}
3133
3134fn new_attachment_id() -> String {
3138 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
3139 format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
3140}
3141
3142#[cfg(test)]
3143mod tests {
3144 #[test]
3145 fn turn_access_lifts_the_sandbox_only_for_the_talk_opt_in() {
3146 use super::turn_access;
3147 assert_eq!(turn_access(false, false), (false, false));
3148 assert_eq!(turn_access(true, false), (true, true));
3149 assert_eq!(turn_access(false, true), (true, false));
3151 assert_eq!(turn_access(true, true), (true, true));
3152 }
3153
3154 #[test]
3155 fn the_briefing_points_at_origin_main_not_the_working_tree() {
3156 let b = briefing(Path::new("/r"), "en", false);
3157 assert!(b.contains("origin/main"));
3158 assert!(b.contains("git show origin/main:"));
3159 }
3160 use std::collections::BTreeMap;
3161
3162 use crate::config::{AgentChoice, AgentKind, AgentSpec, Graph};
3163 use crate::queue::{Queue, Source, Task};
3164
3165 use super::*;
3166
3167 fn ctx_agent(id: &str, model: Option<&str>) -> AgentSpec {
3168 AgentSpec {
3169 id: id.to_owned(),
3170 kind: AgentKind::Command,
3171 model: model.map(str::to_owned),
3172 command: Vec::new(),
3173 extra_args: Vec::new(),
3174 env: BTreeMap::new(),
3175 prompt_delivery: None,
3176 }
3177 }
3178
3179 fn ctx_talk(agent: &str, turns: Vec<Turn>) -> Talk {
3180 Talk {
3181 posts_read_through: None,
3182 pending_breaks: None,
3183 pending_messages: Vec::new(),
3184 schema: SCHEMA,
3185 id: "20260904-014455-ab12".to_owned(),
3186 repo: PathBuf::from("."),
3187 agent: agent.to_owned(),
3188 status: TalkStatus::Open,
3189 turns,
3190 pending: String::new(),
3191 pending_attachments: Vec::new(),
3192 fallback: false,
3193 persona: String::new(),
3194 persona_dirty: false,
3195 implementers: 1,
3196 implementers_dirty: false,
3197 notified: Vec::new(),
3198 completion_notices: Vec::new(),
3199 created_at: Timestamp::now(),
3200 updated_at: Timestamp::now(),
3201 seat: SeatState::new(SEAT, agent, 1),
3202 }
3203 }
3204
3205 fn reply(body: &str, usage: Option<(u64, &str, Option<&str>)>) -> Turn {
3206 Turn {
3207 sender: None,
3208 breaks: None,
3209 messages: Vec::new(),
3210 posted_by: None,
3211 who: Who::Agent,
3212 body: body.to_owned(),
3213 at: Timestamp::now(),
3214 attachments: Vec::new(),
3215 usage: usage.map(|(t, a, m)| TurnUsage {
3216 context_tokens: t,
3217 agent: a.to_owned(),
3218 model: m.map(str::to_owned),
3219 }),
3220 }
3221 }
3222
3223 fn ctx_config(windows: &[(&str, u64)]) -> Config {
3224 Config {
3225 agents: vec![
3226 ctx_agent("small", Some("small-model")),
3227 ctx_agent("big", Some("big-model")),
3228 ctx_agent("plain", None),
3229 ],
3230 context_windows: windows.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect(),
3231 ..Config::default()
3232 }
3233 }
3234
3235 #[test]
3236 fn context_usage_computes_percent_and_warns_at_eighty() {
3237 let cfg = ctx_config(&[("small-model", 1000)]);
3238 let at = |tokens| {
3239 let t = ctx_talk(
3240 "small",
3241 vec![reply("hi", Some((tokens, "small", Some("small-model"))))],
3242 );
3243 context_usage(&t, Some(&cfg))
3244 };
3245 let u = at(799);
3246 assert_eq!((u.percent, u.warn, u.window), (Some(79), false, Some(1000)));
3247 let u = at(800);
3248 assert_eq!((u.percent, u.warn), (Some(80), true));
3249 let u = at(1500);
3250 assert_eq!((u.percent, u.warn), (Some(150), true));
3251 assert!(!u.since_switch);
3252 }
3253
3254 #[test]
3255 fn context_usage_is_unknown_without_usage_and_never_looks_back() {
3256 let cfg = ctx_config(&[("small-model", 1000)]);
3257 let t = ctx_talk(
3258 "small",
3259 vec![
3260 reply("old", Some((900, "small", Some("small-model")))),
3261 reply("new", None),
3262 ],
3263 );
3264 let u = context_usage(&t, Some(&cfg));
3265 assert!(u.estimated);
3267 assert_ne!(u.tokens, Some(900));
3268 assert!(u.tokens.is_some());
3269 let t = ctx_talk(
3271 "small",
3272 vec![
3273 reply("old", Some((900, "small", Some("small-model")))),
3274 reply("magi: could not run agent", None),
3275 ],
3276 );
3277 assert_eq!(context_usage(&t, Some(&cfg)).tokens, Some(900));
3278 assert_eq!(
3279 context_usage(&ctx_talk("small", Vec::new()), Some(&cfg)).tokens,
3280 None
3281 );
3282 }
3283
3284 #[test]
3285 fn estimate_counts_chars_both_sides_and_standing_prompt() {
3286 let mut t = ctx_talk("small", vec![reply("abcdefg", None)]);
3287 assert_eq!(estimate_context_tokens(&t, 0), Some(2)); let op = Turn {
3289 sender: None,
3290 who: Who::Operator,
3291 ..reply("abcdefg", None)
3292 };
3293 t.turns.push(op);
3294 assert_eq!(estimate_context_tokens(&t, 0), Some(4));
3295 assert!(
3296 estimate_context_tokens(&t, 700).unwrap() > estimate_context_tokens(&t, 0).unwrap()
3297 );
3298 let ja = ctx_talk("small", vec![reply("日本語日本語日", None)]);
3300 assert_eq!(estimate_context_tokens(&ja, 0), Some(2));
3301 let note = ctx_talk("small", vec![reply("magi: could not run agent", None)]);
3303 assert_eq!(estimate_context_tokens(¬e, 1000), None);
3304 assert_eq!(
3305 estimate_context_tokens(&ctx_talk("small", Vec::new()), 1000),
3306 None
3307 );
3308 }
3309
3310 #[test]
3311 fn context_usage_measured_wins_and_estimate_gets_percent_and_warn() {
3312 let cfg = ctx_config(&[("small-model", 1000)]);
3313 let t = ctx_talk(
3314 "small",
3315 vec![reply(
3316 &"x".repeat(5000),
3317 Some((10, "small", Some("small-model"))),
3318 )],
3319 );
3320 let u = context_usage(&t, Some(&cfg));
3321 assert_eq!((u.tokens, u.estimated), (Some(10), false));
3322 let t = ctx_talk("small", vec![reply(&"x".repeat(5000), None)]);
3323 let u = context_usage(&t, Some(&cfg));
3324 assert!(u.estimated && !u.since_switch);
3325 assert_eq!(u.window, Some(1000));
3326 assert!(u.warn && u.percent.unwrap() >= 80);
3327 let t = ctx_talk("small", vec![reply("hi", None)]);
3328 let u = context_usage(&t, Some(&cfg));
3329 assert!(u.estimated && u.percent.is_some());
3330 }
3331
3332 #[test]
3333 fn context_usage_without_a_window_shows_tokens_only() {
3334 let cfg = ctx_config(&[]);
3335 let t = ctx_talk("plain", vec![reply("hi", Some((5000, "plain", None)))]);
3337 let u = context_usage(&t, Some(&cfg));
3338 assert_eq!(
3339 (u.tokens, u.window, u.percent, u.warn),
3340 (Some(5000), None, None, false)
3341 );
3342 let t = ctx_talk(
3343 "small",
3344 vec![reply("hi", Some((5000, "small", Some("small-model"))))],
3345 );
3346 assert_eq!(context_usage(&t, Some(&cfg)).percent, None);
3347 assert_eq!(context_usage(&t, None).window, None);
3349 }
3350
3351 #[test]
3352 fn context_usage_switching_model_changes_the_denominator() {
3353 let cfg = ctx_config(&[("small-model", 1000), ("big-model", 10_000)]);
3354 let used = reply("hi", Some((900, "small", Some("small-model"))));
3355 let before = context_usage(&ctx_talk("small", vec![used.clone()]), Some(&cfg));
3356 assert_eq!(
3357 (before.percent, before.warn, before.since_switch),
3358 (Some(90), true, false)
3359 );
3360 let after = context_usage(&ctx_talk("big", vec![used]), Some(&cfg));
3363 assert_eq!(after.window, Some(10_000));
3364 assert_eq!(
3365 (after.percent, after.warn, after.since_switch),
3366 (Some(9), false, true)
3367 );
3368 assert_eq!(after.model.as_deref(), Some("big-model"));
3369 }
3370
3371 #[test]
3372 fn a_turn_recorded_before_usage_existed_still_reads() {
3373 let old = r#"{"who":"agent","body":"hi","at":"2026-09-04T01:44:55Z"}"#;
3374 let turn: Turn = serde_json::from_str(old).expect("old turn reads");
3375 assert!(turn.usage.is_none());
3376 let json = serde_json::to_string(&turn).expect("serialize");
3377 assert!(
3378 !json.contains("usage"),
3379 "absent usage is not written: {json}"
3380 );
3381 }
3382
3383 fn store() -> (tempfile::TempDir, Talks) {
3385 let tmp = tempfile::tempdir().expect("tempdir");
3386 let talks = Talks::at(tmp.path().join("talks"));
3387 (tmp, talks)
3388 }
3389
3390 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
3394 let path = dir.join("mock-talk-agent.sh");
3395 std::fs::write(&path, script).expect("write mock");
3396 AgentSpec {
3397 id: "mock".to_owned(),
3398 kind: AgentKind::Command,
3399 model: None,
3400 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
3401 extra_args: Vec::new(),
3402 env,
3403 prompt_delivery: None,
3404 }
3405 }
3406
3407 fn config(spec: AgentSpec) -> Config {
3408 Config {
3409 agents: vec![spec],
3410 graph: Graph {
3411 language: "en".to_owned(),
3412 ..Graph::default()
3413 },
3414 ..Config::default()
3415 }
3416 }
3417
3418 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
3420
3421 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
3423
3424 const ECHO: &str = "#!/bin/sh\ncat\n";
3427
3428 fn env(reply: &str) -> BTreeMap<String, String> {
3429 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
3430 }
3431
3432 #[test]
3433 fn the_frozen_json_field_names_round_trip_through_disk() {
3434 let (tmp, talks) = store();
3435 let mut talk = Talk {
3436 posts_read_through: None,
3437 pending_breaks: None,
3438 pending_messages: Vec::new(),
3439 schema: SCHEMA,
3440 id: "20260904-014455-ab12".to_owned(),
3441 repo: tmp.path().to_owned(),
3442 agent: "sonnet".to_owned(),
3443 status: TalkStatus::Open,
3444 turns: Vec::new(),
3445 pending: String::new(),
3446 pending_attachments: Vec::new(),
3447 fallback: false,
3448 persona: String::new(),
3449 persona_dirty: false,
3450 implementers: 1,
3451 implementers_dirty: false,
3452 notified: Vec::new(),
3453 completion_notices: Vec::new(),
3454 created_at: Timestamp::now(),
3455 updated_at: Timestamp::now(),
3456 seat: SeatState::new(SEAT, "sonnet", 7),
3457 };
3458 talks.put(&mut talk).expect("put");
3459
3460 let raw = std::fs::read_to_string(talks.path_of(&talk.id)).expect("read back");
3461 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
3462 for field in [
3463 "schema",
3464 "id",
3465 "repo",
3466 "agent",
3467 "status",
3468 "turns",
3469 "created_at",
3470 "updated_at",
3471 ] {
3472 assert!(v.get(field).is_some(), "missing field `{field}`");
3473 }
3474 assert_eq!(v["schema"], 1);
3475 assert_eq!(v["status"], "open");
3476
3477 let back = talks.get(&talk.id).expect("get");
3478 assert_eq!(back.posts_read_through, None);
3479 assert_eq!(back.id, talk.id);
3480 assert_eq!(back.status, TalkStatus::Open);
3481 }
3482
3483 #[test]
3484 fn opening_a_talk_takes_no_agent_turn() {
3485 let (tmp, talks) = store();
3486 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
3490 let cfg = config(spec);
3491
3492 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
3493 assert_eq!(talk.status, TalkStatus::Open);
3494 assert!(talk.turns.is_empty(), "nothing has been said yet");
3495
3496 let on_disk = talks.get(&talk.id).expect("get");
3497 assert_eq!(on_disk.turns.len(), 0);
3498 }
3499
3500 #[test]
3508 fn chatter_wins_when_set_and_falls_back_to_pick_s_default_order_otherwise() {
3509 let (tmp, talks) = store();
3510 let first_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
3511 let mut chatter_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
3512 chatter_spec.id = "chatter-mock".to_owned();
3513
3514 let mut cfg = Config {
3515 agents: vec![first_spec.clone(), chatter_spec.clone()],
3516 graph: Graph {
3517 language: "en".to_owned(),
3518 ..Graph::default()
3519 },
3520 ..Config::default()
3521 };
3522 cfg.roles.chatter = Some(chatter_spec.id.as_str().into());
3523
3524 let talk =
3525 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter set");
3526 assert_eq!(talk.agent, chatter_spec.id, "an explicit chatter must win");
3527
3528 cfg.roles.chatter = None;
3529 let fallback =
3530 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter unset");
3531 assert_eq!(
3532 fallback.agent, first_spec.id,
3533 "unset chatter must fall back to agent::pick's own default order"
3534 );
3535 }
3536
3537 #[test]
3540 fn a_talk_recorded_without_attachments_still_reads() {
3541 let (tmp, talks) = store();
3542 let path = talks.path_of("20260904-014455-ab12");
3543 std::fs::create_dir_all(talks.root()).expect("talks dir");
3544 std::fs::write(
3545 &path,
3546 serde_json::json!({
3547 "schema": 1,
3548 "id": "20260904-014455-ab12",
3549 "repo": tmp.path(),
3550 "agent": "sonnet",
3551 "status": "open",
3552 "turns": [
3553 { "who": "operator", "body": "still there?",
3554 "at": Timestamp::now().to_string() },
3555 ],
3556 "created_at": Timestamp::now().to_string(),
3557 "updated_at": Timestamp::now().to_string(),
3558 "seat": SeatState::new(SEAT, "sonnet", 7),
3559 })
3560 .to_string(),
3561 )
3562 .expect("write pre-attachments talk");
3563
3564 let talk = talks.get("20260904-014455-ab12").expect("must still read");
3565 assert!(talk.turns[0].attachments.is_empty());
3566 }
3567
3568 fn lease_store() -> (tempfile::TempDir, Talks, Talks) {
3569 let tmp = tempfile::TempDir::new().expect("tmp");
3570 let root = tmp.path().join("talks");
3571 (tmp, Talks::at(root.clone()), Talks::at(root))
3572 }
3573
3574 #[test]
3575 fn two_starters_on_one_talk_one_wins_and_the_other_is_refused() {
3576 let (_tmp, a, b) = lease_store();
3577 let won = a.claim_turn("t1").expect("claim").expect("first wins");
3578 assert!(
3579 b.claim_turn("t1").expect("claim").is_none(),
3580 "second is refused"
3581 );
3582 assert!(b.turn_held("t1"));
3583 assert!(
3584 b.claim_turn("t2").expect("claim").is_some(),
3585 "other talks are free"
3586 );
3587 drop(won);
3588 }
3589
3590 #[test]
3591 fn a_stale_lease_is_taken_over_and_the_old_guard_cannot_release_it() {
3592 let (_tmp, a, b) = lease_store();
3593 let old = a.claim_turn("t1").expect("claim").expect("held");
3594 let later = Timestamp::now()
3595 .checked_add(jiff::SignedDuration::from_secs(
3596 crate::ask::LEASE_TTL.as_secs() as i64 + 5,
3597 ))
3598 .expect("later");
3599 let new = b
3600 .claim_turn_at("t1", later)
3601 .expect("claim")
3602 .expect("a stale lease is taken over");
3603 drop(old);
3604 assert!(a.turn_held("t1"), "the old guard left the new lease alone");
3605 assert!(new.beat().expect("beat"), "the new owner still beats");
3606 drop(new);
3607 assert!(!a.turn_held("t1"));
3608 }
3609
3610 #[tokio::test]
3611 async fn a_turn_whose_lease_was_taken_over_is_stopped() {
3612 let (_tmp, a, b) = lease_store();
3613 let old = a.claim_turn("t1").expect("claim").expect("held");
3614 let later = Timestamp::now()
3615 .checked_add(jiff::SignedDuration::from_secs(
3616 crate::ask::LEASE_TTL.as_secs() as i64 + 5,
3617 ))
3618 .expect("later");
3619 let _new = b
3620 .claim_turn_at("t1", later)
3621 .expect("claim")
3622 .expect("taken over");
3623 let out = old
3624 .beating_every(Duration::from_millis(10), std::future::pending::<()>())
3625 .await;
3626 assert!(out.is_err(), "the displaced turn must stop, not run on");
3627 }
3628
3629 #[tokio::test]
3630 async fn a_turn_that_finishes_is_returned_and_keeps_its_lease_beating() {
3631 let (_tmp, a, _b) = lease_store();
3632 let lease = a.claim_turn("t1").expect("claim").expect("held");
3633 let out = lease
3634 .beating_every(Duration::from_millis(5), async {
3635 tokio::time::sleep(Duration::from_millis(40)).await;
3636 7
3637 })
3638 .await
3639 .expect("still ours");
3640 assert_eq!(out, 7);
3641 assert!(a.turn_held("t1"));
3642 }
3643
3644 #[test]
3645 fn an_unreadable_lease_counts_as_stale() {
3646 let (_tmp, a, b) = lease_store();
3647 std::fs::create_dir_all(a.root()).expect("dir");
3648 std::fs::write(a.turn_path("t1"), "not json").expect("write");
3649 assert!(!a.turn_held("t1"));
3650 assert!(b.claim_turn("t1").expect("claim").is_some());
3651 }
3652
3653 #[test]
3654 fn without_hard_links_a_claim_is_still_exclusive_and_a_young_placeholder_blocks() {
3655 let (_tmp, a, b) = lease_store();
3656 failpoint::force_no_link(true);
3657 let held = a.claim_turn("t1").expect("claim").expect("first wins");
3658 assert!(b.claim_turn("t1").expect("claim").is_none());
3659 assert!(a.turn_held("t1"));
3660 drop(held);
3661 assert!(!a.turn_held("t1"));
3662 let path = a.turn_path("t1");
3664 assert!(create_exclusive(&path, "").expect("placeholder"));
3665 assert!(b.claim_turn("t1").expect("claim").is_none(), "young: held");
3666 age_file(&path);
3667 assert!(b.claim_turn("t1").expect("claim").is_some(), "old: stale");
3668 let lease = a.turn_path("t2");
3670 let lock = TurnLock::take(&lease).expect("take").expect("lock");
3671 assert!(TurnLock::take(&lease).expect("take").is_none());
3672 drop(lock);
3673 assert!(TurnLock::take(&lease).expect("take").is_some());
3674 failpoint::force_no_link(false);
3675 }
3676
3677 #[test]
3678 fn remove_if_carries_removes_only_the_expected_content() {
3679 let (_tmp, a, _b) = lease_store();
3680 std::fs::create_dir_all(a.root()).expect("dir");
3681 let p = a.root().join("x.turn.lock");
3682 std::fs::write(&p, "mine").expect("write");
3683 assert!(!remove_if_carries(&p, "other"));
3684 assert_eq!(std::fs::read_to_string(&p).expect("kept"), "mine");
3685 assert!(remove_if_carries(&p, "mine"));
3686 assert!(!p.exists());
3687 assert!(!remove_if_carries(&p, "mine"), "absent is not a removal");
3688 }
3689
3690 #[test]
3691 fn a_dropped_lock_does_not_remove_a_lock_taken_over_since() {
3692 let (_tmp, a, _b) = lease_store();
3693 std::fs::create_dir_all(a.root()).expect("dir");
3694 let lease = a.turn_path("t1");
3695 let lock = TurnLock::take(&lease).expect("take").expect("lock");
3696 let path = lock.path.clone();
3697 std::fs::write(&path, "someone-else").expect("replace");
3698 drop(lock);
3699 assert_eq!(
3700 std::fs::read_to_string(&path).expect("kept"),
3701 "someone-else"
3702 );
3703 }
3704
3705 #[test]
3706 fn concurrent_takeovers_of_a_stale_lease_have_one_winner() {
3707 let (_tmp, a, _b) = lease_store();
3708 drop(a.claim_turn("t1").expect("claim").expect("held"));
3709 std::fs::write(
3710 a.turn_path("t1"),
3711 serde_json::to_string(&TurnRecord {
3712 token: "gone".into(),
3713 pid: 1,
3714 beat_at: Timestamp::from_second(1).expect("ts"),
3715 })
3716 .expect("json"),
3717 )
3718 .expect("write");
3719 let wins: Vec<_> = std::thread::scope(|sc| {
3720 let hs: Vec<_> = (0..8)
3721 .map(|_| {
3722 let s = a.clone();
3723 sc.spawn(move || s.claim_turn("t1").expect("claim"))
3724 })
3725 .collect();
3726 hs.into_iter().map(|h| h.join().expect("join")).collect()
3727 });
3728 assert_eq!(wins.iter().filter(|w| w.is_some()).count(), 1);
3729 }
3730
3731 fn age_file(path: &Path) {
3732 let f = std::fs::OpenOptions::new()
3733 .write(true)
3734 .open(path)
3735 .expect("open");
3736 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
3737 .expect("age");
3738 }
3739
3740 #[test]
3741 fn a_late_taker_of_a_broken_lock_cannot_disturb_its_replacement() {
3742 let (_tmp, a, _b) = lease_store();
3743 let lease = a.turn_path("t1");
3744 let lock = lease.with_extension("turn.lock");
3745 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3746 std::fs::write(&lock, "t1-dead").expect("dead lock");
3747 age_file(&lock);
3748 let b = TurnLock::take(&lease).expect("take").expect("b wins");
3750 let fresh = std::fs::read_to_string(&lock).expect("read");
3751 assert_eq!(fresh, b.token);
3752 let ticket = std::fs::read_dir(lock.parent().expect("dir"))
3755 .expect("dir")
3756 .flatten()
3757 .map(|e| e.path())
3758 .find(|p| p.to_string_lossy().ends_with(".break.0"))
3759 .expect("ticket");
3760 assert!(!create_exclusive(&ticket, "").expect("ticket"));
3761 assert!(TurnLock::take(&lease).expect("take").is_none());
3762 assert_eq!(std::fs::read_to_string(&lock).expect("read"), fresh);
3763 age_file(&lock);
3765 age_file(&ticket);
3766 std::mem::forget(b);
3767 let c = TurnLock::take(&lease)
3768 .expect("take")
3769 .expect("next generation");
3770 assert_ne!(c.token, fresh);
3771 }
3772
3773 #[test]
3774 fn a_live_ticket_blocks_and_a_stale_one_hands_over_to_the_next_generation() {
3775 let (_tmp, a, _b) = lease_store();
3776 let lease = a.turn_path("t1");
3777 let lock = lease.with_extension("turn.lock");
3778 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3779 std::fs::write(&lock, "t1-dead").expect("dead lock");
3780 age_file(&lock);
3781 let t0 = lock.with_extension("lock.t1-dead.break.0");
3782 assert!(create_exclusive(&t0, "").expect("ticket"));
3783 assert!(TurnLock::take(&lease).expect("take").is_none());
3785 assert_eq!(std::fs::read_to_string(&lock).expect("read"), "t1-dead");
3786 age_file(&t0);
3788 let c = TurnLock::take(&lease).expect("take").expect("generation 1");
3789 assert_eq!(std::fs::read_to_string(&lock).expect("read"), c.token);
3790 assert!(lock.with_extension("lock.t1-dead.break.1").exists());
3791 assert!(TurnLock::take(&lease).expect("take").is_none());
3792 }
3793
3794 #[test]
3795 fn exhausted_ticket_generations_recover_once_the_sweep_ages_them_out() {
3796 let (_tmp, a, _b) = lease_store();
3797 let lease = a.turn_path("t1");
3798 let lock = lease.with_extension("turn.lock");
3799 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3800 std::fs::write(&lock, "t1-dead").expect("dead lock");
3801 age_file(&lock);
3802 let tickets: Vec<_> = (0..TICKET_GENERATIONS)
3803 .map(|n| lock.with_extension(format!("lock.t1-dead.break.{n}")))
3804 .collect();
3805 for t in &tickets {
3806 assert!(create_exclusive(t, "").expect("ticket"));
3807 age_file(t);
3808 }
3809 assert!(TurnLock::take(&lease).expect("take").is_none());
3811 assert!(tickets.iter().all(|t| t.exists()));
3812 for t in &tickets {
3814 let f = std::fs::OpenOptions::new()
3815 .write(true)
3816 .open(t)
3817 .expect("open");
3818 f.set_modified(std::time::SystemTime::now() - TICKET_SWEEP_AGE * 2)
3819 .expect("age");
3820 }
3821 assert!(TurnLock::take(&lease).expect("take").is_none());
3822 assert!(TurnLock::take(&lease).expect("take").is_some());
3823 }
3824
3825 #[test]
3826 fn an_aged_empty_lock_is_broken() {
3827 let (_tmp, a, _b) = lease_store();
3828 let lease = a.turn_path("t1");
3829 let lock = lease.with_extension("turn.lock");
3830 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3831 std::fs::write(&lock, "").expect("empty lock");
3832 age_file(&lock);
3833 assert!(TurnLock::take(&lease).expect("take").is_some());
3834 }
3835
3836 #[test]
3837 fn reply_plain_text_sends_keep_provenance_and_boundaries() {
3838 let (tmp, talks) = store();
3839 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
3840 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
3841 record(&mut talk, &talks, "original", Vec::new()).expect("record");
3842 queue(&mut talk, &talks, "first", Vec::new()).expect("queue");
3843 queue(&mut talk, &talks, "second", Vec::new()).expect("queue");
3844 let wire: serde_json::Value =
3845 serde_json::from_str(&std::fs::read_to_string(talks.path_of(&talk.id)).unwrap())
3846 .unwrap();
3847 assert_eq!(wire["pending_messages"][0]["sender"], "operator");
3848 assert_eq!(wire["turns"][0]["messages"][0]["sender"], "operator");
3849 drain(&mut talk, &talks).expect("drain");
3850 let saved = talks.get(&talk.id).expect("reload");
3851 assert_eq!(saved.turns[1].body, "first\n\nsecond");
3852 assert_eq!(saved.turns[1].breaks, Some(vec![7]));
3853 let wire: serde_json::Value =
3854 serde_json::from_str(&std::fs::read_to_string(talks.path_of(&saved.id)).unwrap())
3855 .unwrap();
3856 for turn in wire["turns"].as_array().expect("turns") {
3858 for field in turn.as_object().expect("turn").keys() {
3859 assert!(
3860 [
3861 "who",
3862 "body",
3863 "at",
3864 "attachments",
3865 "usage",
3866 "breaks",
3867 "posted_by",
3868 "sender",
3869 "messages"
3870 ]
3871 .contains(&field.as_str()),
3872 "unknown turn field: {field}"
3873 );
3874 }
3875 }
3876 record_reply(
3877 &mut talk,
3878 &talks,
3879 "reply",
3880 Vec::new(),
3881 Some(ReplyTarget {
3882 turn: 1,
3883 message: 1,
3884 }),
3885 )
3886 .expect("reply to reloaded plain text");
3887 assert_eq!(
3888 talk.turns[2].messages[0].reply.as_ref().unwrap().excerpt,
3889 "second"
3890 );
3891 }
3892
3893 #[tokio::test]
3894 async fn reply_image_identity_reaches_fresh_and_followup_prompts_after_reload() {
3895 let (tmp, talks) = store();
3896 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
3897 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
3898 let first = talks
3899 .put_attachment(&talk.id, "image/png", "first.png", b"first")
3900 .expect("first");
3901 let second = talks
3902 .put_attachment(&talk.id, "image/png", "second.png", b"second")
3903 .expect("second");
3904 let first_path = talks.attachment_path(&talk.id, &first).unwrap();
3905 let second_path = talks.attachment_path(&talk.id, &second).unwrap();
3906 queue(&mut talk, &talks, "", vec![first]).expect("queue first");
3908 queue(&mut talk, &talks, "", vec![second]).expect("queue second");
3909 drain(&mut talk, &talks).expect("drain");
3910 let lease = talks.claim_turn(&talk.id).expect("claim").expect("lease");
3911 for (message, selected, other) in [
3912 (0, &first_path, &second_path),
3913 (1, &second_path, &first_path),
3914 ] {
3915 record_reply(
3916 &mut talk,
3917 &talks,
3918 "describe this",
3919 Vec::new(),
3920 Some(ReplyTarget { turn: 0, message }),
3921 )
3922 .expect("reply");
3923 talk = talks.get(&talk.id).expect("reload");
3924 let quote = message_prompt(talk.turns.last().unwrap(), &talk, &talks);
3925 assert!(quote.contains(selected.to_str().unwrap()));
3926 assert!(!quote.contains(other.to_str().unwrap()));
3927 respond(&lease, &mut talk, &talks, &cfg, "describe this")
3928 .await
3929 .expect("respond");
3930 assert!(
3931 talk.turns
3932 .last()
3933 .unwrap()
3934 .body
3935 .contains(&format!("Replied-to image: {}", selected.display()))
3936 );
3937 }
3938 }
3939
3940 #[test]
3941 fn reply_sends_survive_queue_edit_drain_reload_and_legacy_records() {
3942 let (tmp, talks) = store();
3943 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
3944 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
3945 record(&mut talk, &talks, &"猫".repeat(300), Vec::new()).expect("record");
3946 talk.turns[0].messages.clear();
3948 talks.put(&mut talk).expect("legacy save");
3949 let target = ReplyTarget {
3950 turn: 0,
3951 message: 0,
3952 };
3953 queue_reply(&mut talk, &talks, "first", Vec::new(), Some(target.clone())).expect("first");
3954 queue_reply(&mut talk, &talks, "second", Vec::new(), None).expect("second");
3955 assert!(
3956 edit_pending_message(&mut talk, &talks, "edited", "first\n\nsecond", &[], Some(0))
3957 .expect("edit")
3958 );
3959 drain(&mut talk, &talks).expect("drain");
3960 let saved = talks.get(&talk.id).expect("reload");
3961 assert_eq!(saved.turns[1].body, "edited\n\nsecond");
3962 assert_eq!(saved.turns[1].messages.len(), 2);
3963 let reply = saved.turns[1].messages[0].reply.as_ref().expect("reply");
3964 assert_eq!(reply.target, target);
3965 assert_eq!(reply.excerpt, "猫".repeat(240));
3966 assert!(message_prompt(&saved.turns[1], &saved, &talks).contains("[Reply to Operator:"));
3967 assert!(transcript(&saved, &talks).contains("[Reply to Operator:"));
3968 queue_reply(
3969 &mut talk,
3970 &talks,
3971 "third",
3972 Vec::new(),
3973 Some(ReplyTarget {
3974 turn: 1,
3975 message: 1,
3976 }),
3977 )
3978 .expect("specific send");
3979 assert_eq!(
3980 talk.pending_messages[0]
3981 .reply
3982 .as_ref()
3983 .expect("reply")
3984 .excerpt,
3985 "second"
3986 );
3987 clear_pending(&mut talk, &talks).expect("clear");
3988 assert!(talk.pending_messages.is_empty());
3989 assert!(
3990 record_reply(
3991 &mut talk,
3992 &talks,
3993 "invalid",
3994 Vec::new(),
3995 Some(ReplyTarget {
3996 turn: 1,
3997 message: 2
3998 })
3999 )
4000 .is_err()
4001 );
4002 assert_eq!(talks.get(&talk.id).expect("reload").turns.len(), 2);
4003 }
4004
4005 #[tokio::test]
4006 async fn reply_quotes_reach_fresh_and_followup_prompts_without_changing_body() {
4007 let (tmp, talks) = store();
4008 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4009 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4010 record(&mut talk, &talks, "original", Vec::new()).expect("record");
4011 let target = ReplyTarget {
4012 turn: 0,
4013 message: 0,
4014 };
4015 record_reply(
4016 &mut talk,
4017 &talks,
4018 "answer",
4019 Vec::new(),
4020 Some(target.clone()),
4021 )
4022 .expect("reply");
4023 let lease = talks.claim_turn(&talk.id).expect("claim").expect("lease");
4024 respond(&lease, &mut talk, &talks, &cfg, "answer")
4025 .await
4026 .expect("respond");
4027 assert!(
4028 talk.turns
4029 .last()
4030 .expect("response")
4031 .body
4032 .contains("[Reply to Operator: original]\nanswer")
4033 );
4034 assert_eq!(talk.turns[1].body, "answer");
4035 record_reply(&mut talk, &talks, "followup", Vec::new(), Some(target)).expect("reply");
4036 respond(&lease, &mut talk, &talks, &cfg, "followup")
4037 .await
4038 .expect("respond");
4039 assert!(
4040 talk.turns
4041 .last()
4042 .expect("response")
4043 .body
4044 .contains("[Reply to Operator: original]\nfollowup")
4045 );
4046 }
4047
4048 #[test]
4049 fn owner_indentation_survives_record_queue_and_edit() {
4050 let (tmp, talks) = store();
4051 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4052 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4053 for text in [" Post original", "\tUse rewrite"] {
4054 record_reply(&mut talk, &talks, text, Vec::new(), None).unwrap();
4055 assert_eq!(
4056 talks.get(&talk.id).unwrap().turns.last().unwrap().body,
4057 text
4058 );
4059 queue(&mut talk, &talks, "draft", Vec::new()).unwrap();
4060 assert!(edit_pending_message(&mut talk, &talks, text, "draft", &[], Some(0)).unwrap());
4061 let saved = talks.get(&talk.id).unwrap();
4062 assert_eq!(saved.pending, text);
4063 assert_eq!(saved.pending_messages[0].body, text);
4064 assert!(edit_pending_message(&mut talk, &talks, text, text, &[], None).unwrap());
4065 let saved = talks.get(&talk.id).unwrap();
4066 assert_eq!(saved.pending, text);
4067 assert_eq!(saved.pending_messages[0].body, text);
4068 assert_eq!(drain(&mut talk, &talks).unwrap().as_deref(), Some(text));
4069 }
4070 }
4071
4072 #[test]
4073 fn queued_text_is_durable_combined_and_drained_as_one_operator_turn() {
4074 let (tmp, talks) = store();
4075 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4076 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4077
4078 queue(&mut talk, &talks, "first", Vec::new()).expect("queue first");
4079 queue(&mut talk, &talks, "second", Vec::new()).expect("queue second");
4080 let saved = talks.get(&talk.id).expect("reload queued talk");
4081 assert_eq!(saved.pending, "first\n\nsecond");
4082 assert_eq!(saved.pending_breaks, Some(vec!["first\n\n".len()]));
4083 assert!(saved.turns.is_empty(), "a draft is not a transcript turn");
4084
4085 let drained = drain(&mut talk, &talks).expect("drain");
4086 assert_eq!(drained.as_deref(), Some("first\n\nsecond"));
4087 let saved = talks.get(&talk.id).expect("reload drained talk");
4088 assert!(saved.pending.is_empty());
4089 assert_eq!(saved.turns.len(), 1);
4090 assert_eq!(saved.turns[0].body, "first\n\nsecond");
4091 assert_eq!(saved.turns[0].breaks, Some(vec!["first\n\n".len()]));
4092 assert_eq!(saved.pending_breaks, None);
4093 }
4094
4095 #[test]
4096 fn editing_a_queued_draft_preserves_its_attachments_and_rejects_a_stale_snapshot() {
4097 let (tmp, talks) = store();
4098 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4099 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4100 let attachment = Attachment {
4101 id: "a".repeat(32),
4102 name: "shot.png".to_owned(),
4103 mime: "image/png".to_owned(),
4104 bytes: 3,
4105 };
4106
4107 queue(&mut talk, &talks, "first", vec![attachment.clone()]).expect("queue");
4108 assert!(
4109 edit_pending_text(
4110 &mut talk,
4111 &talks,
4112 "corrected",
4113 "first",
4114 std::slice::from_ref(&attachment.id),
4115 )
4116 .expect("edit")
4117 );
4118 let saved = talks.get(&talk.id).expect("reload edited draft");
4119 assert_eq!(saved.pending, "corrected");
4120 assert_eq!(saved.pending_attachments, vec![attachment]);
4121
4122 queue(&mut talk, &talks, "later", Vec::new()).expect("queue concurrent draft");
4123 assert!(
4124 !edit_pending_text(
4125 &mut talk,
4126 &talks,
4127 "stale edit",
4128 "corrected",
4129 &["a".repeat(32)],
4130 )
4131 .expect("stale edit is a conflict")
4132 );
4133 assert_eq!(
4134 talks.get(&talk.id).expect("reload after conflict").pending,
4135 "corrected\n\nlater"
4136 );
4137 assert!(
4138 !clear_pending_if_matches(&mut talk, &talks, "corrected", &["a".repeat(32)])
4139 .expect("stale clear is a conflict")
4140 );
4141 assert_eq!(
4142 talks
4143 .get(&talk.id)
4144 .expect("reload after stale clear")
4145 .pending,
4146 "corrected\n\nlater"
4147 );
4148 }
4149
4150 #[tokio::test]
4151 async fn a_reply_save_preserves_pending_accepted_while_the_cli_runs() {
4152 let (tmp, talks) = store();
4153 let slow = "#!/bin/sh\ncat >/dev/null\nsleep 0.1\nprintf reply\n";
4154 let cfg = config(mock_agent(tmp.path(), slow, BTreeMap::new()));
4155 let mut running = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4156 let id = running.id.clone();
4157 let first = record(&mut running, &talks, "first", Vec::new()).expect("record");
4158
4159 let response_talks = talks.clone();
4160 let response_cfg = cfg.clone();
4161 let reply = tokio::spawn(async move {
4162 respond(
4163 &response_talks.claim_turn(&running.id).unwrap().unwrap(),
4164 &mut running,
4165 &response_talks,
4166 &response_cfg,
4167 &first,
4168 )
4169 .await
4170 });
4171 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4172
4173 let mut queued = talks.get(&id).expect("queued handle");
4174 queue(&mut queued, &talks, "next", Vec::new()).expect("queue");
4175 reply.await.expect("join").expect("reply");
4176
4177 let saved = talks.get(&id).expect("reload");
4178 assert_eq!(saved.pending, "next");
4179 assert_eq!(saved.pending_breaks, Some(Vec::new()));
4180 assert_eq!(saved.turns.len(), 2, "operator message and reply remain");
4181 }
4182
4183 #[tokio::test]
4184 async fn posts_during_a_resumed_turn_are_replayed_once_even_after_a_reply_save_failure() {
4185 for lose_reply in [false, true] {
4186 let (tmp, talks) = store();
4187 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4188 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4189 say(
4190 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4191 &mut talk,
4192 &talks,
4193 &cfg,
4194 "establish the session",
4195 Vec::new(),
4196 )
4197 .await
4198 .expect("first turn");
4199 assert!(agent::has_session(
4200 AgentKind::Command,
4201 &talk.seat,
4202 cfg.graph.sessions
4203 ));
4204
4205 post(&talks, &talk.id, "already available", "task-earlier").expect("earlier post");
4206 talk = talks.get(&talk.id).expect("reload earlier post");
4207 let ready = tmp.path().join("ready");
4208 let release = tmp.path().join("release");
4209 let slow = "#!/bin/sh\ncat >/dev/null\ntouch \"$READY\"\nn=0\nwhile [ ! -f \"$RELEASE\" ] && [ \"$n\" -lt 200 ]; do sleep 0.01; n=$((n + 1)); done\nprintf reply\n";
4210 let slow_cfg = config(mock_agent(
4211 tmp.path(),
4212 slow,
4213 BTreeMap::from([
4214 ("READY".to_owned(), ready.to_string_lossy().into_owned()),
4215 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
4216 ]),
4217 ));
4218 let text = record(&mut talk, &talks, "keep going", Vec::new()).expect("record");
4219 let prompt_turns = talk.turns.len();
4220 let id = talk.id.clone();
4221 let response_talks = talks.clone();
4222 let running = tokio::spawn(async move {
4223 respond(
4224 &response_talks.claim_turn(&talk.id).unwrap().unwrap(),
4225 &mut talk,
4226 &response_talks,
4227 &slow_cfg,
4228 &text,
4229 )
4230 .await
4231 });
4232 tokio::time::timeout(std::time::Duration::from_secs(5), async {
4233 while !ready.exists() {
4234 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4235 }
4236 })
4237 .await
4238 .expect("CLI read its prompt");
4239 post(&talks, &id, "concurrent task result", "task-one").expect("post");
4240 post(&talks, &id, "second task result", "task-two").expect("second post");
4241 if lose_reply {
4242 failpoint::force_put_failures(PUT_RETRIES);
4243 }
4244 std::fs::write(release, "go").expect("release CLI");
4245 let result = running.await.expect("join");
4246 if lose_reply {
4247 assert!(
4248 result
4249 .unwrap_err()
4250 .to_string()
4251 .contains("could not be saved")
4252 );
4253 } else {
4254 result.expect("reply");
4255 }
4256
4257 let mut saved = talks.get(&id).expect("reload");
4258 assert_eq!(saved.posts_read_through, Some(prompt_turns));
4259 assert_eq!(saved.turns[prompt_turns].body, "concurrent task result");
4260 assert_eq!(saved.turns[prompt_turns + 1].body, "second task result");
4261 assert!(posts_since_last_reply(&saved).is_some());
4262 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4263 say(
4264 &talks.claim_turn(&id).unwrap().unwrap(),
4265 &mut saved,
4266 &talks,
4267 &cfg,
4268 "read the results",
4269 Vec::new(),
4270 )
4271 .await
4272 .expect("replay turn");
4273 let prompt = &saved.turns.last().unwrap().body;
4274 assert!(prompt.contains("# Posted by a task"), "{prompt}");
4275 assert!(!prompt.contains("already available"), "{prompt}");
4276 assert!(
4277 prompt
4278 .find("concurrent task result")
4279 .expect("first post in prompt")
4280 < prompt
4281 .find("second task result")
4282 .expect("second post in prompt")
4283 );
4284 let mut saved = talks.get(&id).expect("reload after replay");
4285 assert!(posts_since_last_reply(&saved).is_none());
4286 say(
4287 &talks.claim_turn(&id).unwrap().unwrap(),
4288 &mut saved,
4289 &talks,
4290 &cfg,
4291 "continue",
4292 Vec::new(),
4293 )
4294 .await
4295 .expect("next turn");
4296 assert!(!saved.turns.last().unwrap().body.contains("task result"));
4297 }
4298 }
4299
4300 fn counting_agent(dir: &Path, id: &str, body: &str) -> AgentSpec {
4303 let calls = dir.join(format!("{id}.calls"));
4304 let script = format!(
4305 "#!/bin/sh\necho x >> '{}'\n{body}\n",
4306 calls.to_string_lossy()
4307 );
4308 let path = dir.join(format!("mock-{id}.sh"));
4309 std::fs::write(&path, script).expect("write mock");
4310 AgentSpec {
4311 id: id.to_owned(),
4312 kind: AgentKind::Command,
4313 model: None,
4314 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
4315 extra_args: Vec::new(),
4316 env: BTreeMap::new(),
4317 prompt_delivery: None,
4318 }
4319 }
4320
4321 fn calls(dir: &Path, id: &str) -> usize {
4322 std::fs::read_to_string(dir.join(format!("{id}.calls"))).map_or(0, |s| s.lines().count())
4323 }
4324
4325 fn chain_config(specs: Vec<AgentSpec>, ids: &[&str]) -> Config {
4326 let mut cfg = config(specs[0].clone());
4327 cfg.agents = specs;
4328 cfg.roles.chatter = Some(AgentChoice::Chain(
4329 ids.iter().map(|s| (*s).to_owned()).collect(),
4330 ));
4331 cfg
4332 }
4333
4334 #[tokio::test]
4335 async fn completion_evidence_reaches_fresh_resumed_and_fallback_prompts() {
4336 for fallback in [false, true] {
4337 let (tmp, talks) = store();
4338 let cfg = if fallback {
4339 chain_config(
4340 vec![
4341 counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3"),
4342 counting_agent(tmp.path(), "b", "cat"),
4343 ],
4344 &["a", "b"],
4345 )
4346 } else {
4347 config(mock_agent(tmp.path(), ECHO, BTreeMap::new()))
4348 };
4349 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).unwrap();
4350 let lease = talks.claim_turn(&talk.id).unwrap().unwrap();
4351 for n in 0..2 {
4352 let text = format!(
4353 "# {}\n\n- Task: `unavailable-{n}`",
4354 crate::prompt::CHAT_TASK_NOTICE_HEADING
4355 );
4356 let reference = crate::chat_notify::CompletionReference {
4357 task: format!("unavailable-{n}"),
4358 run: None,
4359 notice: text.clone(),
4360 };
4361 let key = format!("unavailable-{n}:done");
4362 assert!(
4363 queue_once_matching(
4364 &mut talk,
4365 &talks,
4366 &text,
4367 &key,
4368 &|_| false,
4369 Some(&reference)
4370 )
4371 .unwrap()
4372 );
4373 assert!(
4374 !queue_once_matching(
4375 &mut talk,
4376 &talks,
4377 &text,
4378 &key,
4379 &|_| false,
4380 Some(&reference)
4381 )
4382 .unwrap()
4383 );
4384 talk = talks.get(&talk.id).unwrap();
4385 assert_eq!(talk.completion_notices.len(), n + 1);
4386 let text = drain(&mut talk, &talks).unwrap().unwrap();
4387 respond(&lease, &mut talk, &talks, &cfg, &text)
4388 .await
4389 .unwrap();
4390 let reply = &talk.turns.last().unwrap().body;
4391 assert!(reply.contains("# Verified outcome evidence"), "{reply}");
4392 assert!(reply.contains("Outcome evidence unavailable"));
4393 assert!(reply.contains("Do not file new tasks or edit repositories solely"));
4394 assert!(reply.contains("Explicitly say when no action remains"));
4395 }
4396 record(&mut talk, &talks, "ordinary follow-up", vec![]).unwrap();
4397 assert!(
4398 crate::chat_notify::response_evidence(&talk)
4399 .await
4400 .is_empty(),
4401 "history does not trigger lookups"
4402 );
4403 record(
4404 &mut talk,
4405 &talks,
4406 &format!("# {}\n\nlegacy", crate::prompt::CHAT_TASK_NOTICE_HEADING),
4407 vec![],
4408 )
4409 .unwrap();
4410 assert!(
4411 crate::chat_notify::response_evidence(&talk)
4412 .await
4413 .contains("Legacy notification has no structured task/run reference")
4414 );
4415 }
4416 }
4417
4418 #[tokio::test]
4419 async fn a_chatter_chain_falls_back_resends_the_transcript_and_sticks() {
4420 let (tmp, talks) = store();
4421 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4422 let b = counting_agent(tmp.path(), "b", "cat");
4423 let cfg = chain_config(vec![a, b], &["a", "b"]);
4424 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4425 assert_eq!(talk.agent, "a");
4426 post(&talks, &talk.id, "fallback task result", "task").expect("post");
4427 talk = talks.get(&talk.id).expect("reload");
4428
4429 say(
4430 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4431 &mut talk,
4432 &talks,
4433 &cfg,
4434 "hello there",
4435 Vec::new(),
4436 )
4437 .await
4438 .expect("turn");
4439 assert_eq!(calls(tmp.path(), "a"), 1, "each id is tried once");
4440 assert_eq!(calls(tmp.path(), "b"), 1);
4441 assert_eq!(talk.agent, "b", "the switch persists");
4442 assert!(talks.get(&talk.id).unwrap().agent == "b");
4443 let reply = talk.turns.last().unwrap();
4444 assert!(reply.body.contains("hello there"));
4445 assert!(reply.body.contains("fallback task result"));
4446 let saved = talks.get(&talk.id).expect("reload fallback");
4447 assert_eq!(saved.posts_read_through, Some(2));
4448 assert!(posts_since_last_reply(&saved).is_none());
4449 assert!(
4450 reply.body.contains("magi task add --solo"),
4451 "a fresh seat gets the full briefing"
4452 );
4453 assert!(
4454 talk.turns
4455 .iter()
4456 .any(|t| t.body.contains("agent changed from a to b")),
4457 "the switch is noted"
4458 );
4459 }
4460
4461 #[tokio::test]
4462 async fn an_exhausted_chatter_chain_fails_like_a_single_seat_and_stays_put() {
4463 let (tmp, talks) = store();
4464 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4465 let b = counting_agent(tmp.path(), "b", "cat >/dev/null\nexit 4");
4466 let cfg = chain_config(vec![a, b], &["a", "b", "a"]);
4467 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4468
4469 let err = say(
4470 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4471 &mut talk,
4472 &talks,
4473 &cfg,
4474 "hi",
4475 Vec::new(),
4476 )
4477 .await
4478 .expect_err("every agent failed");
4479 assert!(err.to_string().contains("`a`"), "{err:#}");
4480 assert_eq!(calls(tmp.path(), "a"), 1);
4481 assert_eq!(calls(tmp.path(), "b"), 1);
4482 assert_eq!(talk.agent, "a", "an exhausted chain leaves the agent alone");
4483 }
4484
4485 #[test]
4486 fn a_chatter_chain_skips_an_unknown_id_at_begin() {
4487 let (tmp, talks) = store();
4488 let b = counting_agent(tmp.path(), "b", "cat");
4489 let cfg = chain_config(vec![b], &["ghost", "b"]);
4490 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4491 assert_eq!(talk.agent, "b");
4492 }
4493
4494 #[tokio::test]
4495 async fn an_explicit_agent_inside_the_chatter_chain_stays_pinned() {
4496 let (tmp, talks) = store();
4497 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4498 let b = counting_agent(tmp.path(), "b", "cat");
4499 let cfg = chain_config(vec![a, b], &["a", "b"]);
4500 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), Some("a")).expect("begin");
4501 say(
4502 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4503 &mut talk,
4504 &talks,
4505 &cfg,
4506 "hi",
4507 Vec::new(),
4508 )
4509 .await
4510 .expect_err("a alone, and it fails");
4511 assert_eq!(calls(tmp.path(), "b"), 0);
4512 assert_eq!(talk.agent, "a");
4513 }
4514
4515 #[tokio::test]
4516 async fn an_explicit_agent_does_not_borrow_the_chatter_chain() {
4517 let (tmp, talks) = store();
4518 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4519 let b = counting_agent(tmp.path(), "b", "cat");
4520 let c = counting_agent(tmp.path(), "c", "cat >/dev/null\nexit 3");
4521 let cfg = chain_config(vec![a, b, c], &["a", "b"]);
4522 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), Some("c")).expect("begin");
4523 say(
4524 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4525 &mut talk,
4526 &talks,
4527 &cfg,
4528 "hi",
4529 Vec::new(),
4530 )
4531 .await
4532 .expect_err("c alone, and it fails");
4533 assert_eq!(calls(tmp.path(), "b"), 0);
4534 }
4535
4536 #[test]
4537 fn briefing_names_the_operator_with_or_without_a_persona() {
4538 let plain = briefing(Path::new("/repo"), "en", false);
4539 let named = briefing_with(Path::new("/repo"), "en", false, None, Some("Commander"));
4540 assert!(named.starts_with(&plain));
4541 assert!(named.contains("# Addressing the operator"));
4542 assert!(named.contains("\"Commander\""));
4543 let rei = crate::persona::builtin_catalog()
4544 .into_iter()
4545 .find(|p| p.id == "rei")
4546 .expect("rei");
4547 let with = briefing_with(
4548 Path::new("/repo"),
4549 "en",
4550 false,
4551 Some(&rei),
4552 Some("Commander"),
4553 );
4554 assert!(with.contains("\"Commander\""));
4555 assert!(!with.contains("# Addressing the operator"));
4556 }
4557
4558 #[test]
4559 fn briefing_carries_a_persona_section_only_when_one_is_chosen() {
4560 let plain = briefing(Path::new("/repo"), "en", false);
4561 assert_eq!(
4562 plain,
4563 briefing_with(Path::new("/repo"), "en", false, None, None)
4564 );
4565 assert!(!plain.contains("Persona"));
4566 let rei = crate::persona::builtin_catalog()
4567 .into_iter()
4568 .find(|p| p.id == "rei")
4569 .expect("rei");
4570 let with = briefing_with(Path::new("/repo"), "en", false, Some(&rei), None);
4571 assert!(with.starts_with(&plain), "the plain briefing is untouched");
4572 assert!(with.contains("# Persona (tone only)"));
4573 assert!(with.contains("TONE ONLY"));
4574 assert!(with.contains("task ids"));
4575 assert!(with.contains("write policy"));
4576 assert!(with.contains("`magi task add`"));
4577 assert!(with.contains("Rei Ayanami"));
4578 }
4579
4580 #[test]
4581 fn a_talk_written_before_personas_still_loads() {
4582 let (tmp, talks) = store();
4583 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4584 let cfg = config(spec);
4585 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4586 let path = talks.root.join(format!("{}.json", talk.id));
4587 let mut v: serde_json::Value =
4588 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4589 v.as_object_mut().unwrap().remove("persona");
4590 v.as_object_mut().unwrap().remove("persona_dirty");
4591 std::fs::write(&path, v.to_string()).unwrap();
4592 let loaded = talks.get(&talk.id).expect("old record loads");
4593 assert_eq!(loaded.persona, "");
4594 assert!(!loaded.persona_dirty);
4595 }
4596
4597 #[test]
4598 fn the_briefing_files_with_solo_or_the_chosen_implementer_count() {
4599 let repo = Path::new("/repo");
4600 let solo = briefing_for(repo, "", false, None, None, 1);
4601 assert_eq!(solo, briefing(repo, "", false));
4602 assert!(solo.contains("magi task add --solo --repo"));
4603 assert!(solo.contains("magi task add --solo --attach"));
4604 for n in [2u8, 3] {
4605 let b = briefing_for(repo, "", false, None, None, n);
4606 assert!(
4607 b.contains(&format!("magi task add --implementers {n} --repo")),
4608 "{b}"
4609 );
4610 assert!(b.contains(&format!("magi task add --implementers {n} --attach")));
4611 assert!(!b.contains("task add --solo"), "{b}");
4612 assert!(!b.contains("Use --solo"), "{b}");
4613 }
4614 }
4615
4616 #[test]
4617 fn begin_with_applies_remembered_choices_and_drops_unfit_ones_alone() {
4618 let (tmp, talks) = store();
4619 let first = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4620 let mut second = first.clone();
4621 second.id = "second".to_owned();
4622 let mut cfg = config(first.clone());
4623 cfg.agents.push(second.clone());
4624 let open = |p: &Preferred| {
4625 begin_with(&talks, &cfg, tmp.path().to_owned(), None, p).expect("begin")
4626 };
4627
4628 let talk = open(&Preferred {
4629 agent: Some("second".into()),
4630 persona: Some("rei".into()),
4631 implementers: Some(i64::from(check_implementers(3, &cfg).unwrap_or(1))),
4632 });
4633 assert_eq!(talk.agent, "second");
4634 assert!(
4635 !talk.fallback,
4636 "a remembered non-head agent is an explicit choice"
4637 );
4638 assert_eq!(talk.persona, "rei");
4639 assert_eq!(talk.implementers, check_implementers(3, &cfg).unwrap_or(1));
4640 assert!(!talk.persona_dirty && !talk.implementers_dirty);
4641
4642 let head = open(&Preferred {
4644 agent: Some(first.id.clone()),
4645 ..Preferred::default()
4646 });
4647 assert!(head.fallback);
4648
4649 for implementers in [0, 4, -1, 1000] {
4651 let t = open(&Preferred {
4652 agent: Some("gone".into()),
4653 persona: Some("no-such-persona".into()),
4654 implementers: Some(implementers),
4655 });
4656 assert_eq!(t.agent, first.id);
4657 assert!(t.fallback);
4658 assert_eq!(t.persona, "");
4659 assert_eq!(t.implementers, 1);
4660 }
4661
4662 let t = open(&Preferred {
4664 agent: Some("second".into()),
4665 persona: Some("default".into()),
4666 implementers: Some(9),
4667 });
4668 assert_eq!(
4669 (t.agent.as_str(), t.persona.as_str(), t.implementers),
4670 ("second", "", 1)
4671 );
4672
4673 let strict = begin_with(
4675 &talks,
4676 &cfg,
4677 tmp.path().to_owned(),
4678 Some("first-unknown"),
4679 &Preferred {
4680 agent: Some("second".into()),
4681 ..Preferred::default()
4682 },
4683 );
4684 assert!(strict.is_err());
4685 }
4686
4687 #[test]
4688 fn remembered_agent_can_open_when_the_chatter_chain_cannot_resolve() {
4689 let (tmp, talks) = store();
4690 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4691 let cfg = chain_config(vec![spec.clone()], &["missing-chatter"]);
4694 assert!(begin(&talks, &cfg, tmp.path().to_owned(), None).is_err());
4695 let talk = begin_with(
4696 &talks,
4697 &cfg,
4698 tmp.path().to_owned(),
4699 None,
4700 &Preferred {
4701 agent: Some(spec.id.clone()),
4702 ..Preferred::default()
4703 },
4704 )
4705 .expect("a valid remembered agent does not require a default chain");
4706 assert_eq!(talk.agent, spec.id);
4707 assert!(!talk.fallback);
4708 assert_eq!(talks.get(&talk.id).unwrap().agent, spec.id);
4709 assert!(
4710 begin_with(
4711 &talks,
4712 &cfg,
4713 tmp.path().to_owned(),
4714 None,
4715 &Preferred {
4716 agent: Some("missing-preference".into()),
4717 ..Preferred::default()
4718 },
4719 )
4720 .is_err()
4721 );
4722 }
4723
4724 #[test]
4725 fn implementers_are_validated_and_old_records_load_as_solo() {
4726 let (tmp, talks) = store();
4727 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4728 assert_eq!(check_implementers(1, &cfg), Ok(1));
4729 assert_eq!(check_implementers(3, &cfg), Ok(3));
4730 assert!(check_implementers(0, &cfg).is_err());
4731 assert!(check_implementers(4, &cfg).is_err());
4732 let mut empty = cfg.clone();
4733 empty.agents.clear();
4734 assert!(check_implementers(2, &empty).is_err());
4735
4736 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4737 let path = talks.path_of(&talk.id);
4738 let mut v: serde_json::Value =
4739 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4740 v.as_object_mut().unwrap().remove("implementers");
4741 v.as_object_mut().unwrap().remove("implementers_dirty");
4742 std::fs::write(&path, v.to_string()).unwrap();
4743 let loaded = talks.get(&talk.id).expect("load");
4744 assert_eq!(loaded.implementers, 1);
4745 assert!(!loaded.implementers_dirty);
4746 }
4747
4748 #[tokio::test]
4749 async fn an_implementers_switch_is_noted_once_and_kept_across_a_failed_turn() {
4750 let (tmp, talks) = store();
4751 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4752 let cfg = config(spec);
4753 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4754 let lease = talks.claim_turn(&talk.id).unwrap().unwrap();
4755 say(&lease, &mut talk, &talks, &cfg, "hello", Vec::new())
4756 .await
4757 .expect("first turn");
4758
4759 assert!(switch_implementers(&mut talk, &talks, 2).expect("switch"));
4760 assert!(!switch_implementers(&mut talk, &talks, 2).expect("same"));
4761 assert!(talk.implementers_dirty);
4762 say(&lease, &mut talk, &talks, &cfg, "next", Vec::new())
4763 .await
4764 .expect("turn");
4765 let prompt = &talk.turns.last().unwrap().body;
4766 assert!(prompt.contains("# Task filing update"), "{prompt}");
4767 assert!(prompt.contains("--implementers 2"));
4768 assert!(!talk.implementers_dirty, "cleared after a successful turn");
4769
4770 say(&lease, &mut talk, &talks, &cfg, "again", Vec::new())
4771 .await
4772 .expect("turn");
4773 assert!(
4774 !talk
4775 .turns
4776 .last()
4777 .unwrap()
4778 .body
4779 .contains("# Task filing update")
4780 );
4781
4782 let mut no_sessions = cfg.clone();
4784 no_sessions.graph.sessions = false;
4785 say(&lease, &mut talk, &talks, &no_sessions, "one", Vec::new())
4786 .await
4787 .expect("turn");
4788 say(&lease, &mut talk, &talks, &no_sessions, "two", Vec::new())
4789 .await
4790 .expect("turn");
4791 assert!(talk.turns.last().unwrap().body.contains("--implementers 2"));
4792 }
4793
4794 #[tokio::test]
4795 async fn a_persona_switch_notes_marks_dirty_and_updates_the_next_turn_once() {
4796 let (tmp, talks) = store();
4797 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4798 let cfg = config(spec);
4799 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4800 let lease = talks.claim_turn(&talk.id).unwrap().unwrap();
4801 say(&lease, &mut talk, &talks, &cfg, "hello", Vec::new())
4802 .await
4803 .expect("first turn");
4804 assert!(!talk.turns[1].body.contains("Persona"));
4805
4806 assert!(switch_persona(&mut talk, &talks, "misato").expect("switch"));
4807 assert!(!switch_persona(&mut talk, &talks, "misato").expect("same"));
4808 assert!(talk.persona_dirty);
4809 assert!(talk.turns.last().unwrap().body.contains("persona changed"));
4810
4811 say(&lease, &mut talk, &talks, &cfg, "next", Vec::new())
4812 .await
4813 .expect("turn");
4814 let prompt = &talk.turns.last().unwrap().body;
4815 assert!(prompt.contains("# Persona update"), "{prompt}");
4816 assert!(prompt.contains("Misato Katsuragi"));
4817 assert!(!talk.persona_dirty, "cleared after a successful turn");
4818
4819 say(&lease, &mut talk, &talks, &cfg, "again", Vec::new())
4820 .await
4821 .expect("turn");
4822 assert!(!talk.turns.last().unwrap().body.contains("# Persona update"));
4823
4824 assert!(switch_persona(&mut talk, &talks, "default").expect("back"));
4825 assert_eq!(talk.persona, "");
4826 say(&lease, &mut talk, &talks, &cfg, "plain", Vec::new())
4827 .await
4828 .expect("turn");
4829 assert!(
4830 talk.turns
4831 .last()
4832 .unwrap()
4833 .body
4834 .contains("turned the persona off")
4835 );
4836
4837 assert!(switch_persona(&mut talk, &talks, "rei").expect("rei"));
4839 let mut no_sessions = cfg.clone();
4840 no_sessions.graph.sessions = false;
4841 say(&lease, &mut talk, &talks, &no_sessions, "one", Vec::new())
4842 .await
4843 .expect("turn");
4844 say(&lease, &mut talk, &talks, &no_sessions, "two", Vec::new())
4845 .await
4846 .expect("turn");
4847 let last = &talk.turns.last().unwrap().body;
4848 assert!(!talk.persona_dirty);
4849 assert!(last.contains("# Persona (tone only)"), "{last}");
4850 assert!(last.contains("Rei Ayanami"));
4851 }
4852
4853 #[tokio::test]
4854 async fn the_first_turn_carries_the_briefing_and_later_turns_do_not() {
4855 let (tmp, talks) = store();
4856 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4857 let cfg = config(spec);
4858 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4859
4860 say(
4861 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4862 &mut talk,
4863 &talks,
4864 &cfg,
4865 "what does the queue module do?",
4866 Vec::new(),
4867 )
4868 .await
4869 .expect("first turn");
4870 let first_prompt = &talk.turns[1].body;
4871 assert!(first_prompt.contains("magi task add --solo"));
4872 assert!(first_prompt.contains("what does the queue module do?"));
4873
4874 say(
4875 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4876 &mut talk,
4877 &talks,
4878 &cfg,
4879 "and how is it locked?",
4880 Vec::new(),
4881 )
4882 .await
4883 .expect("second turn");
4884 let second_prompt = &talk.turns[3].body;
4885 assert!(
4886 !second_prompt.contains("magi task add --solo"),
4887 "the briefing is sent once, not on every turn: {second_prompt}"
4888 );
4889 assert!(second_prompt.contains("and how is it locked?"));
4890 }
4891
4892 #[tokio::test]
4893 async fn switching_agent_resets_the_seat_notes_it_and_resends_the_transcript() {
4894 let (tmp, talks) = store();
4895 let a = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4896 let mut b = a.clone();
4897 b.id = "other".to_owned();
4898 let mut cfg = config(a.clone());
4899 cfg.agents.push(b.clone());
4900 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), Some(&a.id)).expect("begin");
4901 say(
4902 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4903 &mut talk,
4904 &talks,
4905 &cfg,
4906 "remember the walrus",
4907 Vec::new(),
4908 )
4909 .await
4910 .expect("first turn");
4911 let old_session = talk.seat.claude_session.clone();
4912 assert_eq!(talk.seat.turns, 1);
4913
4914 assert!(switch_agent(&mut talk, &talks, &b).expect("switch"));
4915 assert_eq!(talk.agent, "other");
4916 assert_eq!(talk.seat.turns, 0);
4917 assert_eq!(talk.seat.agent, "other");
4918 assert_ne!(talk.seat.claude_session, old_session);
4919 let note = talk.turns.last().expect("note");
4920 assert_eq!(note.who, Who::Agent);
4921 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
4922 assert!(note.body.contains("changed from"), "{}", note.body);
4923 assert_eq!(talks.get(&talk.id).expect("reload").agent, "other");
4924
4925 let before = talk.turns.len();
4926 assert!(!switch_agent(&mut talk, &talks, &b).expect("same agent"));
4927 assert_eq!(talk.turns.len(), before, "a no-op writes no note");
4928
4929 say(
4930 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4931 &mut talk,
4932 &talks,
4933 &cfg,
4934 "what did I say?",
4935 Vec::new(),
4936 )
4937 .await
4938 .expect("turn after switch");
4939 let prompt = &talk.turns.last().expect("reply").body;
4940 assert!(prompt.contains("remember the walrus"), "{prompt}");
4941 assert!(prompt.contains("## magi"), "{prompt}");
4942 assert!(prompt.contains("what did I say?"), "{prompt}");
4943 }
4944
4945 #[tokio::test]
4946 async fn say_appends_the_operator_turn_then_the_agent_turn() {
4947 let (tmp, talks) = store();
4948 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
4949 let cfg = config(spec);
4950 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4951
4952 say(
4953 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4954 &mut talk,
4955 &talks,
4956 &cfg,
4957 "can I rename this function?",
4958 Vec::new(),
4959 )
4960 .await
4961 .expect("say");
4962
4963 assert_eq!(talk.turns.len(), 2);
4964 assert_eq!(talk.turns[0].who, Who::Operator);
4965 assert_eq!(talk.turns[0].body, "can I rename this function?");
4966 assert_eq!(talk.turns[1].who, Who::Agent);
4967 assert_eq!(talk.turns[1].body, "go ahead");
4968 assert_eq!(talks.get(&talk.id).expect("get").turns, talk.turns);
4969 }
4970
4971 #[tokio::test]
4972 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
4973 let (tmp, talks) = store();
4974 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
4975 let cfg = config(spec);
4976 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4977
4978 talk.posts_read_through = None;
4979 talks.put(&mut talk).expect("save old-format boundary");
4980 post(&talks, &talk.id, "unread task result", "task").expect("post");
4981 talk = talks.get(&talk.id).expect("reload");
4982
4983 let err = say(
4984 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4985 &mut talk,
4986 &talks,
4987 &cfg,
4988 "check the tests",
4989 Vec::new(),
4990 )
4991 .await
4992 .expect_err("a turn with no answer is an error");
4993 assert!(err.to_string().contains("no answer"), "{err}");
4994
4995 let on_disk = talks.get(&talk.id).expect("get");
4996 assert_eq!(on_disk.turns.len(), 3);
4997 assert_eq!(on_disk.posts_read_through, Some(0));
4998 assert!(
4999 posts_since_last_reply(&on_disk)
5000 .unwrap()
5001 .contains("unread task result")
5002 );
5003 assert_eq!(on_disk.turns[1].body, "check the tests");
5004 let note = &on_disk.turns[2];
5005 assert_eq!(note.who, Who::Agent);
5006 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
5007 assert!(note.body.contains("your message is saved"));
5008 }
5009
5010 #[tokio::test]
5016 async fn a_passing_write_failure_while_saving_the_reply_does_not_lose_it() {
5017 let (tmp, talks) = store();
5018 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5019 let cfg = config(spec);
5020 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5021
5022 let text =
5023 record(&mut talk, &talks, "can I rename this function?", Vec::new()).expect("record");
5024 failpoint::force_put_failures(PUT_RETRIES - 1);
5027 respond(
5028 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5029 &mut talk,
5030 &talks,
5031 &cfg,
5032 &text,
5033 )
5034 .await
5035 .expect("respond must survive a write failure its own retries can outlast");
5036
5037 assert_eq!(talk.turns.len(), 2);
5038 assert_eq!(talk.turns[1].who, Who::Agent);
5039 assert_eq!(talk.turns[1].body, "go ahead");
5040 let on_disk = talks.get(&talk.id).expect("get");
5041 assert_eq!(
5042 on_disk.turns, talk.turns,
5043 "the reply must reach disk despite the early write failures"
5044 );
5045 }
5046
5047 #[tokio::test]
5053 async fn a_persistent_write_failure_while_saving_the_reply_is_never_silent() {
5054 let (tmp, talks) = store();
5055 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5056 let cfg = config(spec);
5057 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5058
5059 let text = record(&mut talk, &talks, "check the tests", Vec::new()).expect("record");
5060 failpoint::force_put_failures(PUT_RETRIES);
5065 let err = respond(
5066 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5067 &mut talk,
5068 &talks,
5069 &cfg,
5070 &text,
5071 )
5072 .await
5073 .expect_err("a reply that cannot be saved must be reported, not swallowed");
5074 assert!(err.to_string().contains("could not be saved"), "{err}");
5075
5076 let on_disk = talks.get(&talk.id).expect("get");
5077 assert_eq!(
5078 on_disk.turns.len(),
5079 2,
5080 "the operator turn plus a visible note"
5081 );
5082 assert_eq!(on_disk.turns[0].body, "check the tests");
5083 let note = &on_disk.turns[1];
5084 assert_eq!(note.who, Who::Agent);
5085 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
5086 assert!(
5087 note.body.contains("could not be saved"),
5088 "the operator must be told the reply is missing, not left staring \
5089 at a gap with no explanation: {}",
5090 note.body
5091 );
5092 assert_eq!(
5093 talk.turns, on_disk.turns,
5094 "the in-memory talk must match what actually landed on disk"
5095 );
5096
5097 let artifacts = talks.artifacts_of(&talk.id);
5100 let stash = std::fs::read_dir(&artifacts)
5101 .expect("artifacts dir")
5102 .filter_map(|e| e.ok())
5103 .find(|e| e.file_name().to_string_lossy().ends_with("-lost.txt"))
5104 .expect("a stash file for the lost reply");
5105 let stashed = std::fs::read_to_string(stash.path()).expect("read stash");
5106 assert_eq!(stashed, "go ahead");
5107
5108 assert_eq!(
5116 on_disk.seat.turns, 1,
5117 "the note's write must carry the turn the CLI actually took"
5118 );
5119 assert_eq!(
5120 on_disk.seat.claude_session, talk.seat.claude_session,
5121 "the session id handed to the CLI must survive the failed reply"
5122 );
5123 assert_eq!(on_disk.seat.captured_session, talk.seat.captured_session);
5124 assert!(
5125 agent::has_session(AgentKind::Command, &on_disk.seat, cfg.graph.sessions),
5126 "the next turn must resume, not open the same session id twice"
5127 );
5128 }
5129
5130 #[tokio::test]
5135 async fn a_write_failure_that_also_loses_the_note_still_reports_it() {
5136 let (tmp, talks) = store();
5137 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5138 let cfg = config(spec);
5139 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5140
5141 let text = record(&mut talk, &talks, "check the tests", Vec::new()).expect("record");
5142 failpoint::force_put_failures(PUT_RETRIES * 2);
5145 let err = respond(
5146 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5147 &mut talk,
5148 &talks,
5149 &cfg,
5150 &text,
5151 )
5152 .await
5153 .expect_err("neither the reply nor the note could be saved");
5154 assert!(err.to_string().contains("could not be saved"), "{err}");
5155
5156 assert_eq!(talk.turns.len(), 1, "only the operator's own turn");
5157 let on_disk = talks.get(&talk.id).expect("get");
5158 assert_eq!(on_disk.turns.len(), 1);
5159
5160 assert_eq!(
5170 on_disk.seat.turns, 0,
5171 "an unwritable file cannot record the turn the CLI took"
5172 );
5173 assert_eq!(
5174 talk.seat.turns, 1,
5175 "the in-memory seat still reports the turn the CLI actually took"
5176 );
5177 assert_eq!(
5178 on_disk.seat.claude_session, talk.seat.claude_session,
5179 "the session id was minted at `begin` and never changes here"
5180 );
5181 }
5182
5183 #[tokio::test]
5187 async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
5188 let (tmp, talks) = store();
5189 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
5190 let cfg = config(spec);
5191 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5192
5193 let att = talks
5194 .put_attachment(
5195 &talk.id,
5196 "image/png",
5197 "screenshot.png",
5198 b"pretend-png-bytes",
5199 )
5200 .expect("put attachment");
5201
5202 say(
5203 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5204 &mut talk,
5205 &talks,
5206 &cfg,
5207 "",
5208 vec![att.clone()],
5209 )
5210 .await
5211 .expect("an empty body with an attachment is still a turn");
5212
5213 let operator_turn = &talk.turns[0];
5214 assert_eq!(operator_turn.who, Who::Operator);
5215 assert_eq!(operator_turn.body, "");
5216 assert_eq!(operator_turn.attachments, vec![att.clone()]);
5217
5218 let prompt = &talk.turns[1].body;
5219 let expected_path = talks
5220 .attachments_dir(&talk.id)
5221 .join(format!("{}.png", att.id));
5222 assert!(
5223 prompt.contains(&expected_path.display().to_string()),
5224 "the agent must be told the attachment's absolute path: {prompt}"
5225 );
5226 assert!(prompt.contains("image/png"), "and its mime: {prompt}");
5227 }
5228
5229 #[test]
5238 fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
5239 let talks = Talks::at(PathBuf::from("relative-talks-root-for-this-test"));
5240 let att = Attachment {
5241 id: "0".repeat(32),
5242 name: "shot.png".to_owned(),
5243 mime: "image/png".to_owned(),
5244 bytes: 3,
5245 };
5246 let path = talks
5247 .attachment_path("some-talk-id", &att)
5248 .expect("a supported mime always yields a path");
5249 assert!(
5250 path.is_absolute(),
5251 "must be absolute even off a relative store root: {}",
5252 path.display()
5253 );
5254 }
5255
5256 #[tokio::test]
5257 async fn a_turn_past_the_configured_talk_timeout_is_reported_with_that_timeout() {
5258 let (tmp, talks) = store();
5263 let slow = mock_agent(
5264 tmp.path(),
5265 "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
5266 BTreeMap::new(),
5267 );
5268 let mut cfg = config(slow);
5269 cfg.graph.timeout_talk = 1;
5270 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5271
5272 let err = say(
5273 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5274 &mut talk,
5275 &talks,
5276 &cfg,
5277 "check the tests",
5278 Vec::new(),
5279 )
5280 .await
5281 .expect_err("a turn that never answers is an error");
5282 assert!(
5283 err.to_string().contains("did not answer within 1s"),
5284 "{err}"
5285 );
5286
5287 let on_disk = talks.get(&talk.id).expect("get");
5288 let note = on_disk.turns.last().expect("a note turn was recorded");
5289 assert!(
5290 note.body.contains("did not answer within 1s"),
5291 "the transcript must show the configured timeout: {}",
5292 note.body
5293 );
5294 }
5295
5296 #[test]
5297 fn closing_is_idempotent_and_a_closed_talk_takes_no_more_turns() {
5298 let (tmp, talks) = store();
5299 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5300 let cfg = config(spec);
5301 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5302
5303 close(&mut talk, &talks).expect("close");
5304 assert_eq!(talk.status, TalkStatus::Closed);
5305 close(&mut talk, &talks).expect("closing twice is not an error");
5306
5307 let err =
5308 record(&mut talk, &talks, "still there?", Vec::new()).expect_err("closed talks refuse");
5309 assert!(err.to_string().contains("closed"));
5310 let _ = &cfg; }
5312
5313 #[tokio::test]
5314 async fn a_close_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
5315 let (tmp, talks) = store();
5316 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
5317 let cfg = config(spec);
5318 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5321
5322 let mut closed_elsewhere = talks.get(&in_flight.id).expect("reread");
5326 close(&mut closed_elsewhere, &talks).expect("close");
5327 assert_eq!(
5328 talks.get(&in_flight.id).expect("reread").status,
5329 TalkStatus::Closed,
5330 "the close landed on disk before the turn finished"
5331 );
5332
5333 assert_eq!(in_flight.status, TalkStatus::Open);
5337 respond(
5338 &talks.claim_turn(&in_flight.id).unwrap().unwrap(),
5339 &mut in_flight,
5340 &talks,
5341 &cfg,
5342 "one more question",
5343 )
5344 .await
5345 .expect("the turn itself still completes");
5346
5347 let on_disk = talks.get(&in_flight.id).expect("reread");
5348 assert_eq!(
5349 on_disk.status,
5350 TalkStatus::Closed,
5351 "a close must stick even when a turn that started before it finishes after it"
5352 );
5353 assert!(
5356 on_disk.turns.iter().any(|t| t.body == "here you go"),
5357 "the in-flight turn's own reply is still recorded: {:?}",
5358 on_disk.turns
5359 );
5360 }
5361
5362 #[test]
5363 fn a_close_that_lands_before_record_is_called_is_not_undone_by_it() {
5364 let (tmp, talks) = store();
5365 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5366 let cfg = config(spec);
5367 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5370
5371 let mut closed_elsewhere = talks.get(&stale.id).expect("reread");
5374 close(&mut closed_elsewhere, &talks).expect("close");
5375 assert_eq!(
5376 talks.get(&stale.id).expect("reread").status,
5377 TalkStatus::Closed,
5378 "the close landed on disk before record was called"
5379 );
5380
5381 assert_eq!(stale.status, TalkStatus::Open);
5385 let err = record(&mut stale, &talks, "still there?", Vec::new())
5386 .expect_err("a close that landed first must be honored, not overwritten");
5387 assert!(err.to_string().contains("closed"));
5388
5389 let on_disk = talks.get(&stale.id).expect("reread");
5390 assert_eq!(
5391 on_disk.status,
5392 TalkStatus::Closed,
5393 "record must not resurrect a conversation closed while its snapshot was stale"
5394 );
5395 assert!(
5396 on_disk.turns.is_empty(),
5397 "the rejected turn must not have been appended: {:?}",
5398 on_disk.turns
5399 );
5400 let _ = &cfg; }
5402
5403 #[test]
5404 fn close_blocks_on_records_guard_rather_than_interleaving_with_it() {
5405 let (tmp, talks) = store();
5406 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5407 let cfg = config(spec);
5408 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5409
5410 let held = talks.guard().unwrap();
5414
5415 let talks2 = talks.clone();
5416 let id = talk.id.clone();
5417 let closing = std::thread::spawn(move || {
5418 let mut talk = talks2.get(&id).expect("get");
5419 close(&mut talk, &talks2).expect("close");
5420 });
5421
5422 std::thread::sleep(Duration::from_millis(50));
5423 assert!(
5424 !closing.is_finished(),
5425 "close must wait for the guard, not read and write while it is held - \
5426 a re-read alone narrows this window without closing it"
5427 );
5428
5429 drop(held);
5430 closing.join().expect("close thread panicked");
5431
5432 assert_eq!(
5433 talks.get(&talk.id).expect("reread").status,
5434 TalkStatus::Closed,
5435 "once the guard is free, close still lands"
5436 );
5437 let _ = &cfg; }
5439
5440 #[test]
5441 fn reopening_a_closed_talk_lets_it_take_turns_again_and_reopening_twice_is_not_an_error() {
5442 let (tmp, talks) = store();
5443 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5444 let cfg = config(spec);
5445 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5446
5447 close(&mut talk, &talks).expect("close");
5448 assert_eq!(talk.status, TalkStatus::Closed);
5449
5450 reopen(&mut talk, &talks).expect("reopen");
5451 assert_eq!(talk.status, TalkStatus::Open);
5452 assert_eq!(
5453 talks.get(&talk.id).expect("reread").status,
5454 TalkStatus::Open
5455 );
5456
5457 reopen(&mut talk, &talks).expect("reopening an open talk is not an error");
5459 assert_eq!(talk.status, TalkStatus::Open);
5460
5461 record(&mut talk, &talks, "one more thing", Vec::new())
5462 .expect("a reopened talk takes turns again");
5463 let _ = &cfg; }
5465
5466 #[test]
5467 fn removing_a_talk_deletes_its_record_and_artifacts_and_refuses_an_unknown_id() {
5468 let (tmp, talks) = store();
5469 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5470 let cfg = config(spec);
5471 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5472
5473 let artifacts = talks.artifacts_of(&talk.id);
5474 std::fs::create_dir_all(&artifacts).expect("create artifacts dir");
5475 std::fs::write(artifacts.join("turn-1.txt"), "hello").expect("write artifact");
5476
5477 talks.remove(&talk.id).expect("remove");
5478 assert!(!talks.path_of(&talk.id).is_file(), "the record is gone");
5479 assert!(!artifacts.is_dir(), "the artifacts directory is gone");
5480 assert!(
5481 talks.get(&talk.id).is_err(),
5482 "a removed talk cannot be read back"
5483 );
5484
5485 let err = talks
5486 .remove("nonexistent-id")
5487 .expect_err("unknown id refused");
5488 assert!(err.to_string().contains("no talk matches"), "{err}");
5489 let _ = &cfg; }
5491
5492 #[tokio::test]
5493 async fn a_delete_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
5494 let (tmp, talks) = store();
5495 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
5496 let cfg = config(spec);
5497 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5500
5501 talks.remove(&in_flight.id).expect("remove");
5502 assert!(
5503 talks.get(&in_flight.id).is_err(),
5504 "the delete landed on disk before the turn finished"
5505 );
5506
5507 respond(
5510 &talks.claim_turn(&in_flight.id).unwrap().unwrap(),
5511 &mut in_flight,
5512 &talks,
5513 &cfg,
5514 "one more question",
5515 )
5516 .await
5517 .expect("the turn itself still completes rather than erroring");
5518
5519 assert!(
5520 talks.get(&in_flight.id).is_err(),
5521 "a delete must stick even when a turn that started before it finishes after it"
5522 );
5523 }
5524
5525 #[test]
5526 fn a_delete_that_lands_before_record_is_called_is_not_undone_by_it() {
5527 let (tmp, talks) = store();
5528 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5529 let cfg = config(spec);
5530 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5533
5534 talks.remove(&stale.id).expect("remove");
5535
5536 let err = record(&mut stale, &talks, "still there?", Vec::new())
5540 .expect_err("a delete that landed first must be honored, not overwritten");
5541 assert!(err.to_string().contains("deleted"), "{err}");
5542
5543 assert!(
5544 talks.get(&stale.id).is_err(),
5545 "record must not resurrect a conversation deleted while its snapshot was stale"
5546 );
5547 let _ = &cfg; }
5549
5550 #[test]
5551 fn a_delete_that_lands_before_close_is_called_is_not_undone_by_it() {
5552 let (tmp, talks) = store();
5553 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5554 let cfg = config(spec);
5555 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5558
5559 talks.remove(&stale.id).expect("remove");
5560
5561 let err = close(&mut stale, &talks)
5565 .expect_err("a delete that landed first must be honored, not overwritten");
5566 assert!(err.to_string().contains("deleted"), "{err}");
5567
5568 assert!(
5569 talks.get(&stale.id).is_err(),
5570 "close must not resurrect a conversation deleted while its snapshot was stale"
5571 );
5572 let _ = &cfg; }
5574
5575 #[test]
5576 fn a_delete_that_lands_before_reopen_is_called_is_not_undone_by_it() {
5577 let (tmp, talks) = store();
5578 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5579 let cfg = config(spec);
5580 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5583 close(&mut stale, &talks).expect("close");
5584
5585 talks.remove(&stale.id).expect("remove");
5586
5587 let err = reopen(&mut stale, &talks)
5591 .expect_err("a delete that landed first must be honored, not overwritten");
5592 assert!(err.to_string().contains("deleted"), "{err}");
5593
5594 assert!(
5595 talks.get(&stale.id).is_err(),
5596 "reopen must not resurrect a conversation deleted while its snapshot was stale"
5597 );
5598 let _ = &cfg; }
5600
5601 #[test]
5602 fn list_puts_open_talks_before_closed_ones() {
5603 let (tmp, talks) = store();
5604 let make = |id: &str, status: TalkStatus| {
5605 let mut t = Talk {
5606 posts_read_through: None,
5607 pending_breaks: None,
5608 pending_messages: Vec::new(),
5609 schema: SCHEMA,
5610 id: id.to_owned(),
5611 repo: tmp.path().to_owned(),
5612 agent: "mock".to_owned(),
5613 status,
5614 turns: Vec::new(),
5615 pending: String::new(),
5616 pending_attachments: Vec::new(),
5617 fallback: false,
5618 persona: String::new(),
5619 persona_dirty: false,
5620 implementers: 1,
5621 implementers_dirty: false,
5622 notified: Vec::new(),
5623 completion_notices: Vec::new(),
5624 created_at: Timestamp::now(),
5625 updated_at: Timestamp::now(),
5626 seat: SeatState::new(SEAT, "mock", 7),
5627 };
5628 talks.put(&mut t).expect("put");
5629 };
5630 make("20260901-000000-0001", TalkStatus::Open);
5631 make("20260902-000000-0002", TalkStatus::Open);
5632 make("20260903-000000-0003", TalkStatus::Closed);
5633
5634 let ids: Vec<String> = talks.list().into_iter().map(|t| t.id).collect();
5635 assert_eq!(
5636 ids,
5637 [
5638 "20260902-000000-0002",
5639 "20260901-000000-0001",
5640 "20260903-000000-0003"
5641 ]
5642 );
5643 assert_eq!(talks.count_open(), 2);
5644 }
5645
5646 #[test]
5647 fn tasks_of_finds_only_this_talks_own_tasks() {
5648 let dir = tempfile::tempdir().expect("tempdir");
5649 let queue = Queue::at(dir.path().join("queue"));
5650
5651 let mut mine = Task::new(
5652 "rework the loader".to_owned(),
5653 "rework the loader".to_owned(),
5654 PathBuf::from("/repo"),
5655 Source::Agent {
5656 run: "20260904-014455-ab12".to_owned(),
5657 node: "chat".to_owned(),
5658 },
5659 );
5660 queue.put(&mut mine).expect("put mine");
5661
5662 let mut theirs = Task::new(
5663 "unrelated".to_owned(),
5664 "unrelated".to_owned(),
5665 PathBuf::from("/repo"),
5666 Source::Agent {
5667 run: "20260904-090000-zz99".to_owned(),
5668 node: "implement".to_owned(),
5669 },
5670 );
5671 queue.put(&mut theirs).expect("put theirs");
5672
5673 let mut human = Task::new(
5674 "typed by hand".to_owned(),
5675 "typed by hand".to_owned(),
5676 PathBuf::from("/repo"),
5677 Source::Human,
5678 );
5679 queue.put(&mut human).expect("put human");
5680
5681 let found = tasks_of(&queue, "20260904-014455-ab12");
5682 assert_eq!(found.len(), 1);
5683 assert_eq!(found[0].id, mine.id);
5684 }
5685
5686 #[test]
5687 fn the_briefing_names_solo_task_add() {
5688 let brief = briefing(Path::new("/repo"), "en", false);
5689 assert!(brief.contains("magi task add --solo"));
5690 assert!(brief.contains("/repo"));
5691 assert!(!brief.contains("Hold this conversation in"));
5692 }
5693
5694 #[test]
5700 fn the_briefing_explains_targeting_a_different_repository_by_name() {
5701 let brief = briefing(Path::new("/repo"), "en", false);
5702 assert!(brief.contains("--repo does not have to be a full path"));
5703 assert!(brief.contains("owner/repo"));
5704 assert!(brief.contains("magi repos"));
5705 assert!(brief.contains("ask the operator"));
5706 }
5707
5708 #[test]
5709 fn the_briefing_tells_the_assistant_to_pass_images_with_attach() {
5710 let brief = briefing(Path::new("/repo"), "en", false);
5711 assert!(brief.contains("--attach <path>"), "{brief}");
5712 assert!(brief.contains("deleting this conversation"), "{brief}");
5713 }
5714
5715 #[test]
5716 fn the_briefing_names_the_language_when_it_is_not_english() {
5717 let brief = briefing(Path::new("/repo"), "Japanese", false);
5718 assert!(brief.contains("Hold this conversation in Japanese"));
5719 }
5720
5721 #[test]
5722 fn the_briefing_forbids_writes_unless_the_repository_opted_in() {
5723 let read_only = briefing(Path::new("/repo"), "en", false);
5724 assert!(read_only.contains("Do not write files"));
5725 assert!(!read_only.contains("allow_write"));
5726
5727 let writable = briefing(Path::new("/repo"), "en", true);
5728 assert!(!writable.contains("Do not write files"));
5729 assert!(writable.contains("allow_write = true"));
5730 assert!(writable.contains("magi task add --solo"));
5733 assert!(writable.contains("say plainly what you"));
5734 }
5735
5736 #[test]
5737 fn the_briefing_says_the_chat_cannot_push_and_tasks_report_back() {
5738 let b = briefing_for(Path::new("/r"), "en", false, None, None, 1);
5739 assert!(
5740 b.contains("cannot send the operator a message on your own"),
5741 "{b}"
5742 );
5743 assert!(b.contains(crate::prompt::CHAT_TASK_NOTICE_HEADING));
5744 assert!(b.contains("magi talk watch --timeout 900"));
5745 assert!(b.contains("nor a successfully registered watch"));
5746 }
5747
5748 #[test]
5749 fn a_post_is_replayed_until_the_agent_answers_again() {
5750 let (_tmp, talks) = store();
5751 let mut talk = ctx_talk("mock", Vec::new());
5752 talks.put(&mut talk).expect("put");
5753 post(&talks, &talk.id, " found it ", "20260902-000000-aaaa").expect("post");
5754 let mut talk = talks.get(&talk.id).expect("get");
5755 let block = posts_since_last_reply(&talk).expect("block");
5756 assert!(block.starts_with("# Posted by a task"), "{block}");
5757 assert!(block.contains("found it"));
5758 talk.turns.push(reply("ok", None));
5759 assert!(posts_since_last_reply(&talk).is_none());
5760 assert!(post(&talks, &talk.id, " ", "r").is_err());
5761 close(&mut talk, &talks).expect("close");
5762 assert!(post(&talks, &talk.id, "late", "r").is_err());
5763 }
5764
5765 #[test]
5766 fn queue_once_accepts_a_key_a_single_time_even_after_a_drain() {
5767 let (_tmp, talks) = store();
5768 let mut talk = ctx_talk("mock", Vec::new());
5769 talks.put(&mut talk).expect("put");
5770 assert!(queue_once(&mut talk, &talks, "one", "k").expect("first"));
5771 drain(&mut talk, &talks).expect("drain");
5772 assert!(!queue_once(&mut talk, &talks, "one", "k").expect("again"));
5773 assert!(talks.get(&talk.id).expect("get").pending.is_empty());
5774 }
5775 #[test]
5776 fn sender_mixed_notifications_survive_reload_drain_and_reply() {
5777 let (_tmp, talks) = store();
5778 let mut talk = ctx_talk("mock", Vec::new());
5779 talks.put(&mut talk).unwrap();
5780 queue(&mut talk, &talks, "owner", Vec::new()).unwrap();
5781 assert!(queue_once(&mut talk, &talks, "notice", "n").unwrap());
5782 let mut talk = talks.get(&talk.id).unwrap();
5783 assert_eq!(talk.pending_messages[0].sender, Some(Sender::Operator));
5784 assert_eq!(talk.pending_messages[1].sender, Some(Sender::Magi));
5785 assert!(
5786 edit_pending_message(&mut talk, &talks, "forged", "owner\n\nnotice", &[], Some(1))
5787 .is_err()
5788 );
5789 drain(&mut talk, &talks).unwrap();
5790 let mut talk = talks.get(&talk.id).unwrap();
5791 let turn = talk.turns.last().unwrap();
5792 assert_eq!(turn.who, Who::Operator);
5793 assert_eq!(sender(turn, turn.messages.first()), Sender::Operator);
5794 assert_eq!(sender(turn, turn.messages.get(1)), Sender::Magi);
5795 let reply = resolve_reply(
5796 &talk,
5797 Some(ReplyTarget {
5798 turn: 0,
5799 message: 1,
5800 }),
5801 )
5802 .unwrap()
5803 .unwrap();
5804 assert_eq!(reply.sender, Some(Sender::Magi));
5805 assert!(!queue_once(&mut talk, &talks, "notice", "n").unwrap());
5806 }
5807
5808 #[test]
5809 fn sender_legacy_operator_prose_is_never_migrated_by_heading() {
5810 let mut turn = reply("# Task finished\n\nmagi: operator typed this", None);
5811 turn.who = Who::Operator;
5812 assert_eq!(sender(&turn, None), Sender::Operator);
5813 turn.who = Who::Agent;
5814 turn.body = "magi: failed".into();
5815 assert_eq!(sender(&turn, None), Sender::Magi);
5816 turn.body = "persona reply".into();
5817 assert_eq!(sender(&turn, None), Sender::Persona);
5818 turn.posted_by = Some("task".into());
5819 assert_eq!(sender(&turn, None), Sender::Magi);
5820 turn.posted_by = None;
5821 turn.body = "magi: actual persona words".into();
5822 turn.sender = Some(Sender::Persona);
5823 assert_eq!(sender(&turn, None), Sender::Persona);
5824 }
5825}