1use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex, MutexGuard};
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 answer = self.answer_guard()?;
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 _answer: answer,
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 fn try_guard(&self) -> Result<Option<StoreGuard<'_>>> {
658 let Some(answer) = self.try_answer_guard()? else {
659 return Ok(None);
660 };
661 std::fs::create_dir_all(&self.root)
662 .with_context(|| format!("create {}", self.root.display()))?;
663 Ok(
664 TurnLock::take(&self.root.join(".store.turn"))?.map(|file| StoreGuard {
665 _file: file,
666 _answer: answer,
667 }),
668 )
669 }
670
671 pub fn answer_guard(&self) -> Result<AnswerGuard<'_>> {
676 let deadline = std::time::Instant::now() + TAKEOVER_LOCK_TTL * 2;
677 loop {
678 if let Some(guard) = self.try_answer_guard()? {
679 return Ok(guard);
680 }
681 if std::time::Instant::now() >= deadline {
682 bail!("conversation evidence is locked by another process");
683 }
684 std::thread::sleep(Duration::from_millis(10));
685 }
686 }
687
688 pub fn try_answer_guard(&self) -> Result<Option<AnswerGuard<'_>>> {
692 let mutex = match self.lock.try_lock() {
693 Ok(guard) => guard,
694 Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(),
695 Err(std::sync::TryLockError::WouldBlock) => return Ok(None),
696 };
697 let root = self.answer_lock_root();
700 std::fs::create_dir_all(&root).context("create conversation evidence lock directory")?;
701 let path = root.join("store.turn");
702 Ok(TurnLock::take(&path)?.map(|file| AnswerGuard {
703 _file: file,
704 _mutex: mutex,
705 }))
706 }
707
708 pub fn answer_lock_root(&self) -> PathBuf {
710 self.root.join(".answer-lock")
711 }
712
713 pub fn root(&self) -> &Path {
715 &self.root
716 }
717
718 pub fn path_of(&self, id: &str) -> PathBuf {
720 self.root.join(format!("{id}.json"))
721 }
722
723 pub fn artifacts_of(&self, id: &str) -> PathBuf {
726 self.root.join(format!("{id}.artifacts"))
727 }
728
729 pub fn attachments_dir(&self, id: &str) -> PathBuf {
733 self.artifacts_of(id).join("attachments")
734 }
735
736 pub fn put_attachment(
745 &self,
746 id: &str,
747 mime: &str,
748 name: &str,
749 data: &[u8],
750 ) -> Result<Attachment> {
751 let dir = self.attachments_dir(id);
752 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
753 let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
754 let att = Attachment {
755 id: new_attachment_id(),
756 name: name.to_owned(),
757 mime: mime.to_owned(),
758 bytes: data.len() as u64,
759 };
760 std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
761 .with_context(|| format!("write attachment {}", att.id))?;
762 std::fs::write(
763 dir.join(format!("{}.json", att.id)),
764 serde_json::to_string(&att).context("serialize attachment")?,
765 )
766 .with_context(|| format!("write attachment metadata {}", att.id))?;
767 Ok(att)
768 }
769
770 pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
779 if !valid_attachment_id(att_id) {
780 return Ok(None);
781 }
782 let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
783 if !meta_path.is_file() {
784 return Ok(None);
785 }
786 let att = serde_json::from_str(
787 &std::fs::read_to_string(&meta_path)
788 .with_context(|| format!("read {}", meta_path.display()))?,
789 )
790 .with_context(|| format!("parse {}", meta_path.display()))?;
791 Ok(Some(att))
792 }
793
794 pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
798 let Some(att) = self.attachment_meta(id, att_id)? else {
799 return Ok(None);
800 };
801 let ext = attachment_ext(&att.mime).with_context(|| {
802 format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
803 })?;
804 let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
805 let data =
806 std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
807 Ok(Some((att, data)))
808 }
809
810 fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
827 let ext = attachment_ext(&att.mime)?;
828 let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
829 std::path::absolute(&path).ok()
830 }
831
832 pub fn put(&self, t: &mut Talk) -> Result<()> {
841 std::fs::create_dir_all(&self.root)
842 .with_context(|| format!("create {}", self.root.display()))?;
843 t.updated_at = Timestamp::now();
844 let mut persisted = t.clone();
847 for turn in &mut persisted.turns {
848 if turn.attachments.is_empty() && messages_need_no_metadata(&turn.messages) {
849 turn.messages.clear();
850 }
851 }
852 if persisted.pending_attachments.is_empty()
853 && messages_need_no_metadata(&persisted.pending_messages)
854 {
855 persisted.pending_messages.clear();
856 }
857 let body = serde_json::to_string_pretty(&persisted).context("serialize talk")?;
858 let path = self.path_of(&t.id);
859 let tmp = path.with_extension("json.tmp");
860 write_atomic(&tmp, &path, &body)
861 }
862
863 pub fn get(&self, id: &str) -> Result<Talk> {
865 let resolved = self.resolve_id(id)?;
866 read_path(&self.path_of(&resolved))
867 }
868
869 pub fn list(&self) -> Vec<Talk> {
872 self.list_counting_unreadable().0
873 }
874
875 pub fn list_counting_unreadable(&self) -> (Vec<Talk>, usize) {
878 let mut unreadable = 0;
879 let mut all: Vec<Talk> = std::fs::read_dir(&self.root)
880 .into_iter()
881 .flatten()
882 .flatten()
883 .map(|e| e.path())
884 .filter(|p| p.extension().is_some_and(|x| x == "json"))
885 .filter_map(|p| {
886 let talk = read_path(&p).ok();
887 if talk.is_none() {
888 unreadable += 1;
889 }
890 talk
891 })
892 .collect();
893 all.sort_unstable_by(|a, b| {
894 let rank = |t: &Talk| u8::from(!t.status.open());
895 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
896 });
897 (all, unreadable)
898 }
899
900 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
902 if self.path_of(prefix).is_file() {
903 return Ok(prefix.to_owned());
904 }
905 let hits: Vec<String> = self
906 .list()
907 .into_iter()
908 .map(|t| t.id)
909 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
910 .collect();
911 match hits.len() {
912 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
913 0 => bail!("no talk matches `{prefix}`"),
914 _ => bail!(
915 "`{prefix}` matches {} talks: {}",
916 hits.len(),
917 hits.join(", ")
918 ),
919 }
920 }
921
922 pub fn revision(&self) -> u64 {
925 std::fs::read_dir(&self.root)
926 .into_iter()
927 .flatten()
928 .flatten()
929 .filter(|e| e.path().extension().is_none_or(|x| x != "turn"))
930 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
931 .filter_map(|e| e.metadata().ok())
932 .filter_map(|m| m.modified().ok())
933 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
934 .map(|d| d.as_millis() as u64)
935 .max()
936 .unwrap_or(0)
937 }
938
939 pub fn count_open(&self) -> usize {
941 self.list().iter().filter(|t| t.status.open()).count()
942 }
943
944 pub fn turn_path(&self, id: &str) -> PathBuf {
947 self.root.join(format!("{id}.turn"))
948 }
949
950 pub fn claim_turn(&self, id: &str) -> Result<Option<TurnLease>> {
959 self.claim_turn_at(id, Timestamp::now())
960 }
961
962 fn claim_turn_at(&self, id: &str, now: Timestamp) -> Result<Option<TurnLease>> {
963 std::fs::create_dir_all(&self.root)
964 .with_context(|| format!("create {}", self.root.display()))?;
965 let path = self.turn_path(id);
966 let token = fresh_token();
967 if create_turn(&path, &token, now)? {
968 return Ok(Some(TurnLease {
969 talk: id.to_owned(),
970 path,
971 token,
972 }));
973 }
974 if lease_blocks(&path, now) {
975 return Ok(None);
976 }
977 let Some(_lock) = TurnLock::take(&path)? else {
982 return Ok(None);
983 };
984 if lease_blocks(&path, now) {
985 return Ok(None);
986 }
987 let _ = std::fs::remove_file(&path);
988 Ok(create_turn(&path, &token, now)?.then_some(TurnLease {
989 talk: id.to_owned(),
990 path,
991 token,
992 }))
993 }
994
995 pub fn turn_held(&self, id: &str) -> bool {
997 read_turn(&self.turn_path(id)).is_some_and(|r| r.fresh(Timestamp::now()))
998 }
999
1000 pub fn remove(&self, id: &str) -> Result<()> {
1013 let _guard = self.guard()?;
1014 let resolved = self.resolve_id(id)?;
1015 let path = self.path_of(&resolved);
1016 std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
1017 let artifacts = self.artifacts_of(&resolved);
1018 if artifacts.is_dir() {
1019 std::fs::remove_dir_all(&artifacts)
1020 .with_context(|| format!("remove {}", artifacts.display()))?;
1021 }
1022 let _ = std::fs::remove_file(self.turn_path(&resolved));
1023 Ok(())
1024 }
1025}
1026
1027struct StoreGuard<'a> {
1030 _file: TurnLock,
1031 _answer: AnswerGuard<'a>,
1032}
1033
1034pub struct AnswerGuard<'a> {
1036 _file: TurnLock,
1037 _mutex: MutexGuard<'a, ()>,
1038}
1039
1040#[derive(Debug, Serialize, Deserialize)]
1043struct TurnRecord {
1044 token: String,
1045 pid: u32,
1046 beat_at: Timestamp,
1047}
1048
1049impl TurnRecord {
1050 fn fresh(&self, now: Timestamp) -> bool {
1051 now.as_second() - self.beat_at.as_second() <= crate::ask::LEASE_TTL.as_secs() as i64
1052 }
1053}
1054
1055fn fresh_token() -> String {
1059 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1060 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1061 let seed = crate::rng::entropy() ^ n.wrapping_mul(0x9E37_79B9_7F4A_7C15);
1062 crate::rng::SplitMix64::new(seed).uuid_v4()
1063}
1064
1065fn read_turn(path: &Path) -> Option<TurnRecord> {
1066 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
1067}
1068
1069const TAKEOVER_LOCK_TTL: Duration = Duration::from_secs(10);
1071
1072const TICKET_TTL: Duration = Duration::from_secs(10);
1075
1076const TICKET_GENERATIONS: u32 = 16;
1078
1079const TICKET_SWEEP_AGE: Duration = Duration::from_secs(3600);
1081
1082fn create_exclusive(path: &Path, body: &str) -> Result<bool> {
1084 use std::io::Write as _;
1085 match std::fs::OpenOptions::new()
1086 .write(true)
1087 .create_new(true)
1088 .open(path)
1089 {
1090 Ok(mut f) => {
1091 if let Err(e) = f.write_all(body.as_bytes()) {
1092 drop(f);
1093 let _ = std::fs::remove_file(path);
1094 return Err(e).with_context(|| format!("write {}", path.display()));
1095 }
1096 Ok(true)
1097 }
1098 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1099 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1100 }
1101}
1102
1103fn create_turn(path: &Path, token: &str, now: Timestamp) -> Result<bool> {
1104 let record = TurnRecord {
1105 token: token.to_owned(),
1106 pid: std::process::id(),
1107 beat_at: now,
1108 };
1109 let body = serde_json::to_string(&record).context("serialize turn lease")?;
1110 let tmp = path.with_extension(format!("turn.{token}.new"));
1113 publish_exclusive(path, &tmp, &body)
1114}
1115
1116fn lease_blocks(path: &Path, now: Timestamp) -> bool {
1120 if read_turn(path).is_some_and(|r| r.fresh(now)) {
1121 return true;
1122 }
1123 std::fs::metadata(path).is_ok_and(|m| {
1124 m.len() == 0
1125 && m.modified()
1126 .ok()
1127 .and_then(|t| t.elapsed().ok())
1128 .is_some_and(|age| age <= TAKEOVER_LOCK_TTL)
1129 })
1130}
1131
1132fn link_unsupported(e: &std::io::Error) -> bool {
1135 e.kind() == std::io::ErrorKind::Unsupported || (cfg!(windows) && e.raw_os_error() == Some(1))
1136}
1137
1138fn create_in_place(path: &Path, body: &str) -> Result<bool> {
1156 use std::io::Write as _;
1157 let mut f = match std::fs::OpenOptions::new()
1158 .write(true)
1159 .create_new(true)
1160 .open(path)
1161 {
1162 Ok(f) => f,
1163 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
1164 Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
1165 };
1166 f.write_all(body.as_bytes())
1167 .with_context(|| format!("write {}", path.display()))?;
1168 Ok(std::fs::read_to_string(path).is_ok_and(|t| t == body))
1171}
1172
1173fn publish_exclusive(path: &Path, tmp: &Path, body: &str) -> Result<bool> {
1174 std::fs::write(tmp, body).with_context(|| format!("write {}", tmp.display()))?;
1175 #[cfg(test)]
1176 let linked = if failpoint::no_link_forced() {
1177 Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
1178 } else {
1179 std::fs::hard_link(tmp, path)
1180 };
1181 #[cfg(not(test))]
1182 let linked = std::fs::hard_link(tmp, path);
1183 let out = match linked {
1184 Ok(()) => Ok(true),
1185 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1186 Err(e) if link_unsupported(&e) => create_in_place(path, body),
1187 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1188 };
1189 let _ = std::fs::remove_file(tmp);
1190 out
1191}
1192
1193fn remove_if_carries(path: &Path, expected: &str) -> bool {
1204 let gone = path.with_extension(format!("gone.{}", fresh_token()));
1205 if std::fs::rename(path, &gone).is_err() {
1206 return false;
1207 }
1208 let found = std::fs::read_to_string(&gone);
1209 if found.as_ref().is_ok_and(|t| t == expected) {
1210 let _ = std::fs::remove_file(&gone);
1211 return true;
1212 }
1213 if let Ok(body) = found {
1214 let back = path.with_extension(format!("back.{}", fresh_token()));
1215 match publish_exclusive(path, &back, &body) {
1216 Ok(true) => {}
1217 Ok(false) | Err(_) => tracing::warn!(
1218 "{} was replaced while a stale removal had it aside; \
1219 the displaced file is dropped",
1220 path.display()
1221 ),
1222 }
1223 }
1224 let _ = std::fs::remove_file(&gone);
1225 false
1226}
1227
1228struct TurnLock {
1232 path: PathBuf,
1233 token: String,
1234}
1235
1236impl TurnLock {
1237 fn token() -> String {
1238 fresh_token()
1239 }
1240
1241 fn publish(path: &Path, token: &str) -> Result<bool> {
1245 let tmp = path.with_extension(format!("lock.{token}.new"));
1246 publish_exclusive(path, &tmp, token)
1247 }
1248
1249 fn take(lease: &Path) -> Result<Option<Self>> {
1250 let path = lease.with_extension("turn.lock");
1251 let token = Self::token();
1252 if Self::publish(&path, &token)? {
1253 return Ok(Some(Self { path, token }));
1254 }
1255 let Ok(seen) = std::fs::read_to_string(&path) else {
1256 return Ok(None);
1257 };
1258 let key = if !seen.is_empty() && seen.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1261 {
1262 seen.as_str()
1263 } else {
1264 "invalid"
1265 };
1266 let aged = std::fs::metadata(&path)
1267 .and_then(|m| m.modified())
1268 .ok()
1269 .and_then(|t| t.elapsed().ok())
1270 .is_some_and(|age| age > TAKEOVER_LOCK_TTL);
1271 if !aged {
1272 return Ok(None);
1273 }
1274 let mut won = false;
1286 for n in 0..TICKET_GENERATIONS {
1287 let ticket = path.with_extension(format!("lock.{key}.break.{n}"));
1288 if create_exclusive(&ticket, "")? {
1289 won = true;
1290 break;
1291 }
1292 let stale = std::fs::metadata(&ticket)
1293 .and_then(|m| m.modified())
1294 .ok()
1295 .and_then(|t| t.elapsed().ok())
1296 .is_some_and(|age| age > TICKET_TTL);
1297 if !stale {
1298 return Ok(None);
1299 }
1300 }
1301 if !won {
1302 Self::sweep_tickets(&path);
1307 return Ok(None);
1308 }
1309 Self::sweep_tickets(&path);
1310 if !remove_if_carries(&path, &seen) {
1316 return Ok(None);
1317 }
1318 if Self::publish(&path, &token)? {
1319 return Ok(Some(Self { path, token }));
1320 }
1321 Ok(None)
1322 }
1323
1324 fn sweep_tickets(path: &Path) {
1327 let (Some(dir), Some(name)) = (path.parent(), path.file_name().and_then(|n| n.to_str()))
1328 else {
1329 return;
1330 };
1331 let prefix = format!("{name}.");
1332 let Ok(entries) = std::fs::read_dir(dir) else {
1333 return;
1334 };
1335 for entry in entries.flatten() {
1336 let file = entry.file_name();
1337 let Some(file) = file.to_str() else { continue };
1338 if !(file.starts_with(&prefix) && file.contains(".break.")) {
1339 continue;
1340 }
1341 let old = entry
1342 .metadata()
1343 .and_then(|m| m.modified())
1344 .ok()
1345 .and_then(|t| t.elapsed().ok())
1346 .is_some_and(|age| age > TICKET_SWEEP_AGE);
1347 if old {
1348 let _ = std::fs::remove_file(entry.path());
1349 }
1350 }
1351 }
1352
1353 fn take_patiently(lease: &Path) -> Option<Self> {
1355 for _ in 0..50 {
1356 match Self::take(lease) {
1357 Ok(Some(lock)) => return Some(lock),
1358 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
1359 Err(_) => return None,
1360 }
1361 }
1362 None
1363 }
1364}
1365
1366impl Drop for TurnLock {
1367 fn drop(&mut self) {
1368 remove_if_carries(&self.path, &self.token);
1370 }
1371}
1372
1373pub const TURN_BEAT: Duration = Duration::from_secs(20);
1376
1377#[derive(Debug)]
1382pub struct TurnLease {
1383 talk: String,
1384 path: PathBuf,
1385 token: String,
1386}
1387
1388impl TurnLease {
1389 pub fn holds(&self) -> bool {
1391 read_turn(&self.path).is_some_and(|r| r.token == self.token)
1392 }
1393
1394 pub fn beat(&self) -> Result<bool> {
1398 let _lock = TurnLock::take_patiently(&self.path)
1399 .with_context(|| format!("lock {} to renew it", self.path.display()))?;
1400 let Some(mut record) = read_turn(&self.path).filter(|r| r.token == self.token) else {
1401 return Ok(false);
1402 };
1403 record.beat_at = Timestamp::now();
1404 let body = serde_json::to_string(&record).context("serialize turn lease")?;
1405 let tmp = self.path.with_extension(format!("turn.{}.tmp", self.token));
1406 write_atomic(&tmp, &self.path, &body)?;
1407 Ok(true)
1408 }
1409
1410 pub async fn beating<T>(&self, fut: impl std::future::Future<Output = T>) -> Result<T> {
1415 self.beating_every(TURN_BEAT, fut).await
1416 }
1417
1418 async fn beating_every<T>(
1419 &self,
1420 period: Duration,
1421 fut: impl std::future::Future<Output = T>,
1422 ) -> Result<T> {
1423 tokio::pin!(fut);
1424 loop {
1425 match tokio::time::timeout(period, &mut fut).await {
1426 Ok(out) => return Ok(out),
1427 Err(_) => match self.beat() {
1428 Ok(true) => {}
1429 Ok(false) => bail!(
1430 "the turn lease {} was taken over; this turn is stopped",
1431 self.path.display()
1432 ),
1433 Err(e) => tracing::warn!("{e:#}"),
1434 },
1435 }
1436 }
1437 }
1438}
1439
1440impl Drop for TurnLease {
1441 fn drop(&mut self) {
1442 if let Some(_lock) = TurnLock::take_patiently(&self.path) {
1445 if read_turn(&self.path).is_some_and(|r| r.token == self.token) {
1446 let _ = std::fs::remove_file(&self.path);
1447 }
1448 }
1449 }
1450}
1451
1452pub fn begin(store: &Talks, cfg: &Config, repo: PathBuf, agent: Option<&str>) -> Result<Talk> {
1462 begin_with(store, cfg, repo, agent, &Preferred::default())
1463}
1464
1465#[derive(Debug, Default, Clone)]
1469pub struct Preferred {
1470 pub agent: Option<String>,
1472 pub persona: Option<String>,
1474 pub implementers: Option<i64>,
1476}
1477
1478pub fn begin_with(
1485 store: &Talks,
1486 cfg: &Config,
1487 repo: PathBuf,
1488 agent: Option<&str>,
1489 preferred: &Preferred,
1490) -> Result<Talk> {
1491 let repo = repo.canonicalize().unwrap_or(repo);
1494 let head = || -> Result<AgentSpec> {
1497 Ok(agent::pick_chain(
1498 &cfg.agents,
1499 cfg.roles.chatter.as_ref(),
1500 &agent::installed,
1501 "chatter",
1502 )?
1503 .remove(0))
1504 };
1505 let mut fallback = agent.is_none();
1506 let spec = match agent {
1507 Some(id) => agent::pick(&cfg.agents, Some(id), &agent::installed)?,
1508 None => {
1509 match preferred
1510 .agent
1511 .as_deref()
1512 .map(str::trim)
1513 .filter(|id| !id.is_empty())
1514 {
1515 Some(id) => match agent::pick(&cfg.agents, Some(id), &agent::installed) {
1516 Ok(spec) => {
1517 fallback = head().is_ok_and(|head| spec.id == head.id);
1518 spec
1519 }
1520 Err(_) => head()?,
1521 },
1522 None => head()?,
1523 }
1524 }
1525 };
1526 let persona = preferred
1527 .persona
1528 .as_deref()
1529 .and_then(|id| crate::persona::find(&cfg.talk.personas, id))
1530 .filter(|p| !p.is_default())
1531 .map(|p| p.id)
1532 .unwrap_or_default();
1533 let implementers = preferred
1534 .implementers
1535 .and_then(|n| u8::try_from(n).ok())
1536 .and_then(|n| check_implementers(n, cfg).ok())
1537 .unwrap_or(1);
1538
1539 let now = Timestamp::now();
1540 let mut talk = Talk {
1541 posts_read_through: Some(0),
1542 pending_breaks: None,
1543 pending_messages: Vec::new(),
1544 schema: SCHEMA,
1545 id: new_id(),
1546 repo,
1547 agent: spec.id.clone(),
1548 status: TalkStatus::Open,
1549 turns: Vec::new(),
1550 pending: String::new(),
1551 pending_attachments: Vec::new(),
1552 fallback,
1553 persona,
1554 persona_dirty: false,
1555 implementers,
1556 implementers_dirty: false,
1557 notified: Vec::new(),
1558 completion_notices: Vec::new(),
1559 created_at: now,
1560 updated_at: now,
1561 seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
1562 };
1563 store.put(&mut talk)?;
1564 Ok(talk)
1565}
1566
1567pub fn record(
1574 talk: &mut Talk,
1575 store: &Talks,
1576 text: &str,
1577 attachments: Vec<Attachment>,
1578) -> Result<String> {
1579 record_reply(talk, store, text, attachments, None)
1580}
1581
1582pub fn record_reply(
1584 talk: &mut Talk,
1585 store: &Talks,
1586 text: &str,
1587 attachments: Vec<Attachment>,
1588 target: Option<ReplyTarget>,
1589) -> Result<String> {
1590 let _guard = store.guard()?;
1598 let Ok(fresh) = store.get(&talk.id) else {
1603 bail!("talk {} was deleted", talk.short());
1604 };
1605 let reply = resolve_reply(&fresh, target)?;
1606 talk.turns = fresh.turns;
1607 talk.status = fresh.status;
1608 talk.pending = fresh.pending;
1611 talk.pending_breaks = fresh.pending_breaks;
1612 talk.pending_messages = fresh.pending_messages;
1613 talk.pending_attachments = fresh.pending_attachments;
1614 talk.notified = fresh.notified;
1615 if !talk.status.open() {
1616 bail!(
1617 "talk {} is {} and takes no more turns",
1618 talk.short(),
1619 talk.status.as_str()
1620 );
1621 }
1622 if text.trim().is_empty() && attachments.is_empty() {
1624 bail!("nothing to say");
1625 }
1626 talk.turns.push(Turn {
1627 sender: Some(Sender::Operator),
1628 who: Who::Operator,
1629 body: text.to_owned(),
1630 at: Timestamp::now(),
1631 messages: vec![Message {
1632 display: None,
1633 sender: Some(Sender::Operator),
1634 body: text.to_owned(),
1635 attachments: attachments.clone(),
1636 reply,
1637 }],
1638 attachments,
1639 usage: None,
1640 breaks: Some(Vec::new()),
1641 posted_by: None,
1642 });
1643 store.put(talk)?;
1644 Ok(text.to_owned())
1645}
1646
1647pub fn queue(
1649 talk: &mut Talk,
1650 store: &Talks,
1651 text: &str,
1652 attachments: Vec<Attachment>,
1653) -> Result<()> {
1654 queue_reply(talk, store, text, attachments, None)
1655}
1656
1657pub fn queue_reply(
1659 talk: &mut Talk,
1660 store: &Talks,
1661 text: &str,
1662 attachments: Vec<Attachment>,
1663 target: Option<ReplyTarget>,
1664) -> Result<()> {
1665 queue_keyed(
1666 talk,
1667 store,
1668 text,
1669 attachments,
1670 None,
1671 &|_| false,
1672 (target, Sender::Operator, None),
1673 )
1674 .map(|_| ())
1675}
1676
1677pub fn queue_once(talk: &mut Talk, store: &Talks, text: &str, key: &str) -> Result<bool> {
1682 queue_once_matching(talk, store, text, key, &|_| false, None)
1683}
1684
1685pub(crate) fn queue_once_matching(
1688 talk: &mut Talk,
1689 store: &Talks,
1690 text: &str,
1691 key: &str,
1692 previous: &dyn Fn(&str) -> bool,
1693 reference: Option<&crate::chat_notify::CompletionReference>,
1694) -> Result<bool> {
1695 queue_keyed(
1696 talk,
1697 store,
1698 text,
1699 Vec::new(),
1700 Some((key, reference)),
1701 previous,
1702 (None, Sender::Magi, None),
1703 )
1704}
1705
1706pub(crate) fn queue_notice(
1708 talk: &mut Talk,
1709 store: &Talks,
1710 text: &str,
1711 key: &str,
1712 previous: &dyn Fn(&str) -> bool,
1713 reference: Option<&crate::chat_notify::CompletionReference>,
1714 display: crate::prompt::NoticeDisplay,
1715) -> Result<bool> {
1716 queue_keyed(
1717 talk,
1718 store,
1719 text,
1720 Vec::new(),
1721 Some((key, reference)),
1722 previous,
1723 (None, Sender::Magi, Some(display)),
1724 )
1725}
1726
1727pub fn queue_magi(talk: &mut Talk, store: &Talks, text: &str) -> Result<()> {
1729 queue_magi_display(talk, store, text, None)
1730}
1731
1732pub(crate) fn queue_magi_display(
1734 talk: &mut Talk,
1735 store: &Talks,
1736 text: &str,
1737 display: Option<crate::prompt::NoticeDisplay>,
1738) -> Result<()> {
1739 queue_keyed(
1740 talk,
1741 store,
1742 text,
1743 Vec::new(),
1744 None,
1745 &|_| false,
1746 (None, Sender::Magi, display),
1747 )
1748 .map(|_| ())
1749}
1750
1751fn queue_keyed(
1752 talk: &mut Talk,
1753 store: &Talks,
1754 text: &str,
1755 attachments: Vec<Attachment>,
1756 key: Option<(&str, Option<&crate::chat_notify::CompletionReference>)>,
1757 previous: &dyn Fn(&str) -> bool,
1758 source: (
1759 Option<ReplyTarget>,
1760 Sender,
1761 Option<crate::prompt::NoticeDisplay>,
1762 ),
1763) -> Result<bool> {
1764 if text.trim().is_empty() && attachments.is_empty() {
1766 bail!("nothing to say");
1767 }
1768 let _guard = store.guard()?;
1769 let mut fresh = store
1770 .get(&talk.id)
1771 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1772 if !fresh.status.open() {
1773 bail!(
1774 "talk {} is {} and takes no more turns",
1775 fresh.short(),
1776 fresh.status.as_str()
1777 );
1778 }
1779 if !append_pending(&mut fresh, text, attachments, key, previous, source)? {
1780 *talk = fresh;
1781 return Ok(false);
1782 }
1783 store.put(&mut fresh)?;
1784 *talk = fresh;
1785 Ok(true)
1786}
1787
1788pub(crate) fn try_queue_consult(
1792 store: &Talks,
1793 id: &str,
1794 text: &str,
1795 display: Option<crate::prompt::NoticeDisplay>,
1796) -> Result<Option<()>> {
1797 let Some(_guard) = store.try_guard()? else {
1798 return Ok(None);
1799 };
1800 let mut fresh = store.get(id).context("read consultation destination")?;
1801 fresh.status = TalkStatus::Open;
1802 append_pending(
1803 &mut fresh,
1804 text,
1805 Vec::new(),
1806 None,
1807 &|_| false,
1808 (None, Sender::Magi, display),
1809 )?;
1810 store.put(&mut fresh)?;
1811 Ok(Some(()))
1812}
1813
1814fn append_pending(
1815 fresh: &mut Talk,
1816 text: &str,
1817 attachments: Vec<Attachment>,
1818 key: Option<(&str, Option<&crate::chat_notify::CompletionReference>)>,
1819 previous: &dyn Fn(&str) -> bool,
1820 source: (
1821 Option<ReplyTarget>,
1822 Sender,
1823 Option<crate::prompt::NoticeDisplay>,
1824 ),
1825) -> Result<bool> {
1826 if let Some((key, reference)) = key {
1827 if fresh.notified.iter().any(|k| k == key || previous(k)) {
1828 return Ok(false);
1829 }
1830 fresh.notified.push(key.to_owned());
1831 if let Some(reference) = reference {
1832 fresh.completion_notices.push(reference.clone());
1833 }
1834 }
1835 let (target, provenance, display) = source;
1836 let reply = resolve_reply(fresh, target)?;
1837 if fresh.pending_messages.is_empty()
1838 && (!fresh.pending.is_empty() || !fresh.pending_attachments.is_empty())
1839 {
1840 fresh.pending_messages.push(Message {
1841 display: None,
1842 sender: None,
1843 body: fresh.pending.clone(),
1844 attachments: fresh.pending_attachments.clone(),
1845 reply: None,
1846 });
1847 }
1848 fresh.pending_messages.push(Message {
1849 display,
1850 sender: Some(provenance),
1851 body: text.to_owned(),
1852 attachments: attachments.clone(),
1853 reply,
1854 });
1855 if !text.is_empty() {
1856 if fresh.pending.is_empty() {
1857 fresh.pending = text.to_owned();
1858 fresh.pending_breaks = Some(Vec::new());
1859 } else {
1860 fresh.pending.push_str("\n\n");
1861 if let Some(b) = fresh.pending_breaks.as_mut() {
1862 b.push(fresh.pending.len());
1863 }
1864 fresh.pending.push_str(text);
1865 }
1866 }
1867 fresh.pending_attachments.extend(attachments);
1868 Ok(true)
1869}
1870
1871pub fn post(store: &Talks, talk_id: &str, body: &str, by: &str) -> Result<()> {
1876 let body = body.trim();
1877 if body.is_empty() {
1878 bail!("nothing to post");
1879 }
1880 let _guard = store.guard()?;
1881 let mut fresh = store
1882 .get(talk_id)
1883 .with_context(|| format!("talk {talk_id} does not exist"))?;
1884 if !fresh.status.open() {
1885 bail!(
1886 "talk {} is closed and takes no more messages",
1887 fresh.short()
1888 );
1889 }
1890 fresh.turns.push(Turn {
1891 sender: Some(Sender::Magi),
1892 who: Who::Agent,
1893 body: body.to_owned(),
1894 at: Timestamp::now(),
1895 attachments: Vec::new(),
1896 usage: None,
1897 breaks: None,
1898 messages: Vec::new(),
1899 posted_by: Some(by.to_owned()),
1900 });
1901 store.put(&mut fresh)
1902}
1903
1904fn posts_read_through(talk: &Talk) -> usize {
1906 talk.posts_read_through.unwrap_or_else(|| {
1907 talk.turns
1908 .iter()
1909 .rposition(|t| t.who != Who::Operator && t.posted_by.is_none())
1910 .map_or(0, |i| i + 1)
1911 })
1912}
1913
1914pub fn posts_since_last_reply(talk: &Talk) -> Option<String> {
1916 let posts: Vec<&Turn> = talk
1917 .turns
1918 .iter()
1919 .skip(posts_read_through(talk))
1920 .filter(|t| t.posted_by.is_some())
1921 .collect();
1922 if posts.is_empty() {
1923 return None;
1924 }
1925 let mut out = String::from("# Posted by a task\n\n");
1926 for t in posts {
1927 out.push_str(&format!(
1928 "Task seat {} posted to this conversation: {}\n\n",
1929 short(t.posted_by.as_deref().unwrap_or_default()),
1930 t.body
1931 ));
1932 }
1933 Some(out)
1934}
1935
1936pub fn drain(talk: &mut Talk, store: &Talks) -> Result<Option<String>> {
1938 let _guard = store.guard()?;
1939 let mut fresh = store
1940 .get(&talk.id)
1941 .with_context(|| format!("talk {} was deleted", talk.short()))?;
1942 if !fresh.status.open() || (fresh.pending.is_empty() && fresh.pending_attachments.is_empty()) {
1943 *talk = fresh;
1944 return Ok(None);
1945 }
1946 let text = std::mem::take(&mut fresh.pending);
1947 let attachments = std::mem::take(&mut fresh.pending_attachments);
1948 let breaks = fresh.pending_breaks.take();
1949 let messages = std::mem::take(&mut fresh.pending_messages);
1950 fresh.turns.push(Turn {
1951 sender: Some(Sender::Operator),
1952 who: Who::Operator,
1953 body: text.clone(),
1954 at: Timestamp::now(),
1955 attachments,
1956 usage: None,
1957 breaks,
1958 messages,
1959 posted_by: None,
1960 });
1961 store.put(&mut fresh)?;
1962 *talk = fresh;
1963 Ok(Some(text))
1964}
1965
1966pub async fn say(
1969 lease: &TurnLease,
1970 talk: &mut Talk,
1971 store: &Talks,
1972 cfg: &Config,
1973 text: &str,
1974 attachments: Vec<Attachment>,
1975) -> Result<()> {
1976 check_lease(lease, talk)?;
1977 let text = record(talk, store, text, attachments)?;
1978 respond(lease, talk, store, cfg, &text).await
1979}
1980
1981pub async fn respond(
1987 lease: &TurnLease,
1988 talk: &mut Talk,
1989 store: &Talks,
1990 cfg: &Config,
1991 text: &str,
1992) -> Result<()> {
1993 check_lease(lease, talk)?;
1994 lease
1995 .beating(turn(talk, store, cfg, text))
1996 .await
1997 .and_then(|done| done)
1998}
1999
2000fn check_lease(lease: &TurnLease, talk: &Talk) -> Result<()> {
2001 if lease.talk != talk.id {
2002 bail!("the turn lease is for talk {}, not {}", lease.talk, talk.id);
2003 }
2004 if !lease.holds() {
2007 bail!("the turn lease for talk {} is no longer held", talk.short());
2008 }
2009 Ok(())
2010}
2011
2012pub fn close(talk: &mut Talk, store: &Talks) -> Result<()> {
2030 let _guard = store.guard()?;
2031 let mut fresh = store
2032 .get(&talk.id)
2033 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2034 fresh.status = TalkStatus::Closed;
2035 fresh.pending.clear();
2037 fresh.pending_breaks = None;
2038 fresh.pending_messages.clear();
2039 fresh.pending_attachments.clear();
2040 store.put(&mut fresh)?;
2041 *talk = fresh;
2042 Ok(())
2043}
2044
2045pub fn reopen(talk: &mut Talk, store: &Talks) -> Result<()> {
2056 let _guard = store.guard()?;
2057 let mut fresh = store
2058 .get(&talk.id)
2059 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2060 fresh.status = TalkStatus::Open;
2061 store.put(&mut fresh)?;
2062 *talk = fresh;
2063 Ok(())
2064}
2065
2066pub fn switch_agent(talk: &mut Talk, store: &Talks, spec: &AgentSpec) -> Result<bool> {
2079 let _guard = store.guard()?;
2080 let mut fresh = store
2081 .get(&talk.id)
2082 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2083 if fresh.agent == spec.id {
2084 *talk = fresh;
2085 return Ok(false);
2086 }
2087 let from = std::mem::replace(&mut fresh.agent, spec.id.clone());
2088 fresh.seat = SeatState::new(SEAT, &spec.id, crate::rng::entropy());
2089 fresh.fallback = false;
2091 fresh.turns.push(Turn {
2092 sender: Some(Sender::Magi),
2093 breaks: None,
2094 messages: Vec::new(),
2095 posted_by: None,
2096 who: Who::Agent,
2097 body: format!("{MAGI_NOTE}agent changed from {from} to {}", spec.id),
2098 at: Timestamp::now(),
2099 attachments: Vec::new(),
2100 usage: None,
2101 });
2102 store.put(&mut fresh)?;
2103 *talk = fresh;
2104 Ok(true)
2105}
2106
2107pub fn switch_persona(talk: &mut Talk, store: &Talks, id: &str) -> Result<bool> {
2115 let id = if id.trim() == crate::persona::DEFAULT_ID {
2116 ""
2117 } else {
2118 id.trim()
2119 };
2120 let _guard = store.guard()?;
2121 let mut fresh = store
2122 .get(&talk.id)
2123 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2124 if fresh.persona == id {
2125 *talk = fresh;
2126 return Ok(false);
2127 }
2128 let from = std::mem::replace(&mut fresh.persona, id.to_owned());
2129 fresh.persona_dirty = true;
2130 let label = |p: &str| {
2131 if p.is_empty() {
2132 crate::persona::DEFAULT_ID.to_owned()
2133 } else {
2134 p.to_owned()
2135 }
2136 };
2137 fresh.turns.push(Turn {
2138 sender: Some(Sender::Magi),
2139 breaks: None,
2140 messages: Vec::new(),
2141 posted_by: None,
2142 who: Who::Agent,
2143 body: format!(
2144 "{MAGI_NOTE}persona changed from {} to {}",
2145 label(&from),
2146 label(id)
2147 ),
2148 at: Timestamp::now(),
2149 attachments: Vec::new(),
2150 usage: None,
2151 });
2152 store.put(&mut fresh)?;
2153 *talk = fresh;
2154 Ok(true)
2155}
2156
2157pub fn clear_pending(talk: &mut Talk, store: &Talks) -> Result<()> {
2159 let _guard = store.guard()?;
2160 let mut fresh = store
2161 .get(&talk.id)
2162 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2163 fresh.pending.clear();
2164 fresh.pending_breaks = None;
2165 fresh.pending_messages.clear();
2166 fresh.pending_attachments.clear();
2167 store.put(&mut fresh)?;
2168 *talk = fresh;
2169 Ok(())
2170}
2171
2172pub fn clear_pending_if_matches(
2174 talk: &mut Talk,
2175 store: &Talks,
2176 expected_text: &str,
2177 expected_attachments: &[String],
2178) -> Result<bool> {
2179 let _guard = store.guard()?;
2180 let mut fresh = store
2181 .get(&talk.id)
2182 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2183 if !pending_matches(&fresh, expected_text, expected_attachments) {
2184 *talk = fresh;
2185 return Ok(false);
2186 }
2187 fresh.pending.clear();
2188 fresh.pending_breaks = None;
2189 fresh.pending_messages.clear();
2190 fresh.pending_attachments.clear();
2191 store.put(&mut fresh)?;
2192 *talk = fresh;
2193 Ok(true)
2194}
2195
2196pub fn edit_pending_text(
2200 talk: &mut Talk,
2201 store: &Talks,
2202 text: &str,
2203 expected_text: &str,
2204 expected_attachments: &[String],
2205) -> Result<bool> {
2206 edit_pending_message(talk, store, text, expected_text, expected_attachments, None)
2207}
2208
2209pub fn edit_pending_message(
2211 talk: &mut Talk,
2212 store: &Talks,
2213 text: &str,
2214 expected_text: &str,
2215 expected_attachments: &[String],
2216 message: Option<usize>,
2217) -> Result<bool> {
2218 let _guard = store.guard()?;
2219 let mut fresh = store
2220 .get(&talk.id)
2221 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2222 if !pending_matches(&fresh, expected_text, expected_attachments) {
2223 *talk = fresh;
2224 return Ok(false);
2225 }
2226 if fresh
2227 .pending_messages
2228 .iter()
2229 .enumerate()
2230 .any(|(i, m)| m.sender == Some(Sender::Magi) && message.is_none_or(|index| index == i))
2231 {
2232 bail!("MAGI messages cannot be edited");
2233 }
2234 if let Some(index) = message {
2235 let edited = fresh
2236 .pending_messages
2237 .get_mut(index)
2238 .context("invalid queued message")?;
2239 if text.trim().is_empty() && edited.attachments.is_empty() {
2240 fresh.pending_messages.remove(index);
2241 } else {
2242 edited.body = text.to_owned();
2243 }
2244 fresh.pending.clear();
2245 fresh.pending_breaks = Some(Vec::new());
2246 fresh.pending_attachments.clear();
2247 for message in &fresh.pending_messages {
2248 if !message.body.is_empty() {
2249 if !fresh.pending.is_empty() {
2250 fresh.pending.push_str("\n\n");
2251 fresh
2252 .pending_breaks
2253 .as_mut()
2254 .expect("initialized")
2255 .push(fresh.pending.len());
2256 }
2257 fresh.pending.push_str(&message.body);
2258 }
2259 fresh
2260 .pending_attachments
2261 .extend(message.attachments.clone());
2262 }
2263 } else {
2264 if fresh.pending_messages.len() > 1
2265 && fresh.pending_messages.iter().any(|m| m.reply.is_some())
2266 {
2267 bail!("edit queued replies individually");
2268 }
2269 let reply = fresh.pending_messages.first().and_then(|m| m.reply.clone());
2270 fresh.pending_messages = vec![Message {
2271 display: None,
2272 sender: Some(Sender::Operator),
2273 body: text.to_owned(),
2274 attachments: fresh.pending_attachments.clone(),
2275 reply,
2276 }];
2277 fresh.pending = text.to_owned();
2278 fresh.pending_breaks = Some(Vec::new());
2279 }
2280 store.put(&mut fresh)?;
2281 *talk = fresh;
2282 Ok(true)
2283}
2284
2285fn pending_matches(talk: &Talk, expected_text: &str, expected_attachments: &[String]) -> bool {
2286 talk.pending == expected_text
2287 && talk
2288 .pending_attachments
2289 .iter()
2290 .map(|attachment| &attachment.id)
2291 .eq(expected_attachments.iter())
2292}
2293
2294async fn turn(talk: &mut Talk, store: &Talks, cfg: &Config, text: &str) -> Result<()> {
2301 let text = talk
2305 .turns
2306 .last()
2307 .filter(|t| t.who == Who::Operator && t.body == text)
2308 .map(|turn| message_prompt(turn, talk, store))
2309 .unwrap_or_else(|| text.to_owned());
2310 let evidence = crate::chat_notify::response_evidence(talk).await;
2311 let text = format!("{text}{evidence}");
2312 let prompt_turns = talk.turns.len();
2313 talk.posts_read_through = Some(posts_read_through(talk));
2314 let spec = cfg
2315 .agents
2316 .iter()
2317 .find(|a| a.id == talk.agent)
2318 .with_context(|| {
2319 format!(
2320 "talk {} was opened with agent `{}`, which is no longer in \
2321 the roster; restore it in magi.toml or start a new \
2322 conversation",
2323 talk.short(),
2324 talk.agent
2325 )
2326 })?;
2327
2328 let last_note = attachment_note(
2332 store,
2333 &talk.id,
2334 talk.turns
2335 .last()
2336 .map_or(&[][..], |t| t.attachments.as_slice()),
2337 );
2338
2339 let attachment_paths: Vec<PathBuf> = talk
2345 .turns
2346 .iter()
2347 .flat_map(|t| t.attachments.iter())
2348 .filter_map(|a| store.attachment_path(&talk.id, a))
2349 .collect();
2350
2351 let questions =
2361 crate::run::try_home().map(|home| crate::ask::Questions::at(home.join("questions")));
2362 let consulted = questions.as_ref().is_some_and(|q| {
2363 crate::consult::pending_consults(q, &talk.id) || crate::consult::authorized_holds(q, talk)
2364 });
2365 let consult_roots: Vec<PathBuf> = match &questions {
2366 Some(q) if consulted => vec![q.root().to_path_buf(), store.answer_lock_root()],
2367 _ => Vec::new(),
2368 };
2369
2370 let (allow_write, unsandboxed) = turn_access(cfg.talk.allow_write, consulted);
2371
2372 let artifacts = store.artifacts_of(&talk.id);
2373 let operator_turns = talk.turns.iter().filter(|t| t.who == Who::Operator).count();
2376 let stem = format!("turn-{}", operator_turns.max(1));
2377 let cache_dir = cfg.cache_dir();
2380
2381 let mut chain = vec![spec.clone()];
2384 if let Some(choice) = cfg.roles.chatter.as_ref()
2385 && talk.fallback
2386 {
2387 for id in choice.ids() {
2388 if id == talk.agent || chain.iter().any(|s| s.id == id) {
2389 continue;
2390 }
2391 match agent::pick(&cfg.agents, Some(id), &agent::installed) {
2392 Ok(s) => chain.push(s),
2393 Err(e) => tracing::warn!("[roles] chatter: skipping `{id}`: {e:#}"),
2394 }
2395 }
2396 }
2397
2398 let persona = crate::persona::active(&cfg.talk.personas, &talk.persona);
2399 let operator_name = cfg.talk.operator_name();
2400 let persona_update = if talk.persona_dirty {
2401 format!(
2402 "{}\n\n",
2403 crate::persona::update_block_for(persona.as_ref(), operator_name)
2404 )
2405 } else {
2406 String::new()
2407 };
2408
2409 let filing_update = if talk.implementers_dirty {
2412 format!("{}\n\n", filing_update_block(talk.implementers))
2413 } else {
2414 String::new()
2415 };
2416
2417 let mut outcome = None;
2418 let mut fell_back_from: Option<String> = None;
2419 let mut first_try: Option<(String, SeatState)> = None;
2422 for (n, spec) in chain.iter().enumerate() {
2423 if n > 0 {
2424 if first_try.is_none() {
2425 first_try = Some((talk.agent.clone(), talk.seat.clone()));
2426 }
2427 tracing::warn!("chat: falling back from `{}` to `{}`", talk.agent, spec.id);
2428 fell_back_from.get_or_insert_with(|| talk.agent.clone());
2431 talk.agent = spec.id.clone();
2432 talk.seat = SeatState::new(SEAT, &spec.id, crate::rng::entropy());
2433 }
2434 let resuming = agent::has_session(spec.kind, &talk.seat, cfg.graph.sessions);
2435 let first_ever = talk.turns.len() <= 1;
2436 let body = if talk.seat.turns == 0 && first_ever {
2437 format!(
2438 "{}\n\n# Operator\n\n{text}{last_note}",
2439 briefing_for(
2440 &talk.repo,
2441 &cfg.graph.language,
2442 cfg.talk.allow_write,
2443 persona.as_ref(),
2444 operator_name,
2445 talk.implementers
2446 )
2447 )
2448 } else if talk.seat.turns == 0 {
2449 format!(
2452 "{}\n\n{}\n\n# Operator\n\n{text}{last_note}",
2453 briefing_for(
2454 &talk.repo,
2455 &cfg.graph.language,
2456 cfg.talk.allow_write,
2457 persona.as_ref(),
2458 operator_name,
2459 talk.implementers
2460 ),
2461 transcript(talk, store)
2462 )
2463 } else if resuming {
2464 let posts = posts_since_last_reply(talk).unwrap_or_default();
2465 format!("{persona_update}{filing_update}{posts}{text}{last_note}")
2466 } else {
2467 let mut standing = match (&persona, talk.persona_dirty) {
2471 (Some(p), false) => format!("{}\n", crate::persona::section_for(p, operator_name)),
2472 _ => persona_update.clone(),
2473 };
2474 if let (None, Some(n)) = (&persona, operator_name) {
2477 standing.push_str(&format!(
2478 "# Addressing the operator\n{}\n",
2479 crate::persona::addressing(n)
2480 ));
2481 }
2482 if talk.implementers_dirty || talk.implementers != 1 {
2483 standing.push_str(&format!("{}\n\n", filing_update_block(talk.implementers)));
2484 }
2485 format!("{}\n\n{standing}{text}{last_note}", transcript(talk, store))
2486 };
2487 let current_approvals = questions
2490 .as_ref()
2491 .map(|store| {
2492 store
2493 .list()
2494 .iter()
2495 .filter(|q| {
2496 q.status.open()
2497 && q.node == crate::land::APPROVAL_NODE
2498 && q.consult.as_ref().is_some_and(|c| c.talk == talk.id)
2499 })
2500 .map(crate::prompt::chat_consult)
2501 .collect::<Vec<_>>()
2502 .join("\n\n")
2503 })
2504 .unwrap_or_default();
2505 let body = if current_approvals.is_empty() {
2506 body
2507 } else {
2508 format!("{current_approvals}\n\n# Current operator message\n\n{body}")
2509 };
2510 let attempt_stem = if n == 0 {
2511 stem.clone()
2512 } else {
2513 format!("{stem}-{}", spec.id)
2514 };
2515 let inv = Invocation {
2516 cwd: &talk.repo,
2517 prompt: &body,
2518 timeout: turn_timeout(cfg),
2519 allow_write,
2524 unsandboxed,
2525 sessions: cfg.graph.sessions,
2526 artifacts: &artifacts,
2527 stem: &attempt_stem,
2528 run: &talk.id,
2531 node: crate::queue::CHAT_NODE,
2532 cache_dir: cache_dir.as_deref(),
2533 attachments: &attachment_paths,
2534 writable: &consult_roots,
2535 };
2536 let result = agent::invoke(spec, &mut talk.seat, &inv).await;
2537 let advance = agent::chain_advances(&result);
2538 if n == 0 || !advance {
2539 outcome = Some(result);
2540 } else {
2541 tracing::warn!("chat: fallback agent `{}` also failed", spec.id);
2543 }
2544 if !advance {
2545 break;
2546 }
2547 }
2548 if outcome.as_ref().is_some_and(agent::chain_advances) {
2549 if let Some((id, seat)) = first_try {
2552 talk.agent = id;
2553 talk.seat = seat;
2554 fell_back_from = None;
2555 }
2556 }
2557 let outcome = outcome.expect("a chain holds at least one agent");
2558 let note = |why: String| Turn {
2559 sender: Some(Sender::Magi),
2560 breaks: None,
2561 messages: Vec::new(),
2562 posted_by: None,
2563 who: Who::Agent,
2564 body: format!("{MAGI_NOTE}{why}"),
2565 at: Timestamp::now(),
2566 attachments: Vec::new(),
2567 usage: None,
2568 };
2569 let (reply, failure) = match outcome {
2570 Err(e) => (
2571 note(format!("could not run agent `{}`: {e}", talk.agent)),
2572 Some(format!("could not run agent `{}`: {e}", talk.agent)),
2573 ),
2574 Ok(out) if out.quota_exhausted() => {
2575 let reset = out
2576 .quota
2577 .as_ref()
2578 .and_then(|q| q.reset.clone())
2579 .map_or_else(String::new, |r| format!(" (resets {r})"));
2580 let why = format!(
2581 "agent `{}` is out of quota{reset}; your message is saved, so \
2582 say it again when the window reopens",
2583 talk.agent
2584 );
2585 (note(why.clone()), Some(why))
2586 }
2587 Ok(out) if out.timed_out => {
2588 let why = format!(
2589 "agent `{}` did not answer within {}s; your message is saved",
2590 talk.agent,
2591 turn_timeout(cfg).as_secs()
2592 );
2593 (note(why.clone()), Some(why))
2594 }
2595 Ok(out) if !out.usable() => {
2596 let why = format!(
2597 "agent `{}` produced no answer (exit {}); your message is saved",
2598 talk.agent,
2599 out.exit_code
2600 .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
2601 );
2602 (note(why.clone()), Some(why))
2603 }
2604 Ok(out) => (
2605 Turn {
2606 sender: Some(Sender::Persona),
2607 breaks: None,
2608 messages: Vec::new(),
2609 posted_by: None,
2610 who: Who::Agent,
2611 body: out.text.trim().to_owned(),
2612 at: Timestamp::now(),
2613 attachments: Vec::new(),
2614 usage: out.context_tokens.map(|context_tokens| TurnUsage {
2617 context_tokens,
2618 agent: talk.agent.clone(),
2619 model: cfg
2620 .agents
2621 .iter()
2622 .find(|a| a.id == talk.agent)
2623 .and_then(|a| a.model.clone()),
2624 }),
2625 },
2626 None,
2627 ),
2628 };
2629
2630 let _guard = store.guard()?;
2642 let Ok(fresh) = store.get(&talk.id) else {
2648 return Ok(());
2649 };
2650 talk.status = fresh.status;
2651 talk.pending = fresh.pending;
2655 talk.pending_breaks = fresh.pending_breaks;
2656 talk.pending_messages = fresh.pending_messages;
2657 talk.pending_attachments = fresh.pending_attachments;
2658 talk.notified = fresh.notified;
2659 if fresh.turns.len() > talk.turns.len() {
2661 let posted: Vec<Turn> = fresh.turns[talk.turns.len()..]
2662 .iter()
2663 .filter(|t| t.posted_by.is_some())
2664 .cloned()
2665 .collect();
2666 talk.turns.extend(posted);
2667 }
2668 if let Some(from) = fell_back_from.filter(|_| failure.is_none()) {
2669 talk.turns.push(note(format!(
2672 "agent changed from {from} to {} (fallback)",
2673 talk.agent
2674 )));
2675 }
2676 if failure.is_none() {
2679 talk.persona_dirty = false;
2680 talk.implementers_dirty = false;
2681 talk.posts_read_through = Some(prompt_turns);
2682 }
2683 talk.turns.push(reply);
2684 if let Err(put_err) = store.put(talk) {
2685 let lost = talk.turns.pop().expect("just pushed above");
2694 let stash = stash_lost_turn(store, &talk.id, &stem, &lost);
2695 let why = match &stash {
2696 Ok(path) => format!(
2697 "agent `{}` answered, but the reply could not be saved to \
2698 this conversation ({put_err:#}); the raw text was kept at \
2699 {} - your message is saved, ask again",
2700 talk.agent,
2701 path.display()
2702 ),
2703 Err(stash_err) => format!(
2704 "agent `{}` answered, but the reply could not be saved to \
2705 this conversation ({put_err:#}), and it could not be kept \
2706 anywhere else either ({stash_err:#}); your message is \
2707 saved, ask again",
2708 talk.agent
2709 ),
2710 };
2711 talk.turns.push(note(why.clone()));
2712 return match store.put(talk) {
2719 Ok(()) => bail!("{why}"),
2720 Err(note_err) => {
2721 talk.turns.pop();
2741 Err(note_err).context(why)
2742 }
2743 };
2744 }
2745
2746 match failure {
2747 Some(why) => bail!("{why}"),
2748 None => Ok(()),
2749 }
2750}
2751
2752fn transcript(talk: &Talk, store: &Talks) -> String {
2755 let mut out = String::from(
2756 "This conversation cannot resume on the CLI's side, so here is \
2757 everything said so far; answer only the last message.\n",
2758 );
2759 for t in &talk.turns {
2760 let who = match t.who {
2761 Who::Operator => "operator",
2762 Who::Agent if t.body.starts_with(MAGI_NOTE) => "magi",
2763 Who::Agent => "you",
2764 };
2765 out.push_str(&format!(
2766 "\n## {who}\n\n{}\n",
2767 message_prompt(t, talk, store).trim()
2768 ));
2769 out.push_str(&attachment_note(store, &talk.id, &t.attachments));
2770 }
2771 out
2772}
2773
2774fn attachment_note(store: &Talks, talk_id: &str, attachments: &[Attachment]) -> String {
2779 if attachments.is_empty() {
2780 return String::new();
2781 }
2782 let mut out = String::from(
2783 "\n\nThe operator attached the image(s) below to this message. Open \
2784 and look at each one before you answer.\n",
2785 );
2786 for att in attachments {
2787 if let Some(path) = store.attachment_path(talk_id, att) {
2788 out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
2789 }
2790 }
2791 out.push('\n');
2792 out
2793}
2794
2795pub(crate) fn turn_access(talk_allow_write: bool, consulted: bool) -> (bool, bool) {
2806 (talk_allow_write || consulted, talk_allow_write)
2807}
2808
2809pub fn briefing(repo: &Path, language: &str, allow_write: bool) -> String {
2825 briefing_with(repo, language, allow_write, None, None)
2826}
2827
2828pub fn briefing_with(
2833 repo: &Path,
2834 language: &str,
2835 allow_write: bool,
2836 persona: Option<&crate::persona::Persona>,
2837 operator_name: Option<&str>,
2838) -> String {
2839 briefing_for(repo, language, allow_write, persona, operator_name, 1)
2840}
2841
2842pub const MAX_IMPLEMENTERS: u8 = 3;
2844
2845fn solo_implementers() -> u8 {
2846 1
2847}
2848
2849fn filing_flag(n: u8) -> String {
2852 if n <= 1 {
2853 "--solo".to_owned()
2854 } else {
2855 format!("--implementers {n}")
2856 }
2857}
2858
2859pub fn filing_update_block(n: u8) -> String {
2862 let flag = filing_flag(n);
2863 if n <= 1 {
2864 format!(
2865 "# Task filing update\n\nThe operator changed how many implementers tasks use. \
2866 From now on file with `magi task add {flag} --repo <repo> <instruction>` \
2867 (one implementer, straight into review).\n"
2868 )
2869 } else {
2870 format!(
2871 "# Task filing update\n\nThe operator changed how many implementers tasks use. \
2872 From now on file with `magi task add {flag} --repo <repo> <instruction>` \
2873 ({n} independent implementations compete). Do not pass `--solo` \
2874 together with `--implementers`; drop any earlier `--solo`.\n"
2875 )
2876 }
2877}
2878
2879pub fn check_implementers(n: u8, cfg: &crate::config::Config) -> std::result::Result<u8, String> {
2882 if !(1..=MAX_IMPLEMENTERS).contains(&n) {
2883 return Err(format!(
2884 "implementers must be 1..={MAX_IMPLEMENTERS}, got {n}"
2885 ));
2886 }
2887 if n > 1 {
2888 let mut cfg = cfg.clone();
2889 crate::queue::RunOverrides {
2890 candidates: Some(usize::from(n)),
2891 ..Default::default()
2892 }
2893 .apply(&mut cfg);
2894 cfg.resolve_roles()
2895 .map_err(|e| format!("{n} implementers is not allowed here: {e:#}"))?;
2896 }
2897 Ok(n)
2898}
2899
2900pub fn switch_implementers(talk: &mut Talk, store: &Talks, n: u8) -> Result<bool> {
2903 let _guard = store.guard()?;
2904 let mut fresh = store
2905 .get(&talk.id)
2906 .with_context(|| format!("talk {} was deleted", talk.short()))?;
2907 if fresh.implementers == n {
2908 *talk = fresh;
2909 return Ok(false);
2910 }
2911 let from = std::mem::replace(&mut fresh.implementers, n);
2912 fresh.implementers_dirty = true;
2913 fresh.turns.push(Turn {
2914 sender: Some(Sender::Magi),
2915 breaks: None,
2916 messages: Vec::new(),
2917 posted_by: None,
2918 who: Who::Agent,
2919 body: format!("{MAGI_NOTE}implementers changed from {from} to {n}"),
2920 at: Timestamp::now(),
2921 attachments: Vec::new(),
2922 usage: None,
2923 });
2924 store.put(&mut fresh)?;
2925 *talk = fresh;
2926 Ok(true)
2927}
2928
2929pub fn briefing_for(
2932 repo: &Path,
2933 language: &str,
2934 allow_write: bool,
2935 persona: Option<&crate::persona::Persona>,
2936 operator_name: Option<&str>,
2937 implementers: u8,
2938) -> String {
2939 let flag = filing_flag(implementers);
2940 let shape = if implementers <= 1 {
2941 "Use --solo: it runs the task through one implementer \
2942 straight into review instead of the usual multi-agent competition, \
2943 which is the right shape for a change this conversation has already \
2944 settled, rather than one still worth several independent takes."
2945 .to_owned()
2946 } else {
2947 format!(
2948 "Use --implementers {implementers}: it has {implementers} \
2949 implementers work on the task independently and compete, which \
2950 is the right shape when several independent takes are worth \
2951 having. Never add --solo to it; the two do not go together."
2952 )
2953 };
2954 let write_policy = if allow_write {
2955 "Write access is enabled for this conversation (`allow_write = \
2956 true`), so you may write files - but only a small, \
2957 already-decided edit the operator names outright in this \
2958 conversation, not an implementation. This is a permission on the \
2959 conversation as a whole, not a property of whichever repository \
2960 it happened to start in: if the operator names a different \
2961 repository for that small edit, the policy allows it there too. \
2962 Your own tool may still confine writes to the repository this \
2963 conversation started in regardless - if a write elsewhere is \
2964 refused, say so plainly rather than working around it. Once you \
2965 have made an edit, say plainly what you edited. Anything bigger, \
2966 or anything still open-ended, still goes through the queue below \
2967 rather than being done here."
2968 } else {
2969 "Do not write files. Implementing a change is not this \
2970 conversation's job; a separate, blind competition of agents does \
2971 that, and a repository this conversation has already edited would \
2972 make their diffs unjudgeable."
2973 };
2974 let mut out = format!(
2975 "You are magi's standing conversation partner for its operator, who \
2976 usually has this open on a phone. Keep replies short: no preamble, \
2977 no restating what they just said.\n\n\
2978 # Repository\n\n{repo}\n\n\
2979 You may look around: read files, run shell commands, search history, \
2980 run tests - whatever answers the question. {write_policy}\n\n\
2981 A short, command-shaped message (\"list\", \"info <id>\", \"show \
2982 3cbf\") is almost always the operator asking you to look something \
2983 up, not an instruction to file - answer it yourself with `magi \
2984 list`, `magi show <id>`, `magi task list`, or the like, the same way \
2985 you would answer any other question in this conversation.\n\n\
2986 # Answering existing questions\n\n\
2987 A consultation destination does not restrict an explicit owner hold. \
2988 For a guarded hold, the latest owner message may be `<question-id>: hold`, \
2989 or explicitly instruct you to answer each immediately preceding recommendation \
2990 (for example, `それぞれ回答して` or `answer each as recommended`). \
2991 Present recommendations as one row per question: `<question-id>: hold` \
2992 or `| <question-id> | hold | reason |`. Use full IDs or unique short IDs. \
2993 Then run `magi answer <question-id> --reply hold --quote <entire latest owner message>`. \
2994 This limited question-store write is available even for a question consulted \
2995 elsewhere or never consulted. It never authorizes merge, public posting, PR closure, \
2996 repair or another driver. For those actions retain the specific confirmation and \
2997 supported execution requirements; report exactly what confirmation is missing. \
2998 Recommendations, thanks alone and generated messages are not owner instructions.\n\n\
2999 # When the operator wants something done\n\n\
3000 Run:\n\n\
3001 magi task add {flag} --repo {repo} <instruction>\n\n\
3002 and tell the operator the task id it prints, so they can follow it \
3003 from the Queue. If it refuses with a duplicate warning (the \
3004 instruction names a branch, commit or pull request that an \
3005 unfinished task, run or PR already owns), do not repeat it with \
3006 --force yourself: tell the operator what it matched and let them \
3007 decide. Write <instruction> so that an implementer who has \
3008 never seen this conversation can act on it alone - it is everything \
3009 they get. {shape}\n\n\
3010 If the operator asks for something in a different repository, \
3011 --repo does not have to be a full path: --repo owner/repo (or just \
3012 repo, when that is unambiguous) is resolved against local checkouts \
3013 the same way `magi repos` lists them. If the command fails because \
3014 nothing matches or more than one checkout shares that name, ask the \
3015 operator which repository they mean (or run `magi repos` yourself \
3016 to see the candidates) rather than guessing.\n\n\
3017 You cannot wait, and you cannot send the operator a message on your \
3018 own: you only speak when they write. A task you file reports back \
3019 into this conversation automatically when it finishes, is held or \
3020 fails, as a message headed \"{notice}\".\n\n\
3021 For a background command you run yourself, register it with \
3022 `magi talk watch --timeout 900 -- <executable> <arguments...>`, for \
3023 example `magi talk watch -- cargo test --locked`. It uses your \
3024 current directory; arguments are passed literally. For shell syntax, \
3025 explicitly call `sh -c`. Only after watch registration succeeds may \
3026 you promise a completion report. Watches default to 900 seconds, \
3027 accept at most 3600 seconds, and share four concurrent slots. Their \
3028 automatic reports resume this conversation through its own turn. \
3029 Never promise to report back on work that is neither a filed task \
3030 nor a successfully registered watch.\n\n\
3031 The current state of the code is whatever origin/main holds, not \
3032 whatever a working tree shows: a primary checkout often lags \
3033 upstream, sits on a detached HEAD and carries uncommitted changes. \
3034 Before answering about code, run `git fetch origin` in that \
3035 repository if it is cheap, then read through \
3036 `git show origin/main:<path>` or `git grep <pattern> origin/main`. \
3037 If the working tree differs, say so; if the fetch fails, say that \
3038 too, so the operator knows the answer may be stale.\n\n\
3039 If the operator attached an image (a screenshot, say) that the task \
3040 is about, pass it with `--attach <path>`, using the absolute path \
3041 the turn's attachment note gives; repeat the flag for several. \
3042 `magi task add {flag} --attach <path> <instruction>` copies the \
3043 file into the task, so the implementer receives it. Do not paste the \
3044 path into <instruction> instead: deleting this conversation deletes \
3045 its attachments, and then that path reaches no one.\n",
3046 repo = repo.display(),
3047 notice = crate::prompt::CHAT_TASK_NOTICE_HEADING,
3048 );
3049 out.push_str(&language_note(language));
3050 match (persona, operator_name) {
3051 (Some(p), n) => out.push_str(&crate::persona::section_for(p, n)),
3052 (None, Some(n)) => {
3053 out.push_str("\n# Addressing the operator\n");
3054 out.push_str(&crate::persona::addressing(n));
3055 }
3056 (None, None) => {}
3057 }
3058 out
3059}
3060
3061fn language_note(language: &str) -> String {
3064 if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
3065 String::new()
3066 } else {
3067 format!("\nHold this conversation in {language}.\n")
3068 }
3069}
3070
3071pub fn tasks_of(queue: &Queue, talk_id: &str) -> Vec<Task> {
3078 let mut tasks: Vec<Task> = queue
3079 .list()
3080 .into_iter()
3081 .filter(|t| matches!(&t.source, Source::Agent { run, .. } if run == talk_id))
3082 .collect();
3083 tasks.sort_unstable_by(|a, b| a.id.cmp(&b.id));
3084 tasks
3085}
3086
3087fn read_path(path: &Path) -> Result<Talk> {
3088 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
3089 let mut talk: Talk =
3090 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3091 for turn in &mut talk.turns {
3092 if turn.who == Who::Operator && turn.messages.is_empty() && turn.attachments.is_empty() {
3093 turn.messages = plain_messages(&turn.body, turn.breaks.as_deref());
3094 }
3095 }
3096 if talk.pending_messages.is_empty() && talk.pending_attachments.is_empty() {
3097 talk.pending_messages = plain_messages(&talk.pending, talk.pending_breaks.as_deref());
3098 }
3099 Ok(talk)
3100}
3101
3102fn plain_messages(body: &str, breaks: Option<&[usize]>) -> Vec<Message> {
3104 let Some(breaks) = breaks else {
3105 return Vec::new();
3106 };
3107 if body.is_empty() {
3108 return Vec::new();
3109 }
3110 let mut messages = Vec::new();
3111 let mut start = 0;
3112 for end in breaks
3113 .iter()
3114 .copied()
3115 .chain(std::iter::once(body.len() + 2))
3116 {
3117 let Some(text) = end.checked_sub(2).and_then(|end| body.get(start..end)) else {
3118 return Vec::new();
3119 };
3120 messages.push(Message {
3121 display: None,
3122 sender: None,
3123 body: text.to_owned(),
3124 attachments: Vec::new(),
3125 reply: None,
3126 });
3127 start = end;
3128 }
3129 messages
3130}
3131
3132const PUT_RETRIES: u32 = 5;
3135
3136fn write_atomic(tmp: &Path, path: &Path, body: &str) -> Result<()> {
3148 let mut last_err = None;
3149 for attempt in 0..PUT_RETRIES {
3150 if attempt > 0 {
3151 std::thread::sleep(Duration::from_millis(20 * u64::from(attempt)));
3152 }
3153 match try_write_atomic(tmp, path, body) {
3154 Ok(()) => return Ok(()),
3155 Err(e) => last_err = Some(e),
3156 }
3157 }
3158 Err(last_err.expect("the loop above always runs at least once"))
3159}
3160
3161fn try_write_atomic(tmp: &Path, path: &Path, body: &str) -> Result<()> {
3162 #[cfg(test)]
3163 if failpoint::take_forced_put_failure() {
3164 bail!("simulated write failure (test)");
3165 }
3166 std::fs::write(tmp, body).with_context(|| format!("write {}", tmp.display()))?;
3167 std::fs::rename(tmp, path).with_context(|| format!("replace {}", path.display()))?;
3168 Ok(())
3169}
3170
3171fn stash_lost_turn(store: &Talks, id: &str, stem: &str, reply: &Turn) -> Result<PathBuf> {
3176 let dir = store.artifacts_of(id);
3177 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
3178 let path = dir.join(format!("{stem}-lost.txt"));
3179 std::fs::write(&path, &reply.body).with_context(|| format!("write {}", path.display()))?;
3180 Ok(path)
3181}
3182
3183#[cfg(test)]
3190mod failpoint {
3191 use std::cell::Cell;
3192
3193 thread_local! {
3194 static FORCE_PUT_FAILURES: Cell<u32> = const { Cell::new(0) };
3195 static FORCE_NO_LINK: Cell<bool> = const { Cell::new(false) };
3196 }
3197
3198 pub(super) fn force_no_link(on: bool) {
3201 FORCE_NO_LINK.with(|c| c.set(on));
3202 }
3203
3204 pub(super) fn no_link_forced() -> bool {
3205 FORCE_NO_LINK.with(Cell::get)
3206 }
3207
3208 pub(super) fn force_put_failures(count: u32) {
3211 FORCE_PUT_FAILURES.with(|c| c.set(count));
3212 }
3213
3214 pub(super) fn take_forced_put_failure() -> bool {
3217 FORCE_PUT_FAILURES.with(|c| {
3218 let n = c.get();
3219 if n == 0 {
3220 false
3221 } else {
3222 c.set(n - 1);
3223 true
3224 }
3225 })
3226 }
3227}
3228
3229fn short(id: &str) -> &str {
3230 id.split('-').next_back().unwrap_or(id)
3231}
3232
3233fn new_id() -> String {
3234 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3235 let seed = crate::rng::entropy();
3236 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3237}
3238
3239fn attachment_ext(mime: &str) -> Option<&'static str> {
3244 match mime {
3245 "image/png" => Some("png"),
3246 "image/jpeg" => Some("jpg"),
3247 "image/gif" => Some("gif"),
3248 "image/webp" => Some("webp"),
3249 _ => None,
3250 }
3251}
3252
3253pub fn valid_attachment_id(id: &str) -> bool {
3258 id.len() == 32
3259 && id
3260 .bytes()
3261 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
3262}
3263
3264fn new_attachment_id() -> String {
3268 let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
3269 format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
3270}
3271
3272#[cfg(test)]
3273mod tests {
3274 #[test]
3275 fn turn_access_lifts_the_sandbox_only_for_the_talk_opt_in() {
3276 use super::turn_access;
3277 assert_eq!(turn_access(false, false), (false, false));
3278 assert_eq!(turn_access(true, false), (true, true));
3279 assert_eq!(turn_access(false, true), (true, false));
3281 assert_eq!(turn_access(true, true), (true, true));
3282 }
3283
3284 #[test]
3285 fn the_briefing_points_at_origin_main_not_the_working_tree() {
3286 let b = briefing(Path::new("/r"), "en", false);
3287 assert!(b.contains("origin/main"));
3288 assert!(b.contains("git show origin/main:"));
3289 }
3290 use std::collections::BTreeMap;
3291
3292 use crate::config::{AgentChoice, AgentKind, AgentSpec, Graph};
3293 use crate::queue::{Queue, Source, Task};
3294
3295 use super::*;
3296
3297 fn ctx_agent(id: &str, model: Option<&str>) -> AgentSpec {
3298 AgentSpec {
3299 id: id.to_owned(),
3300 kind: AgentKind::Command,
3301 model: model.map(str::to_owned),
3302 command: Vec::new(),
3303 extra_args: Vec::new(),
3304 env: BTreeMap::new(),
3305 prompt_delivery: None,
3306 }
3307 }
3308
3309 fn ctx_talk(agent: &str, turns: Vec<Turn>) -> Talk {
3310 Talk {
3311 posts_read_through: None,
3312 pending_breaks: None,
3313 pending_messages: Vec::new(),
3314 schema: SCHEMA,
3315 id: "20260904-014455-ab12".to_owned(),
3316 repo: PathBuf::from("."),
3317 agent: agent.to_owned(),
3318 status: TalkStatus::Open,
3319 turns,
3320 pending: String::new(),
3321 pending_attachments: Vec::new(),
3322 fallback: false,
3323 persona: String::new(),
3324 persona_dirty: false,
3325 implementers: 1,
3326 implementers_dirty: false,
3327 notified: Vec::new(),
3328 completion_notices: Vec::new(),
3329 created_at: Timestamp::now(),
3330 updated_at: Timestamp::now(),
3331 seat: SeatState::new(SEAT, agent, 1),
3332 }
3333 }
3334
3335 fn reply(body: &str, usage: Option<(u64, &str, Option<&str>)>) -> Turn {
3336 Turn {
3337 sender: None,
3338 breaks: None,
3339 messages: Vec::new(),
3340 posted_by: None,
3341 who: Who::Agent,
3342 body: body.to_owned(),
3343 at: Timestamp::now(),
3344 attachments: Vec::new(),
3345 usage: usage.map(|(t, a, m)| TurnUsage {
3346 context_tokens: t,
3347 agent: a.to_owned(),
3348 model: m.map(str::to_owned),
3349 }),
3350 }
3351 }
3352
3353 fn ctx_config(windows: &[(&str, u64)]) -> Config {
3354 Config {
3355 agents: vec![
3356 ctx_agent("small", Some("small-model")),
3357 ctx_agent("big", Some("big-model")),
3358 ctx_agent("plain", None),
3359 ],
3360 context_windows: windows.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect(),
3361 ..Config::default()
3362 }
3363 }
3364
3365 #[test]
3366 fn context_usage_computes_percent_and_warns_at_eighty() {
3367 let cfg = ctx_config(&[("small-model", 1000)]);
3368 let at = |tokens| {
3369 let t = ctx_talk(
3370 "small",
3371 vec![reply("hi", Some((tokens, "small", Some("small-model"))))],
3372 );
3373 context_usage(&t, Some(&cfg))
3374 };
3375 let u = at(799);
3376 assert_eq!((u.percent, u.warn, u.window), (Some(79), false, Some(1000)));
3377 let u = at(800);
3378 assert_eq!((u.percent, u.warn), (Some(80), true));
3379 let u = at(1500);
3380 assert_eq!((u.percent, u.warn), (Some(150), true));
3381 assert!(!u.since_switch);
3382 }
3383
3384 #[test]
3385 fn context_usage_is_unknown_without_usage_and_never_looks_back() {
3386 let cfg = ctx_config(&[("small-model", 1000)]);
3387 let t = ctx_talk(
3388 "small",
3389 vec![
3390 reply("old", Some((900, "small", Some("small-model")))),
3391 reply("new", None),
3392 ],
3393 );
3394 let u = context_usage(&t, Some(&cfg));
3395 assert!(u.estimated);
3397 assert_ne!(u.tokens, Some(900));
3398 assert!(u.tokens.is_some());
3399 let t = ctx_talk(
3401 "small",
3402 vec![
3403 reply("old", Some((900, "small", Some("small-model")))),
3404 reply("magi: could not run agent", None),
3405 ],
3406 );
3407 assert_eq!(context_usage(&t, Some(&cfg)).tokens, Some(900));
3408 assert_eq!(
3409 context_usage(&ctx_talk("small", Vec::new()), Some(&cfg)).tokens,
3410 None
3411 );
3412 }
3413
3414 #[test]
3415 fn estimate_counts_chars_both_sides_and_standing_prompt() {
3416 let mut t = ctx_talk("small", vec![reply("abcdefg", None)]);
3417 assert_eq!(estimate_context_tokens(&t, 0), Some(2)); let op = Turn {
3419 sender: None,
3420 who: Who::Operator,
3421 ..reply("abcdefg", None)
3422 };
3423 t.turns.push(op);
3424 assert_eq!(estimate_context_tokens(&t, 0), Some(4));
3425 assert!(
3426 estimate_context_tokens(&t, 700).unwrap() > estimate_context_tokens(&t, 0).unwrap()
3427 );
3428 let ja = ctx_talk("small", vec![reply("日本語日本語日", None)]);
3430 assert_eq!(estimate_context_tokens(&ja, 0), Some(2));
3431 let note = ctx_talk("small", vec![reply("magi: could not run agent", None)]);
3433 assert_eq!(estimate_context_tokens(¬e, 1000), None);
3434 assert_eq!(
3435 estimate_context_tokens(&ctx_talk("small", Vec::new()), 1000),
3436 None
3437 );
3438 }
3439
3440 #[test]
3441 fn context_usage_measured_wins_and_estimate_gets_percent_and_warn() {
3442 let cfg = ctx_config(&[("small-model", 1000)]);
3443 let t = ctx_talk(
3444 "small",
3445 vec![reply(
3446 &"x".repeat(5000),
3447 Some((10, "small", Some("small-model"))),
3448 )],
3449 );
3450 let u = context_usage(&t, Some(&cfg));
3451 assert_eq!((u.tokens, u.estimated), (Some(10), false));
3452 let t = ctx_talk("small", vec![reply(&"x".repeat(5000), None)]);
3453 let u = context_usage(&t, Some(&cfg));
3454 assert!(u.estimated && !u.since_switch);
3455 assert_eq!(u.window, Some(1000));
3456 assert!(u.warn && u.percent.unwrap() >= 80);
3457 let t = ctx_talk("small", vec![reply("hi", None)]);
3458 let u = context_usage(&t, Some(&cfg));
3459 assert!(u.estimated && u.percent.is_some());
3460 }
3461
3462 #[test]
3463 fn context_usage_without_a_window_shows_tokens_only() {
3464 let cfg = ctx_config(&[]);
3465 let t = ctx_talk("plain", vec![reply("hi", Some((5000, "plain", None)))]);
3467 let u = context_usage(&t, Some(&cfg));
3468 assert_eq!(
3469 (u.tokens, u.window, u.percent, u.warn),
3470 (Some(5000), None, None, false)
3471 );
3472 let t = ctx_talk(
3473 "small",
3474 vec![reply("hi", Some((5000, "small", Some("small-model"))))],
3475 );
3476 assert_eq!(context_usage(&t, Some(&cfg)).percent, None);
3477 assert_eq!(context_usage(&t, None).window, None);
3479 }
3480
3481 #[test]
3482 fn context_usage_switching_model_changes_the_denominator() {
3483 let cfg = ctx_config(&[("small-model", 1000), ("big-model", 10_000)]);
3484 let used = reply("hi", Some((900, "small", Some("small-model"))));
3485 let before = context_usage(&ctx_talk("small", vec![used.clone()]), Some(&cfg));
3486 assert_eq!(
3487 (before.percent, before.warn, before.since_switch),
3488 (Some(90), true, false)
3489 );
3490 let after = context_usage(&ctx_talk("big", vec![used]), Some(&cfg));
3493 assert_eq!(after.window, Some(10_000));
3494 assert_eq!(
3495 (after.percent, after.warn, after.since_switch),
3496 (Some(9), false, true)
3497 );
3498 assert_eq!(after.model.as_deref(), Some("big-model"));
3499 }
3500
3501 #[test]
3502 fn a_turn_recorded_before_usage_existed_still_reads() {
3503 let old = r#"{"who":"agent","body":"hi","at":"2026-09-04T01:44:55Z"}"#;
3504 let turn: Turn = serde_json::from_str(old).expect("old turn reads");
3505 assert!(turn.usage.is_none());
3506 let json = serde_json::to_string(&turn).expect("serialize");
3507 assert!(
3508 !json.contains("usage"),
3509 "absent usage is not written: {json}"
3510 );
3511 }
3512
3513 fn store() -> (tempfile::TempDir, Talks) {
3515 let tmp = tempfile::tempdir().expect("tempdir");
3516 let talks = Talks::at(tmp.path().join("talks"));
3517 (tmp, talks)
3518 }
3519
3520 fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
3524 let path = dir.join("mock-talk-agent.sh");
3525 std::fs::write(&path, script).expect("write mock");
3526 AgentSpec {
3527 id: "mock".to_owned(),
3528 kind: AgentKind::Command,
3529 model: None,
3530 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
3531 extra_args: Vec::new(),
3532 env,
3533 prompt_delivery: None,
3534 }
3535 }
3536
3537 fn config(spec: AgentSpec) -> Config {
3538 Config {
3539 agents: vec![spec],
3540 graph: Graph {
3541 language: "en".to_owned(),
3542 ..Graph::default()
3543 },
3544 ..Config::default()
3545 }
3546 }
3547
3548 const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
3550
3551 const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
3553
3554 const ECHO: &str = "#!/bin/sh\ncat\n";
3557
3558 fn env(reply: &str) -> BTreeMap<String, String> {
3559 BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
3560 }
3561
3562 #[test]
3563 fn the_frozen_json_field_names_round_trip_through_disk() {
3564 let (tmp, talks) = store();
3565 let mut talk = Talk {
3566 posts_read_through: None,
3567 pending_breaks: None,
3568 pending_messages: Vec::new(),
3569 schema: SCHEMA,
3570 id: "20260904-014455-ab12".to_owned(),
3571 repo: tmp.path().to_owned(),
3572 agent: "sonnet".to_owned(),
3573 status: TalkStatus::Open,
3574 turns: Vec::new(),
3575 pending: String::new(),
3576 pending_attachments: Vec::new(),
3577 fallback: false,
3578 persona: String::new(),
3579 persona_dirty: false,
3580 implementers: 1,
3581 implementers_dirty: false,
3582 notified: Vec::new(),
3583 completion_notices: Vec::new(),
3584 created_at: Timestamp::now(),
3585 updated_at: Timestamp::now(),
3586 seat: SeatState::new(SEAT, "sonnet", 7),
3587 };
3588 talks.put(&mut talk).expect("put");
3589
3590 let raw = std::fs::read_to_string(talks.path_of(&talk.id)).expect("read back");
3591 let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
3592 for field in [
3593 "schema",
3594 "id",
3595 "repo",
3596 "agent",
3597 "status",
3598 "turns",
3599 "created_at",
3600 "updated_at",
3601 ] {
3602 assert!(v.get(field).is_some(), "missing field `{field}`");
3603 }
3604 assert_eq!(v["schema"], 1);
3605 assert_eq!(v["status"], "open");
3606
3607 let back = talks.get(&talk.id).expect("get");
3608 assert_eq!(back.posts_read_through, None);
3609 assert_eq!(back.id, talk.id);
3610 assert_eq!(back.status, TalkStatus::Open);
3611 }
3612
3613 #[test]
3614 fn opening_a_talk_takes_no_agent_turn() {
3615 let (tmp, talks) = store();
3616 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
3620 let cfg = config(spec);
3621
3622 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
3623 assert_eq!(talk.status, TalkStatus::Open);
3624 assert!(talk.turns.is_empty(), "nothing has been said yet");
3625
3626 let on_disk = talks.get(&talk.id).expect("get");
3627 assert_eq!(on_disk.turns.len(), 0);
3628 }
3629
3630 #[test]
3638 fn chatter_wins_when_set_and_falls_back_to_pick_s_default_order_otherwise() {
3639 let (tmp, talks) = store();
3640 let first_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
3641 let mut chatter_spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
3642 chatter_spec.id = "chatter-mock".to_owned();
3643
3644 let mut cfg = Config {
3645 agents: vec![first_spec.clone(), chatter_spec.clone()],
3646 graph: Graph {
3647 language: "en".to_owned(),
3648 ..Graph::default()
3649 },
3650 ..Config::default()
3651 };
3652 cfg.roles.chatter = Some(chatter_spec.id.as_str().into());
3653
3654 let talk =
3655 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter set");
3656 assert_eq!(talk.agent, chatter_spec.id, "an explicit chatter must win");
3657
3658 cfg.roles.chatter = None;
3659 let fallback =
3660 begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin with chatter unset");
3661 assert_eq!(
3662 fallback.agent, first_spec.id,
3663 "unset chatter must fall back to agent::pick's own default order"
3664 );
3665 }
3666
3667 #[test]
3670 fn a_talk_recorded_without_attachments_still_reads() {
3671 let (tmp, talks) = store();
3672 let path = talks.path_of("20260904-014455-ab12");
3673 std::fs::create_dir_all(talks.root()).expect("talks dir");
3674 std::fs::write(
3675 &path,
3676 serde_json::json!({
3677 "schema": 1,
3678 "id": "20260904-014455-ab12",
3679 "repo": tmp.path(),
3680 "agent": "sonnet",
3681 "status": "open",
3682 "turns": [
3683 { "who": "operator", "body": "still there?",
3684 "at": Timestamp::now().to_string() },
3685 ],
3686 "created_at": Timestamp::now().to_string(),
3687 "updated_at": Timestamp::now().to_string(),
3688 "seat": SeatState::new(SEAT, "sonnet", 7),
3689 })
3690 .to_string(),
3691 )
3692 .expect("write pre-attachments talk");
3693
3694 let talk = talks.get("20260904-014455-ab12").expect("must still read");
3695 assert!(talk.turns[0].attachments.is_empty());
3696 }
3697
3698 fn lease_store() -> (tempfile::TempDir, Talks, Talks) {
3699 let tmp = tempfile::TempDir::new().expect("tmp");
3700 let root = tmp.path().join("talks");
3701 (tmp, Talks::at(root.clone()), Talks::at(root))
3702 }
3703
3704 #[test]
3705 fn two_starters_on_one_talk_one_wins_and_the_other_is_refused() {
3706 let (_tmp, a, b) = lease_store();
3707 let won = a.claim_turn("t1").expect("claim").expect("first wins");
3708 assert!(
3709 b.claim_turn("t1").expect("claim").is_none(),
3710 "second is refused"
3711 );
3712 assert!(b.turn_held("t1"));
3713 assert!(
3714 b.claim_turn("t2").expect("claim").is_some(),
3715 "other talks are free"
3716 );
3717 drop(won);
3718 }
3719
3720 #[test]
3721 fn a_stale_lease_is_taken_over_and_the_old_guard_cannot_release_it() {
3722 let (_tmp, a, b) = lease_store();
3723 let old = a.claim_turn("t1").expect("claim").expect("held");
3724 let later = Timestamp::now()
3725 .checked_add(jiff::SignedDuration::from_secs(
3726 crate::ask::LEASE_TTL.as_secs() as i64 + 5,
3727 ))
3728 .expect("later");
3729 let new = b
3730 .claim_turn_at("t1", later)
3731 .expect("claim")
3732 .expect("a stale lease is taken over");
3733 drop(old);
3734 assert!(a.turn_held("t1"), "the old guard left the new lease alone");
3735 assert!(new.beat().expect("beat"), "the new owner still beats");
3736 drop(new);
3737 assert!(!a.turn_held("t1"));
3738 }
3739
3740 #[tokio::test]
3741 async fn a_turn_whose_lease_was_taken_over_is_stopped() {
3742 let (_tmp, a, b) = lease_store();
3743 let old = a.claim_turn("t1").expect("claim").expect("held");
3744 let later = Timestamp::now()
3745 .checked_add(jiff::SignedDuration::from_secs(
3746 crate::ask::LEASE_TTL.as_secs() as i64 + 5,
3747 ))
3748 .expect("later");
3749 let _new = b
3750 .claim_turn_at("t1", later)
3751 .expect("claim")
3752 .expect("taken over");
3753 let out = old
3754 .beating_every(Duration::from_millis(10), std::future::pending::<()>())
3755 .await;
3756 assert!(out.is_err(), "the displaced turn must stop, not run on");
3757 }
3758
3759 #[tokio::test]
3760 async fn a_turn_that_finishes_is_returned_and_keeps_its_lease_beating() {
3761 let (_tmp, a, _b) = lease_store();
3762 let lease = a.claim_turn("t1").expect("claim").expect("held");
3763 let out = lease
3764 .beating_every(Duration::from_millis(5), async {
3765 tokio::time::sleep(Duration::from_millis(40)).await;
3766 7
3767 })
3768 .await
3769 .expect("still ours");
3770 assert_eq!(out, 7);
3771 assert!(a.turn_held("t1"));
3772 }
3773
3774 #[test]
3775 fn an_unreadable_lease_counts_as_stale() {
3776 let (_tmp, a, b) = lease_store();
3777 std::fs::create_dir_all(a.root()).expect("dir");
3778 std::fs::write(a.turn_path("t1"), "not json").expect("write");
3779 assert!(!a.turn_held("t1"));
3780 assert!(b.claim_turn("t1").expect("claim").is_some());
3781 }
3782
3783 #[test]
3784 fn without_hard_links_a_claim_is_still_exclusive_and_a_young_placeholder_blocks() {
3785 let (_tmp, a, b) = lease_store();
3786 failpoint::force_no_link(true);
3787 let held = a.claim_turn("t1").expect("claim").expect("first wins");
3788 assert!(b.claim_turn("t1").expect("claim").is_none());
3789 assert!(a.turn_held("t1"));
3790 drop(held);
3791 assert!(!a.turn_held("t1"));
3792 let path = a.turn_path("t1");
3794 assert!(create_exclusive(&path, "").expect("placeholder"));
3795 assert!(b.claim_turn("t1").expect("claim").is_none(), "young: held");
3796 age_file(&path);
3797 assert!(b.claim_turn("t1").expect("claim").is_some(), "old: stale");
3798 let lease = a.turn_path("t2");
3800 let lock = TurnLock::take(&lease).expect("take").expect("lock");
3801 assert!(TurnLock::take(&lease).expect("take").is_none());
3802 drop(lock);
3803 assert!(TurnLock::take(&lease).expect("take").is_some());
3804 failpoint::force_no_link(false);
3805 }
3806
3807 #[test]
3808 fn remove_if_carries_removes_only_the_expected_content() {
3809 let (_tmp, a, _b) = lease_store();
3810 std::fs::create_dir_all(a.root()).expect("dir");
3811 let p = a.root().join("x.turn.lock");
3812 std::fs::write(&p, "mine").expect("write");
3813 assert!(!remove_if_carries(&p, "other"));
3814 assert_eq!(std::fs::read_to_string(&p).expect("kept"), "mine");
3815 assert!(remove_if_carries(&p, "mine"));
3816 assert!(!p.exists());
3817 assert!(!remove_if_carries(&p, "mine"), "absent is not a removal");
3818 }
3819
3820 #[test]
3821 fn a_dropped_lock_does_not_remove_a_lock_taken_over_since() {
3822 let (_tmp, a, _b) = lease_store();
3823 std::fs::create_dir_all(a.root()).expect("dir");
3824 let lease = a.turn_path("t1");
3825 let lock = TurnLock::take(&lease).expect("take").expect("lock");
3826 let path = lock.path.clone();
3827 std::fs::write(&path, "someone-else").expect("replace");
3828 drop(lock);
3829 assert_eq!(
3830 std::fs::read_to_string(&path).expect("kept"),
3831 "someone-else"
3832 );
3833 }
3834
3835 #[test]
3836 fn concurrent_takeovers_of_a_stale_lease_have_one_winner() {
3837 let (_tmp, a, _b) = lease_store();
3838 drop(a.claim_turn("t1").expect("claim").expect("held"));
3839 std::fs::write(
3840 a.turn_path("t1"),
3841 serde_json::to_string(&TurnRecord {
3842 token: "gone".into(),
3843 pid: 1,
3844 beat_at: Timestamp::from_second(1).expect("ts"),
3845 })
3846 .expect("json"),
3847 )
3848 .expect("write");
3849 let wins: Vec<_> = std::thread::scope(|sc| {
3850 let hs: Vec<_> = (0..8)
3851 .map(|_| {
3852 let s = a.clone();
3853 sc.spawn(move || s.claim_turn("t1").expect("claim"))
3854 })
3855 .collect();
3856 hs.into_iter().map(|h| h.join().expect("join")).collect()
3857 });
3858 assert_eq!(wins.iter().filter(|w| w.is_some()).count(), 1);
3859 }
3860
3861 fn age_file(path: &Path) {
3862 let f = std::fs::OpenOptions::new()
3863 .write(true)
3864 .open(path)
3865 .expect("open");
3866 f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
3867 .expect("age");
3868 }
3869
3870 #[test]
3871 fn a_late_taker_of_a_broken_lock_cannot_disturb_its_replacement() {
3872 let (_tmp, a, _b) = lease_store();
3873 let lease = a.turn_path("t1");
3874 let lock = lease.with_extension("turn.lock");
3875 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3876 std::fs::write(&lock, "t1-dead").expect("dead lock");
3877 age_file(&lock);
3878 let b = TurnLock::take(&lease).expect("take").expect("b wins");
3880 let fresh = std::fs::read_to_string(&lock).expect("read");
3881 assert_eq!(fresh, b.token);
3882 let ticket = std::fs::read_dir(lock.parent().expect("dir"))
3885 .expect("dir")
3886 .flatten()
3887 .map(|e| e.path())
3888 .find(|p| p.to_string_lossy().ends_with(".break.0"))
3889 .expect("ticket");
3890 assert!(!create_exclusive(&ticket, "").expect("ticket"));
3891 assert!(TurnLock::take(&lease).expect("take").is_none());
3892 assert_eq!(std::fs::read_to_string(&lock).expect("read"), fresh);
3893 age_file(&lock);
3895 age_file(&ticket);
3896 std::mem::forget(b);
3897 let c = TurnLock::take(&lease)
3898 .expect("take")
3899 .expect("next generation");
3900 assert_ne!(c.token, fresh);
3901 }
3902
3903 #[test]
3904 fn a_live_ticket_blocks_and_a_stale_one_hands_over_to_the_next_generation() {
3905 let (_tmp, a, _b) = lease_store();
3906 let lease = a.turn_path("t1");
3907 let lock = lease.with_extension("turn.lock");
3908 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3909 std::fs::write(&lock, "t1-dead").expect("dead lock");
3910 age_file(&lock);
3911 let t0 = lock.with_extension("lock.t1-dead.break.0");
3912 assert!(create_exclusive(&t0, "").expect("ticket"));
3913 assert!(TurnLock::take(&lease).expect("take").is_none());
3915 assert_eq!(std::fs::read_to_string(&lock).expect("read"), "t1-dead");
3916 age_file(&t0);
3918 let c = TurnLock::take(&lease).expect("take").expect("generation 1");
3919 assert_eq!(std::fs::read_to_string(&lock).expect("read"), c.token);
3920 assert!(lock.with_extension("lock.t1-dead.break.1").exists());
3921 assert!(TurnLock::take(&lease).expect("take").is_none());
3922 }
3923
3924 #[test]
3925 fn exhausted_ticket_generations_recover_once_the_sweep_ages_them_out() {
3926 let (_tmp, a, _b) = lease_store();
3927 let lease = a.turn_path("t1");
3928 let lock = lease.with_extension("turn.lock");
3929 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3930 std::fs::write(&lock, "t1-dead").expect("dead lock");
3931 age_file(&lock);
3932 let tickets: Vec<_> = (0..TICKET_GENERATIONS)
3933 .map(|n| lock.with_extension(format!("lock.t1-dead.break.{n}")))
3934 .collect();
3935 for t in &tickets {
3936 assert!(create_exclusive(t, "").expect("ticket"));
3937 age_file(t);
3938 }
3939 assert!(TurnLock::take(&lease).expect("take").is_none());
3941 assert!(tickets.iter().all(|t| t.exists()));
3942 for t in &tickets {
3944 let f = std::fs::OpenOptions::new()
3945 .write(true)
3946 .open(t)
3947 .expect("open");
3948 f.set_modified(std::time::SystemTime::now() - TICKET_SWEEP_AGE * 2)
3949 .expect("age");
3950 }
3951 assert!(TurnLock::take(&lease).expect("take").is_none());
3952 assert!(TurnLock::take(&lease).expect("take").is_some());
3953 }
3954
3955 #[test]
3956 fn an_aged_empty_lock_is_broken() {
3957 let (_tmp, a, _b) = lease_store();
3958 let lease = a.turn_path("t1");
3959 let lock = lease.with_extension("turn.lock");
3960 std::fs::create_dir_all(lock.parent().expect("dir")).expect("dir");
3961 std::fs::write(&lock, "").expect("empty lock");
3962 age_file(&lock);
3963 assert!(TurnLock::take(&lease).expect("take").is_some());
3964 }
3965
3966 #[test]
3967 fn reply_plain_text_sends_keep_provenance_and_boundaries() {
3968 let (tmp, talks) = store();
3969 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
3970 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
3971 record(&mut talk, &talks, "original", Vec::new()).expect("record");
3972 queue(&mut talk, &talks, "first", Vec::new()).expect("queue");
3973 queue(&mut talk, &talks, "second", Vec::new()).expect("queue");
3974 let wire: serde_json::Value =
3975 serde_json::from_str(&std::fs::read_to_string(talks.path_of(&talk.id)).unwrap())
3976 .unwrap();
3977 assert_eq!(wire["pending_messages"][0]["sender"], "operator");
3978 assert_eq!(wire["turns"][0]["messages"][0]["sender"], "operator");
3979 drain(&mut talk, &talks).expect("drain");
3980 let saved = talks.get(&talk.id).expect("reload");
3981 assert_eq!(saved.turns[1].body, "first\n\nsecond");
3982 assert_eq!(saved.turns[1].breaks, Some(vec![7]));
3983 let wire: serde_json::Value =
3984 serde_json::from_str(&std::fs::read_to_string(talks.path_of(&saved.id)).unwrap())
3985 .unwrap();
3986 for turn in wire["turns"].as_array().expect("turns") {
3988 for field in turn.as_object().expect("turn").keys() {
3989 assert!(
3990 [
3991 "who",
3992 "body",
3993 "at",
3994 "attachments",
3995 "usage",
3996 "breaks",
3997 "posted_by",
3998 "sender",
3999 "messages"
4000 ]
4001 .contains(&field.as_str()),
4002 "unknown turn field: {field}"
4003 );
4004 }
4005 }
4006 record_reply(
4007 &mut talk,
4008 &talks,
4009 "reply",
4010 Vec::new(),
4011 Some(ReplyTarget {
4012 turn: 1,
4013 message: 1,
4014 }),
4015 )
4016 .expect("reply to reloaded plain text");
4017 assert_eq!(
4018 talk.turns[2].messages[0].reply.as_ref().unwrap().excerpt,
4019 "second"
4020 );
4021 }
4022
4023 #[tokio::test]
4024 async fn reply_image_identity_reaches_fresh_and_followup_prompts_after_reload() {
4025 let (tmp, talks) = store();
4026 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4027 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4028 let first = talks
4029 .put_attachment(&talk.id, "image/png", "first.png", b"first")
4030 .expect("first");
4031 let second = talks
4032 .put_attachment(&talk.id, "image/png", "second.png", b"second")
4033 .expect("second");
4034 let first_path = talks.attachment_path(&talk.id, &first).unwrap();
4035 let second_path = talks.attachment_path(&talk.id, &second).unwrap();
4036 queue(&mut talk, &talks, "", vec![first]).expect("queue first");
4038 queue(&mut talk, &talks, "", vec![second]).expect("queue second");
4039 drain(&mut talk, &talks).expect("drain");
4040 let lease = talks.claim_turn(&talk.id).expect("claim").expect("lease");
4041 for (message, selected, other) in [
4042 (0, &first_path, &second_path),
4043 (1, &second_path, &first_path),
4044 ] {
4045 record_reply(
4046 &mut talk,
4047 &talks,
4048 "describe this",
4049 Vec::new(),
4050 Some(ReplyTarget { turn: 0, message }),
4051 )
4052 .expect("reply");
4053 talk = talks.get(&talk.id).expect("reload");
4054 let quote = message_prompt(talk.turns.last().unwrap(), &talk, &talks);
4055 assert!(quote.contains(selected.to_str().unwrap()));
4056 assert!(!quote.contains(other.to_str().unwrap()));
4057 respond(&lease, &mut talk, &talks, &cfg, "describe this")
4058 .await
4059 .expect("respond");
4060 assert!(
4061 talk.turns
4062 .last()
4063 .unwrap()
4064 .body
4065 .contains(&format!("Replied-to image: {}", selected.display()))
4066 );
4067 }
4068 }
4069
4070 #[test]
4071 fn reply_sends_survive_queue_edit_drain_reload_and_legacy_records() {
4072 let (tmp, talks) = store();
4073 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4074 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4075 record(&mut talk, &talks, &"猫".repeat(300), Vec::new()).expect("record");
4076 talk.turns[0].messages.clear();
4078 talks.put(&mut talk).expect("legacy save");
4079 let target = ReplyTarget {
4080 turn: 0,
4081 message: 0,
4082 };
4083 queue_reply(&mut talk, &talks, "first", Vec::new(), Some(target.clone())).expect("first");
4084 queue_reply(&mut talk, &talks, "second", Vec::new(), None).expect("second");
4085 assert!(
4086 edit_pending_message(&mut talk, &talks, "edited", "first\n\nsecond", &[], Some(0))
4087 .expect("edit")
4088 );
4089 drain(&mut talk, &talks).expect("drain");
4090 let saved = talks.get(&talk.id).expect("reload");
4091 assert_eq!(saved.turns[1].body, "edited\n\nsecond");
4092 assert_eq!(saved.turns[1].messages.len(), 2);
4093 let reply = saved.turns[1].messages[0].reply.as_ref().expect("reply");
4094 assert_eq!(reply.target, target);
4095 assert_eq!(reply.excerpt, "猫".repeat(240));
4096 assert!(message_prompt(&saved.turns[1], &saved, &talks).contains("[Reply to Operator:"));
4097 assert!(transcript(&saved, &talks).contains("[Reply to Operator:"));
4098 queue_reply(
4099 &mut talk,
4100 &talks,
4101 "third",
4102 Vec::new(),
4103 Some(ReplyTarget {
4104 turn: 1,
4105 message: 1,
4106 }),
4107 )
4108 .expect("specific send");
4109 assert_eq!(
4110 talk.pending_messages[0]
4111 .reply
4112 .as_ref()
4113 .expect("reply")
4114 .excerpt,
4115 "second"
4116 );
4117 clear_pending(&mut talk, &talks).expect("clear");
4118 assert!(talk.pending_messages.is_empty());
4119 assert!(
4120 record_reply(
4121 &mut talk,
4122 &talks,
4123 "invalid",
4124 Vec::new(),
4125 Some(ReplyTarget {
4126 turn: 1,
4127 message: 2
4128 })
4129 )
4130 .is_err()
4131 );
4132 assert_eq!(talks.get(&talk.id).expect("reload").turns.len(), 2);
4133 }
4134
4135 #[tokio::test]
4136 async fn reply_quotes_reach_fresh_and_followup_prompts_without_changing_body() {
4137 let (tmp, talks) = store();
4138 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4139 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4140 record(&mut talk, &talks, "original", Vec::new()).expect("record");
4141 let target = ReplyTarget {
4142 turn: 0,
4143 message: 0,
4144 };
4145 record_reply(
4146 &mut talk,
4147 &talks,
4148 "answer",
4149 Vec::new(),
4150 Some(target.clone()),
4151 )
4152 .expect("reply");
4153 let lease = talks.claim_turn(&talk.id).expect("claim").expect("lease");
4154 respond(&lease, &mut talk, &talks, &cfg, "answer")
4155 .await
4156 .expect("respond");
4157 assert!(
4158 talk.turns
4159 .last()
4160 .expect("response")
4161 .body
4162 .contains("[Reply to Operator: original]\nanswer")
4163 );
4164 assert_eq!(talk.turns[1].body, "answer");
4165 record_reply(&mut talk, &talks, "followup", Vec::new(), Some(target)).expect("reply");
4166 respond(&lease, &mut talk, &talks, &cfg, "followup")
4167 .await
4168 .expect("respond");
4169 assert!(
4170 talk.turns
4171 .last()
4172 .expect("response")
4173 .body
4174 .contains("[Reply to Operator: original]\nfollowup")
4175 );
4176 }
4177
4178 #[test]
4179 fn owner_indentation_survives_record_queue_and_edit() {
4180 let (tmp, talks) = store();
4181 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4182 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4183 for text in [" Post original", "\tUse rewrite"] {
4184 record_reply(&mut talk, &talks, text, Vec::new(), None).unwrap();
4185 assert_eq!(
4186 talks.get(&talk.id).unwrap().turns.last().unwrap().body,
4187 text
4188 );
4189 queue(&mut talk, &talks, "draft", Vec::new()).unwrap();
4190 assert!(edit_pending_message(&mut talk, &talks, text, "draft", &[], Some(0)).unwrap());
4191 let saved = talks.get(&talk.id).unwrap();
4192 assert_eq!(saved.pending, text);
4193 assert_eq!(saved.pending_messages[0].body, text);
4194 assert!(edit_pending_message(&mut talk, &talks, text, text, &[], None).unwrap());
4195 let saved = talks.get(&talk.id).unwrap();
4196 assert_eq!(saved.pending, text);
4197 assert_eq!(saved.pending_messages[0].body, text);
4198 assert_eq!(drain(&mut talk, &talks).unwrap().as_deref(), Some(text));
4199 }
4200 }
4201
4202 #[test]
4203 fn queued_text_is_durable_combined_and_drained_as_one_operator_turn() {
4204 let (tmp, talks) = store();
4205 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4206 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4207
4208 queue(&mut talk, &talks, "first", Vec::new()).expect("queue first");
4209 queue(&mut talk, &talks, "second", Vec::new()).expect("queue second");
4210 let saved = talks.get(&talk.id).expect("reload queued talk");
4211 assert_eq!(saved.pending, "first\n\nsecond");
4212 assert_eq!(saved.pending_breaks, Some(vec!["first\n\n".len()]));
4213 assert!(saved.turns.is_empty(), "a draft is not a transcript turn");
4214
4215 let drained = drain(&mut talk, &talks).expect("drain");
4216 assert_eq!(drained.as_deref(), Some("first\n\nsecond"));
4217 let saved = talks.get(&talk.id).expect("reload drained talk");
4218 assert!(saved.pending.is_empty());
4219 assert_eq!(saved.turns.len(), 1);
4220 assert_eq!(saved.turns[0].body, "first\n\nsecond");
4221 assert_eq!(saved.turns[0].breaks, Some(vec!["first\n\n".len()]));
4222 assert_eq!(saved.pending_breaks, None);
4223 }
4224
4225 #[test]
4226 fn editing_a_queued_draft_preserves_its_attachments_and_rejects_a_stale_snapshot() {
4227 let (tmp, talks) = store();
4228 let cfg = config(mock_agent(tmp.path(), REPLY, env("reply")));
4229 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4230 let attachment = Attachment {
4231 id: "a".repeat(32),
4232 name: "shot.png".to_owned(),
4233 mime: "image/png".to_owned(),
4234 bytes: 3,
4235 };
4236
4237 queue(&mut talk, &talks, "first", vec![attachment.clone()]).expect("queue");
4238 assert!(
4239 edit_pending_text(
4240 &mut talk,
4241 &talks,
4242 "corrected",
4243 "first",
4244 std::slice::from_ref(&attachment.id),
4245 )
4246 .expect("edit")
4247 );
4248 let saved = talks.get(&talk.id).expect("reload edited draft");
4249 assert_eq!(saved.pending, "corrected");
4250 assert_eq!(saved.pending_attachments, vec![attachment]);
4251
4252 queue(&mut talk, &talks, "later", Vec::new()).expect("queue concurrent draft");
4253 assert!(
4254 !edit_pending_text(
4255 &mut talk,
4256 &talks,
4257 "stale edit",
4258 "corrected",
4259 &["a".repeat(32)],
4260 )
4261 .expect("stale edit is a conflict")
4262 );
4263 assert_eq!(
4264 talks.get(&talk.id).expect("reload after conflict").pending,
4265 "corrected\n\nlater"
4266 );
4267 assert!(
4268 !clear_pending_if_matches(&mut talk, &talks, "corrected", &["a".repeat(32)])
4269 .expect("stale clear is a conflict")
4270 );
4271 assert_eq!(
4272 talks
4273 .get(&talk.id)
4274 .expect("reload after stale clear")
4275 .pending,
4276 "corrected\n\nlater"
4277 );
4278 }
4279
4280 #[tokio::test]
4281 async fn a_reply_save_preserves_pending_accepted_while_the_cli_runs() {
4282 let (tmp, talks) = store();
4283 let slow = "#!/bin/sh\ncat >/dev/null\nsleep 0.1\nprintf reply\n";
4284 let cfg = config(mock_agent(tmp.path(), slow, BTreeMap::new()));
4285 let mut running = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4286 let id = running.id.clone();
4287 let first = record(&mut running, &talks, "first", Vec::new()).expect("record");
4288
4289 let response_talks = talks.clone();
4290 let response_cfg = cfg.clone();
4291 let reply = tokio::spawn(async move {
4292 respond(
4293 &response_talks.claim_turn(&running.id).unwrap().unwrap(),
4294 &mut running,
4295 &response_talks,
4296 &response_cfg,
4297 &first,
4298 )
4299 .await
4300 });
4301 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4302
4303 let mut queued = talks.get(&id).expect("queued handle");
4304 queue(&mut queued, &talks, "next", Vec::new()).expect("queue");
4305 reply.await.expect("join").expect("reply");
4306
4307 let saved = talks.get(&id).expect("reload");
4308 assert_eq!(saved.pending, "next");
4309 assert_eq!(saved.pending_breaks, Some(Vec::new()));
4310 assert_eq!(saved.turns.len(), 2, "operator message and reply remain");
4311 }
4312
4313 #[tokio::test]
4314 async fn posts_during_a_resumed_turn_are_replayed_once_even_after_a_reply_save_failure() {
4315 for lose_reply in [false, true] {
4316 let (tmp, talks) = store();
4317 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4318 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4319 say(
4320 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4321 &mut talk,
4322 &talks,
4323 &cfg,
4324 "establish the session",
4325 Vec::new(),
4326 )
4327 .await
4328 .expect("first turn");
4329 assert!(agent::has_session(
4330 AgentKind::Command,
4331 &talk.seat,
4332 cfg.graph.sessions
4333 ));
4334
4335 post(&talks, &talk.id, "already available", "task-earlier").expect("earlier post");
4336 talk = talks.get(&talk.id).expect("reload earlier post");
4337 let ready = tmp.path().join("ready");
4338 let release = tmp.path().join("release");
4339 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";
4340 let slow_cfg = config(mock_agent(
4341 tmp.path(),
4342 slow,
4343 BTreeMap::from([
4344 ("READY".to_owned(), ready.to_string_lossy().into_owned()),
4345 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
4346 ]),
4347 ));
4348 let text = record(&mut talk, &talks, "keep going", Vec::new()).expect("record");
4349 let prompt_turns = talk.turns.len();
4350 let id = talk.id.clone();
4351 let response_talks = talks.clone();
4352 let running = tokio::spawn(async move {
4353 respond(
4354 &response_talks.claim_turn(&talk.id).unwrap().unwrap(),
4355 &mut talk,
4356 &response_talks,
4357 &slow_cfg,
4358 &text,
4359 )
4360 .await
4361 });
4362 tokio::time::timeout(std::time::Duration::from_secs(5), async {
4363 while !ready.exists() {
4364 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4365 }
4366 })
4367 .await
4368 .expect("CLI read its prompt");
4369 post(&talks, &id, "concurrent task result", "task-one").expect("post");
4370 post(&talks, &id, "second task result", "task-two").expect("second post");
4371 if lose_reply {
4372 failpoint::force_put_failures(PUT_RETRIES);
4373 }
4374 std::fs::write(release, "go").expect("release CLI");
4375 let result = running.await.expect("join");
4376 if lose_reply {
4377 assert!(
4378 result
4379 .unwrap_err()
4380 .to_string()
4381 .contains("could not be saved")
4382 );
4383 } else {
4384 result.expect("reply");
4385 }
4386
4387 let mut saved = talks.get(&id).expect("reload");
4388 assert_eq!(saved.posts_read_through, Some(prompt_turns));
4389 assert_eq!(saved.turns[prompt_turns].body, "concurrent task result");
4390 assert_eq!(saved.turns[prompt_turns + 1].body, "second task result");
4391 assert!(posts_since_last_reply(&saved).is_some());
4392 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4393 say(
4394 &talks.claim_turn(&id).unwrap().unwrap(),
4395 &mut saved,
4396 &talks,
4397 &cfg,
4398 "read the results",
4399 Vec::new(),
4400 )
4401 .await
4402 .expect("replay turn");
4403 let prompt = &saved.turns.last().unwrap().body;
4404 assert!(prompt.contains("# Posted by a task"), "{prompt}");
4405 assert!(!prompt.contains("already available"), "{prompt}");
4406 assert!(
4407 prompt
4408 .find("concurrent task result")
4409 .expect("first post in prompt")
4410 < prompt
4411 .find("second task result")
4412 .expect("second post in prompt")
4413 );
4414 let mut saved = talks.get(&id).expect("reload after replay");
4415 assert!(posts_since_last_reply(&saved).is_none());
4416 say(
4417 &talks.claim_turn(&id).unwrap().unwrap(),
4418 &mut saved,
4419 &talks,
4420 &cfg,
4421 "continue",
4422 Vec::new(),
4423 )
4424 .await
4425 .expect("next turn");
4426 assert!(!saved.turns.last().unwrap().body.contains("task result"));
4427 }
4428 }
4429
4430 fn counting_agent(dir: &Path, id: &str, body: &str) -> AgentSpec {
4433 let calls = dir.join(format!("{id}.calls"));
4434 let script = format!(
4435 "#!/bin/sh\necho x >> '{}'\n{body}\n",
4436 calls.to_string_lossy()
4437 );
4438 let path = dir.join(format!("mock-{id}.sh"));
4439 std::fs::write(&path, script).expect("write mock");
4440 AgentSpec {
4441 id: id.to_owned(),
4442 kind: AgentKind::Command,
4443 model: None,
4444 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
4445 extra_args: Vec::new(),
4446 env: BTreeMap::new(),
4447 prompt_delivery: None,
4448 }
4449 }
4450
4451 fn calls(dir: &Path, id: &str) -> usize {
4452 std::fs::read_to_string(dir.join(format!("{id}.calls"))).map_or(0, |s| s.lines().count())
4453 }
4454
4455 fn chain_config(specs: Vec<AgentSpec>, ids: &[&str]) -> Config {
4456 let mut cfg = config(specs[0].clone());
4457 cfg.agents = specs;
4458 cfg.roles.chatter = Some(AgentChoice::Chain(
4459 ids.iter().map(|s| (*s).to_owned()).collect(),
4460 ));
4461 cfg
4462 }
4463
4464 #[tokio::test]
4465 async fn completion_evidence_reaches_fresh_resumed_and_fallback_prompts() {
4466 for fallback in [false, true] {
4467 let (tmp, talks) = store();
4468 let cfg = if fallback {
4469 chain_config(
4470 vec![
4471 counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3"),
4472 counting_agent(tmp.path(), "b", "cat"),
4473 ],
4474 &["a", "b"],
4475 )
4476 } else {
4477 config(mock_agent(tmp.path(), ECHO, BTreeMap::new()))
4478 };
4479 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).unwrap();
4480 let lease = talks.claim_turn(&talk.id).unwrap().unwrap();
4481 for n in 0..2 {
4482 let text = format!(
4483 "# {}\n\n- Task: `unavailable-{n}`",
4484 crate::prompt::CHAT_TASK_NOTICE_HEADING
4485 );
4486 let reference = crate::chat_notify::CompletionReference {
4487 task: format!("unavailable-{n}"),
4488 run: None,
4489 notice: text.clone(),
4490 };
4491 let key = format!("unavailable-{n}:done");
4492 assert!(
4493 queue_once_matching(
4494 &mut talk,
4495 &talks,
4496 &text,
4497 &key,
4498 &|_| false,
4499 Some(&reference)
4500 )
4501 .unwrap()
4502 );
4503 assert!(
4504 !queue_once_matching(
4505 &mut talk,
4506 &talks,
4507 &text,
4508 &key,
4509 &|_| false,
4510 Some(&reference)
4511 )
4512 .unwrap()
4513 );
4514 talk = talks.get(&talk.id).unwrap();
4515 assert_eq!(talk.completion_notices.len(), n + 1);
4516 let text = drain(&mut talk, &talks).unwrap().unwrap();
4517 respond(&lease, &mut talk, &talks, &cfg, &text)
4518 .await
4519 .unwrap();
4520 let reply = &talk.turns.last().unwrap().body;
4521 assert!(reply.contains("# Verified outcome evidence"), "{reply}");
4522 assert!(reply.contains("Outcome evidence unavailable"));
4523 assert!(reply.contains("Do not file new tasks or edit repositories solely"));
4524 assert!(reply.contains("Explicitly say when no action remains"));
4525 }
4526 record(&mut talk, &talks, "ordinary follow-up", vec![]).unwrap();
4527 assert!(
4528 crate::chat_notify::response_evidence(&talk)
4529 .await
4530 .is_empty(),
4531 "history does not trigger lookups"
4532 );
4533 record(
4534 &mut talk,
4535 &talks,
4536 &format!("# {}\n\nlegacy", crate::prompt::CHAT_TASK_NOTICE_HEADING),
4537 vec![],
4538 )
4539 .unwrap();
4540 assert!(
4541 crate::chat_notify::response_evidence(&talk)
4542 .await
4543 .contains("Legacy notification has no structured task/run reference")
4544 );
4545 }
4546 }
4547
4548 #[tokio::test]
4549 async fn a_chatter_chain_falls_back_resends_the_transcript_and_sticks() {
4550 let (tmp, talks) = store();
4551 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4552 let b = counting_agent(tmp.path(), "b", "cat");
4553 let cfg = chain_config(vec![a, b], &["a", "b"]);
4554 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4555 assert_eq!(talk.agent, "a");
4556 post(&talks, &talk.id, "fallback task result", "task").expect("post");
4557 talk = talks.get(&talk.id).expect("reload");
4558
4559 say(
4560 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4561 &mut talk,
4562 &talks,
4563 &cfg,
4564 "hello there",
4565 Vec::new(),
4566 )
4567 .await
4568 .expect("turn");
4569 assert_eq!(calls(tmp.path(), "a"), 1, "each id is tried once");
4570 assert_eq!(calls(tmp.path(), "b"), 1);
4571 assert_eq!(talk.agent, "b", "the switch persists");
4572 assert!(talks.get(&talk.id).unwrap().agent == "b");
4573 let reply = talk.turns.last().unwrap();
4574 assert!(reply.body.contains("hello there"));
4575 assert!(reply.body.contains("fallback task result"));
4576 let saved = talks.get(&talk.id).expect("reload fallback");
4577 assert_eq!(saved.posts_read_through, Some(2));
4578 assert!(posts_since_last_reply(&saved).is_none());
4579 assert!(
4580 reply.body.contains("magi task add --solo"),
4581 "a fresh seat gets the full briefing"
4582 );
4583 assert!(
4584 talk.turns
4585 .iter()
4586 .any(|t| t.body.contains("agent changed from a to b")),
4587 "the switch is noted"
4588 );
4589 }
4590
4591 #[tokio::test]
4592 async fn an_exhausted_chatter_chain_fails_like_a_single_seat_and_stays_put() {
4593 let (tmp, talks) = store();
4594 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4595 let b = counting_agent(tmp.path(), "b", "cat >/dev/null\nexit 4");
4596 let cfg = chain_config(vec![a, b], &["a", "b", "a"]);
4597 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4598
4599 let err = say(
4600 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4601 &mut talk,
4602 &talks,
4603 &cfg,
4604 "hi",
4605 Vec::new(),
4606 )
4607 .await
4608 .expect_err("every agent failed");
4609 assert!(err.to_string().contains("`a`"), "{err:#}");
4610 assert_eq!(calls(tmp.path(), "a"), 1);
4611 assert_eq!(calls(tmp.path(), "b"), 1);
4612 assert_eq!(talk.agent, "a", "an exhausted chain leaves the agent alone");
4613 }
4614
4615 #[test]
4616 fn a_chatter_chain_skips_an_unknown_id_at_begin() {
4617 let (tmp, talks) = store();
4618 let b = counting_agent(tmp.path(), "b", "cat");
4619 let cfg = chain_config(vec![b], &["ghost", "b"]);
4620 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4621 assert_eq!(talk.agent, "b");
4622 }
4623
4624 #[tokio::test]
4625 async fn an_explicit_agent_inside_the_chatter_chain_stays_pinned() {
4626 let (tmp, talks) = store();
4627 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4628 let b = counting_agent(tmp.path(), "b", "cat");
4629 let cfg = chain_config(vec![a, b], &["a", "b"]);
4630 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), Some("a")).expect("begin");
4631 say(
4632 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4633 &mut talk,
4634 &talks,
4635 &cfg,
4636 "hi",
4637 Vec::new(),
4638 )
4639 .await
4640 .expect_err("a alone, and it fails");
4641 assert_eq!(calls(tmp.path(), "b"), 0);
4642 assert_eq!(talk.agent, "a");
4643 }
4644
4645 #[tokio::test]
4646 async fn an_explicit_agent_does_not_borrow_the_chatter_chain() {
4647 let (tmp, talks) = store();
4648 let a = counting_agent(tmp.path(), "a", "cat >/dev/null\nexit 3");
4649 let b = counting_agent(tmp.path(), "b", "cat");
4650 let c = counting_agent(tmp.path(), "c", "cat >/dev/null\nexit 3");
4651 let cfg = chain_config(vec![a, b, c], &["a", "b"]);
4652 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), Some("c")).expect("begin");
4653 say(
4654 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4655 &mut talk,
4656 &talks,
4657 &cfg,
4658 "hi",
4659 Vec::new(),
4660 )
4661 .await
4662 .expect_err("c alone, and it fails");
4663 assert_eq!(calls(tmp.path(), "b"), 0);
4664 }
4665
4666 #[test]
4667 fn briefing_names_the_operator_with_or_without_a_persona() {
4668 let plain = briefing(Path::new("/repo"), "en", false);
4669 let named = briefing_with(Path::new("/repo"), "en", false, None, Some("Commander"));
4670 assert!(named.starts_with(&plain));
4671 assert!(named.contains("# Addressing the operator"));
4672 assert!(named.contains("\"Commander\""));
4673 let rei = crate::persona::builtin_catalog()
4674 .into_iter()
4675 .find(|p| p.id == "rei")
4676 .expect("rei");
4677 let with = briefing_with(
4678 Path::new("/repo"),
4679 "en",
4680 false,
4681 Some(&rei),
4682 Some("Commander"),
4683 );
4684 assert!(with.contains("\"Commander\""));
4685 assert!(!with.contains("# Addressing the operator"));
4686 }
4687
4688 #[test]
4689 fn briefing_carries_a_persona_section_only_when_one_is_chosen() {
4690 let plain = briefing(Path::new("/repo"), "en", false);
4691 assert_eq!(
4692 plain,
4693 briefing_with(Path::new("/repo"), "en", false, None, None)
4694 );
4695 assert!(!plain.contains("Persona"));
4696 let rei = crate::persona::builtin_catalog()
4697 .into_iter()
4698 .find(|p| p.id == "rei")
4699 .expect("rei");
4700 let with = briefing_with(Path::new("/repo"), "en", false, Some(&rei), None);
4701 assert!(with.starts_with(&plain), "the plain briefing is untouched");
4702 assert!(with.contains("# Persona (tone only)"));
4703 assert!(with.contains("TONE ONLY"));
4704 assert!(with.contains("task ids"));
4705 assert!(with.contains("write policy"));
4706 assert!(with.contains("`magi task add`"));
4707 assert!(with.contains("Rei Ayanami"));
4708 }
4709
4710 #[test]
4711 fn a_talk_written_before_personas_still_loads() {
4712 let (tmp, talks) = store();
4713 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4714 let cfg = config(spec);
4715 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4716 let path = talks.root.join(format!("{}.json", talk.id));
4717 let mut v: serde_json::Value =
4718 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4719 v.as_object_mut().unwrap().remove("persona");
4720 v.as_object_mut().unwrap().remove("persona_dirty");
4721 std::fs::write(&path, v.to_string()).unwrap();
4722 let loaded = talks.get(&talk.id).expect("old record loads");
4723 assert_eq!(loaded.persona, "");
4724 assert!(!loaded.persona_dirty);
4725 }
4726
4727 #[test]
4728 fn the_briefing_files_with_solo_or_the_chosen_implementer_count() {
4729 let repo = Path::new("/repo");
4730 let solo = briefing_for(repo, "", false, None, None, 1);
4731 assert_eq!(solo, briefing(repo, "", false));
4732 assert!(solo.contains("magi task add --solo --repo"));
4733 assert!(solo.contains("magi task add --solo --attach"));
4734 for n in [2u8, 3] {
4735 let b = briefing_for(repo, "", false, None, None, n);
4736 assert!(
4737 b.contains(&format!("magi task add --implementers {n} --repo")),
4738 "{b}"
4739 );
4740 assert!(b.contains(&format!("magi task add --implementers {n} --attach")));
4741 assert!(!b.contains("task add --solo"), "{b}");
4742 assert!(!b.contains("Use --solo"), "{b}");
4743 }
4744 }
4745
4746 #[test]
4747 fn begin_with_applies_remembered_choices_and_drops_unfit_ones_alone() {
4748 let (tmp, talks) = store();
4749 let first = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4750 let mut second = first.clone();
4751 second.id = "second".to_owned();
4752 let mut cfg = config(first.clone());
4753 cfg.agents.push(second.clone());
4754 let open = |p: &Preferred| {
4755 begin_with(&talks, &cfg, tmp.path().to_owned(), None, p).expect("begin")
4756 };
4757
4758 let talk = open(&Preferred {
4759 agent: Some("second".into()),
4760 persona: Some("rei".into()),
4761 implementers: Some(i64::from(check_implementers(3, &cfg).unwrap_or(1))),
4762 });
4763 assert_eq!(talk.agent, "second");
4764 assert!(
4765 !talk.fallback,
4766 "a remembered non-head agent is an explicit choice"
4767 );
4768 assert_eq!(talk.persona, "rei");
4769 assert_eq!(talk.implementers, check_implementers(3, &cfg).unwrap_or(1));
4770 assert!(!talk.persona_dirty && !talk.implementers_dirty);
4771
4772 let head = open(&Preferred {
4774 agent: Some(first.id.clone()),
4775 ..Preferred::default()
4776 });
4777 assert!(head.fallback);
4778
4779 for implementers in [0, 4, -1, 1000] {
4781 let t = open(&Preferred {
4782 agent: Some("gone".into()),
4783 persona: Some("no-such-persona".into()),
4784 implementers: Some(implementers),
4785 });
4786 assert_eq!(t.agent, first.id);
4787 assert!(t.fallback);
4788 assert_eq!(t.persona, "");
4789 assert_eq!(t.implementers, 1);
4790 }
4791
4792 let t = open(&Preferred {
4794 agent: Some("second".into()),
4795 persona: Some("default".into()),
4796 implementers: Some(9),
4797 });
4798 assert_eq!(
4799 (t.agent.as_str(), t.persona.as_str(), t.implementers),
4800 ("second", "", 1)
4801 );
4802
4803 let strict = begin_with(
4805 &talks,
4806 &cfg,
4807 tmp.path().to_owned(),
4808 Some("first-unknown"),
4809 &Preferred {
4810 agent: Some("second".into()),
4811 ..Preferred::default()
4812 },
4813 );
4814 assert!(strict.is_err());
4815 }
4816
4817 #[test]
4818 fn remembered_agent_can_open_when_the_chatter_chain_cannot_resolve() {
4819 let (tmp, talks) = store();
4820 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4821 let cfg = chain_config(vec![spec.clone()], &["missing-chatter"]);
4824 assert!(begin(&talks, &cfg, tmp.path().to_owned(), None).is_err());
4825 let talk = begin_with(
4826 &talks,
4827 &cfg,
4828 tmp.path().to_owned(),
4829 None,
4830 &Preferred {
4831 agent: Some(spec.id.clone()),
4832 ..Preferred::default()
4833 },
4834 )
4835 .expect("a valid remembered agent does not require a default chain");
4836 assert_eq!(talk.agent, spec.id);
4837 assert!(!talk.fallback);
4838 assert_eq!(talks.get(&talk.id).unwrap().agent, spec.id);
4839 assert!(
4840 begin_with(
4841 &talks,
4842 &cfg,
4843 tmp.path().to_owned(),
4844 None,
4845 &Preferred {
4846 agent: Some("missing-preference".into()),
4847 ..Preferred::default()
4848 },
4849 )
4850 .is_err()
4851 );
4852 }
4853
4854 #[test]
4855 fn implementers_are_validated_and_old_records_load_as_solo() {
4856 let (tmp, talks) = store();
4857 let cfg = config(mock_agent(tmp.path(), ECHO, BTreeMap::new()));
4858 assert_eq!(check_implementers(1, &cfg), Ok(1));
4859 assert_eq!(check_implementers(3, &cfg), Ok(3));
4860 assert!(check_implementers(0, &cfg).is_err());
4861 assert!(check_implementers(4, &cfg).is_err());
4862 let mut empty = cfg.clone();
4863 empty.agents.clear();
4864 assert!(check_implementers(2, &empty).is_err());
4865
4866 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4867 let path = talks.path_of(&talk.id);
4868 let mut v: serde_json::Value =
4869 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
4870 v.as_object_mut().unwrap().remove("implementers");
4871 v.as_object_mut().unwrap().remove("implementers_dirty");
4872 std::fs::write(&path, v.to_string()).unwrap();
4873 let loaded = talks.get(&talk.id).expect("load");
4874 assert_eq!(loaded.implementers, 1);
4875 assert!(!loaded.implementers_dirty);
4876 }
4877
4878 #[tokio::test]
4879 async fn an_implementers_switch_is_noted_once_and_kept_across_a_failed_turn() {
4880 let (tmp, talks) = store();
4881 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4882 let cfg = config(spec);
4883 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4884 let lease = talks.claim_turn(&talk.id).unwrap().unwrap();
4885 say(&lease, &mut talk, &talks, &cfg, "hello", Vec::new())
4886 .await
4887 .expect("first turn");
4888
4889 assert!(switch_implementers(&mut talk, &talks, 2).expect("switch"));
4890 assert!(!switch_implementers(&mut talk, &talks, 2).expect("same"));
4891 assert!(talk.implementers_dirty);
4892 say(&lease, &mut talk, &talks, &cfg, "next", Vec::new())
4893 .await
4894 .expect("turn");
4895 let prompt = &talk.turns.last().unwrap().body;
4896 assert!(prompt.contains("# Task filing update"), "{prompt}");
4897 assert!(prompt.contains("--implementers 2"));
4898 assert!(!talk.implementers_dirty, "cleared after a successful turn");
4899
4900 say(&lease, &mut talk, &talks, &cfg, "again", Vec::new())
4901 .await
4902 .expect("turn");
4903 assert!(
4904 !talk
4905 .turns
4906 .last()
4907 .unwrap()
4908 .body
4909 .contains("# Task filing update")
4910 );
4911
4912 let mut no_sessions = cfg.clone();
4914 no_sessions.graph.sessions = false;
4915 say(&lease, &mut talk, &talks, &no_sessions, "one", Vec::new())
4916 .await
4917 .expect("turn");
4918 say(&lease, &mut talk, &talks, &no_sessions, "two", Vec::new())
4919 .await
4920 .expect("turn");
4921 assert!(talk.turns.last().unwrap().body.contains("--implementers 2"));
4922 }
4923
4924 #[tokio::test]
4925 async fn a_persona_switch_notes_marks_dirty_and_updates_the_next_turn_once() {
4926 let (tmp, talks) = store();
4927 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4928 let cfg = config(spec);
4929 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4930 let lease = talks.claim_turn(&talk.id).unwrap().unwrap();
4931 say(&lease, &mut talk, &talks, &cfg, "hello", Vec::new())
4932 .await
4933 .expect("first turn");
4934 assert!(!talk.turns[1].body.contains("Persona"));
4935
4936 assert!(switch_persona(&mut talk, &talks, "misato").expect("switch"));
4937 assert!(!switch_persona(&mut talk, &talks, "misato").expect("same"));
4938 assert!(talk.persona_dirty);
4939 assert!(talk.turns.last().unwrap().body.contains("persona changed"));
4940
4941 say(&lease, &mut talk, &talks, &cfg, "next", Vec::new())
4942 .await
4943 .expect("turn");
4944 let prompt = &talk.turns.last().unwrap().body;
4945 assert!(prompt.contains("# Persona update"), "{prompt}");
4946 assert!(prompt.contains("Misato Katsuragi"));
4947 assert!(!talk.persona_dirty, "cleared after a successful turn");
4948
4949 say(&lease, &mut talk, &talks, &cfg, "again", Vec::new())
4950 .await
4951 .expect("turn");
4952 assert!(!talk.turns.last().unwrap().body.contains("# Persona update"));
4953
4954 assert!(switch_persona(&mut talk, &talks, "default").expect("back"));
4955 assert_eq!(talk.persona, "");
4956 say(&lease, &mut talk, &talks, &cfg, "plain", Vec::new())
4957 .await
4958 .expect("turn");
4959 assert!(
4960 talk.turns
4961 .last()
4962 .unwrap()
4963 .body
4964 .contains("turned the persona off")
4965 );
4966
4967 assert!(switch_persona(&mut talk, &talks, "rei").expect("rei"));
4969 let mut no_sessions = cfg.clone();
4970 no_sessions.graph.sessions = false;
4971 say(&lease, &mut talk, &talks, &no_sessions, "one", Vec::new())
4972 .await
4973 .expect("turn");
4974 say(&lease, &mut talk, &talks, &no_sessions, "two", Vec::new())
4975 .await
4976 .expect("turn");
4977 let last = &talk.turns.last().unwrap().body;
4978 assert!(!talk.persona_dirty);
4979 assert!(last.contains("# Persona (tone only)"), "{last}");
4980 assert!(last.contains("Rei Ayanami"));
4981 }
4982
4983 #[tokio::test]
4984 async fn the_first_turn_carries_the_briefing_and_later_turns_do_not() {
4985 let (tmp, talks) = store();
4986 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
4987 let cfg = config(spec);
4988 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
4989
4990 say(
4991 &talks.claim_turn(&talk.id).unwrap().unwrap(),
4992 &mut talk,
4993 &talks,
4994 &cfg,
4995 "what does the queue module do?",
4996 Vec::new(),
4997 )
4998 .await
4999 .expect("first turn");
5000 let first_prompt = &talk.turns[1].body;
5001 assert!(first_prompt.contains("magi task add --solo"));
5002 assert!(first_prompt.contains("what does the queue module do?"));
5003
5004 say(
5005 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5006 &mut talk,
5007 &talks,
5008 &cfg,
5009 "and how is it locked?",
5010 Vec::new(),
5011 )
5012 .await
5013 .expect("second turn");
5014 let second_prompt = &talk.turns[3].body;
5015 assert!(
5016 !second_prompt.contains("magi task add --solo"),
5017 "the briefing is sent once, not on every turn: {second_prompt}"
5018 );
5019 assert!(second_prompt.contains("and how is it locked?"));
5020 }
5021
5022 #[tokio::test]
5023 async fn switching_agent_resets_the_seat_notes_it_and_resends_the_transcript() {
5024 let (tmp, talks) = store();
5025 let a = mock_agent(tmp.path(), ECHO, BTreeMap::new());
5026 let mut b = a.clone();
5027 b.id = "other".to_owned();
5028 let mut cfg = config(a.clone());
5029 cfg.agents.push(b.clone());
5030 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), Some(&a.id)).expect("begin");
5031 say(
5032 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5033 &mut talk,
5034 &talks,
5035 &cfg,
5036 "remember the walrus",
5037 Vec::new(),
5038 )
5039 .await
5040 .expect("first turn");
5041 let old_session = talk.seat.claude_session.clone();
5042 assert_eq!(talk.seat.turns, 1);
5043
5044 assert!(switch_agent(&mut talk, &talks, &b).expect("switch"));
5045 assert_eq!(talk.agent, "other");
5046 assert_eq!(talk.seat.turns, 0);
5047 assert_eq!(talk.seat.agent, "other");
5048 assert_ne!(talk.seat.claude_session, old_session);
5049 let note = talk.turns.last().expect("note");
5050 assert_eq!(note.who, Who::Agent);
5051 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
5052 assert!(note.body.contains("changed from"), "{}", note.body);
5053 assert_eq!(talks.get(&talk.id).expect("reload").agent, "other");
5054
5055 let before = talk.turns.len();
5056 assert!(!switch_agent(&mut talk, &talks, &b).expect("same agent"));
5057 assert_eq!(talk.turns.len(), before, "a no-op writes no note");
5058
5059 say(
5060 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5061 &mut talk,
5062 &talks,
5063 &cfg,
5064 "what did I say?",
5065 Vec::new(),
5066 )
5067 .await
5068 .expect("turn after switch");
5069 let prompt = &talk.turns.last().expect("reply").body;
5070 assert!(prompt.contains("remember the walrus"), "{prompt}");
5071 assert!(prompt.contains("## magi"), "{prompt}");
5072 assert!(prompt.contains("what did I say?"), "{prompt}");
5073 }
5074
5075 #[tokio::test]
5076 async fn say_appends_the_operator_turn_then_the_agent_turn() {
5077 let (tmp, talks) = store();
5078 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5079 let cfg = config(spec);
5080 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5081
5082 say(
5083 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5084 &mut talk,
5085 &talks,
5086 &cfg,
5087 "can I rename this function?",
5088 Vec::new(),
5089 )
5090 .await
5091 .expect("say");
5092
5093 assert_eq!(talk.turns.len(), 2);
5094 assert_eq!(talk.turns[0].who, Who::Operator);
5095 assert_eq!(talk.turns[0].body, "can I rename this function?");
5096 assert_eq!(talk.turns[1].who, Who::Agent);
5097 assert_eq!(talk.turns[1].body, "go ahead");
5098 assert_eq!(talks.get(&talk.id).expect("get").turns, talk.turns);
5099 }
5100
5101 #[tokio::test]
5102 async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
5103 let (tmp, talks) = store();
5104 let spec = mock_agent(tmp.path(), BROKEN, BTreeMap::new());
5105 let cfg = config(spec);
5106 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5107
5108 talk.posts_read_through = None;
5109 talks.put(&mut talk).expect("save old-format boundary");
5110 post(&talks, &talk.id, "unread task result", "task").expect("post");
5111 talk = talks.get(&talk.id).expect("reload");
5112
5113 let err = say(
5114 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5115 &mut talk,
5116 &talks,
5117 &cfg,
5118 "check the tests",
5119 Vec::new(),
5120 )
5121 .await
5122 .expect_err("a turn with no answer is an error");
5123 assert!(err.to_string().contains("no answer"), "{err}");
5124
5125 let on_disk = talks.get(&talk.id).expect("get");
5126 assert_eq!(on_disk.turns.len(), 3);
5127 assert_eq!(on_disk.posts_read_through, Some(0));
5128 assert!(
5129 posts_since_last_reply(&on_disk)
5130 .unwrap()
5131 .contains("unread task result")
5132 );
5133 assert_eq!(on_disk.turns[1].body, "check the tests");
5134 let note = &on_disk.turns[2];
5135 assert_eq!(note.who, Who::Agent);
5136 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
5137 assert!(note.body.contains("your message is saved"));
5138 }
5139
5140 #[tokio::test]
5146 async fn a_passing_write_failure_while_saving_the_reply_does_not_lose_it() {
5147 let (tmp, talks) = store();
5148 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5149 let cfg = config(spec);
5150 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5151
5152 let text =
5153 record(&mut talk, &talks, "can I rename this function?", Vec::new()).expect("record");
5154 failpoint::force_put_failures(PUT_RETRIES - 1);
5157 respond(
5158 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5159 &mut talk,
5160 &talks,
5161 &cfg,
5162 &text,
5163 )
5164 .await
5165 .expect("respond must survive a write failure its own retries can outlast");
5166
5167 assert_eq!(talk.turns.len(), 2);
5168 assert_eq!(talk.turns[1].who, Who::Agent);
5169 assert_eq!(talk.turns[1].body, "go ahead");
5170 let on_disk = talks.get(&talk.id).expect("get");
5171 assert_eq!(
5172 on_disk.turns, talk.turns,
5173 "the reply must reach disk despite the early write failures"
5174 );
5175 }
5176
5177 #[tokio::test]
5183 async fn a_persistent_write_failure_while_saving_the_reply_is_never_silent() {
5184 let (tmp, talks) = store();
5185 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5186 let cfg = config(spec);
5187 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5188
5189 let text = record(&mut talk, &talks, "check the tests", Vec::new()).expect("record");
5190 failpoint::force_put_failures(PUT_RETRIES);
5195 let err = respond(
5196 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5197 &mut talk,
5198 &talks,
5199 &cfg,
5200 &text,
5201 )
5202 .await
5203 .expect_err("a reply that cannot be saved must be reported, not swallowed");
5204 assert!(err.to_string().contains("could not be saved"), "{err}");
5205
5206 let on_disk = talks.get(&talk.id).expect("get");
5207 assert_eq!(
5208 on_disk.turns.len(),
5209 2,
5210 "the operator turn plus a visible note"
5211 );
5212 assert_eq!(on_disk.turns[0].body, "check the tests");
5213 let note = &on_disk.turns[1];
5214 assert_eq!(note.who, Who::Agent);
5215 assert!(note.body.starts_with(MAGI_NOTE), "{}", note.body);
5216 assert!(
5217 note.body.contains("could not be saved"),
5218 "the operator must be told the reply is missing, not left staring \
5219 at a gap with no explanation: {}",
5220 note.body
5221 );
5222 assert_eq!(
5223 talk.turns, on_disk.turns,
5224 "the in-memory talk must match what actually landed on disk"
5225 );
5226
5227 let artifacts = talks.artifacts_of(&talk.id);
5230 let stash = std::fs::read_dir(&artifacts)
5231 .expect("artifacts dir")
5232 .filter_map(|e| e.ok())
5233 .find(|e| e.file_name().to_string_lossy().ends_with("-lost.txt"))
5234 .expect("a stash file for the lost reply");
5235 let stashed = std::fs::read_to_string(stash.path()).expect("read stash");
5236 assert_eq!(stashed, "go ahead");
5237
5238 assert_eq!(
5246 on_disk.seat.turns, 1,
5247 "the note's write must carry the turn the CLI actually took"
5248 );
5249 assert_eq!(
5250 on_disk.seat.claude_session, talk.seat.claude_session,
5251 "the session id handed to the CLI must survive the failed reply"
5252 );
5253 assert_eq!(on_disk.seat.captured_session, talk.seat.captured_session);
5254 assert!(
5255 agent::has_session(AgentKind::Command, &on_disk.seat, cfg.graph.sessions),
5256 "the next turn must resume, not open the same session id twice"
5257 );
5258 }
5259
5260 #[tokio::test]
5265 async fn a_write_failure_that_also_loses_the_note_still_reports_it() {
5266 let (tmp, talks) = store();
5267 let spec = mock_agent(tmp.path(), REPLY, env("go ahead"));
5268 let cfg = config(spec);
5269 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5270
5271 let text = record(&mut talk, &talks, "check the tests", Vec::new()).expect("record");
5272 failpoint::force_put_failures(PUT_RETRIES * 2);
5275 let err = respond(
5276 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5277 &mut talk,
5278 &talks,
5279 &cfg,
5280 &text,
5281 )
5282 .await
5283 .expect_err("neither the reply nor the note could be saved");
5284 assert!(err.to_string().contains("could not be saved"), "{err}");
5285
5286 assert_eq!(talk.turns.len(), 1, "only the operator's own turn");
5287 let on_disk = talks.get(&talk.id).expect("get");
5288 assert_eq!(on_disk.turns.len(), 1);
5289
5290 assert_eq!(
5300 on_disk.seat.turns, 0,
5301 "an unwritable file cannot record the turn the CLI took"
5302 );
5303 assert_eq!(
5304 talk.seat.turns, 1,
5305 "the in-memory seat still reports the turn the CLI actually took"
5306 );
5307 assert_eq!(
5308 on_disk.seat.claude_session, talk.seat.claude_session,
5309 "the session id was minted at `begin` and never changes here"
5310 );
5311 }
5312
5313 #[tokio::test]
5317 async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
5318 let (tmp, talks) = store();
5319 let spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
5320 let cfg = config(spec);
5321 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5322
5323 let att = talks
5324 .put_attachment(
5325 &talk.id,
5326 "image/png",
5327 "screenshot.png",
5328 b"pretend-png-bytes",
5329 )
5330 .expect("put attachment");
5331
5332 say(
5333 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5334 &mut talk,
5335 &talks,
5336 &cfg,
5337 "",
5338 vec![att.clone()],
5339 )
5340 .await
5341 .expect("an empty body with an attachment is still a turn");
5342
5343 let operator_turn = &talk.turns[0];
5344 assert_eq!(operator_turn.who, Who::Operator);
5345 assert_eq!(operator_turn.body, "");
5346 assert_eq!(operator_turn.attachments, vec![att.clone()]);
5347
5348 let prompt = &talk.turns[1].body;
5349 let expected_path = talks
5350 .attachments_dir(&talk.id)
5351 .join(format!("{}.png", att.id));
5352 assert!(
5353 prompt.contains(&expected_path.display().to_string()),
5354 "the agent must be told the attachment's absolute path: {prompt}"
5355 );
5356 assert!(prompt.contains("image/png"), "and its mime: {prompt}");
5357 }
5358
5359 #[test]
5368 fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
5369 let talks = Talks::at(PathBuf::from("relative-talks-root-for-this-test"));
5370 let att = Attachment {
5371 id: "0".repeat(32),
5372 name: "shot.png".to_owned(),
5373 mime: "image/png".to_owned(),
5374 bytes: 3,
5375 };
5376 let path = talks
5377 .attachment_path("some-talk-id", &att)
5378 .expect("a supported mime always yields a path");
5379 assert!(
5380 path.is_absolute(),
5381 "must be absolute even off a relative store root: {}",
5382 path.display()
5383 );
5384 }
5385
5386 #[tokio::test]
5387 async fn a_turn_past_the_configured_talk_timeout_is_reported_with_that_timeout() {
5388 let (tmp, talks) = store();
5393 let slow = mock_agent(
5394 tmp.path(),
5395 "#!/bin/sh\ncat >/dev/null\nsleep 2\n",
5396 BTreeMap::new(),
5397 );
5398 let mut cfg = config(slow);
5399 cfg.graph.timeout_talk = 1;
5400 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5401
5402 let err = say(
5403 &talks.claim_turn(&talk.id).unwrap().unwrap(),
5404 &mut talk,
5405 &talks,
5406 &cfg,
5407 "check the tests",
5408 Vec::new(),
5409 )
5410 .await
5411 .expect_err("a turn that never answers is an error");
5412 assert!(
5413 err.to_string().contains("did not answer within 1s"),
5414 "{err}"
5415 );
5416
5417 let on_disk = talks.get(&talk.id).expect("get");
5418 let note = on_disk.turns.last().expect("a note turn was recorded");
5419 assert!(
5420 note.body.contains("did not answer within 1s"),
5421 "the transcript must show the configured timeout: {}",
5422 note.body
5423 );
5424 }
5425
5426 #[test]
5427 fn closing_is_idempotent_and_a_closed_talk_takes_no_more_turns() {
5428 let (tmp, talks) = store();
5429 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5430 let cfg = config(spec);
5431 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5432
5433 close(&mut talk, &talks).expect("close");
5434 assert_eq!(talk.status, TalkStatus::Closed);
5435 close(&mut talk, &talks).expect("closing twice is not an error");
5436
5437 let err =
5438 record(&mut talk, &talks, "still there?", Vec::new()).expect_err("closed talks refuse");
5439 assert!(err.to_string().contains("closed"));
5440 let _ = &cfg; }
5442
5443 #[tokio::test]
5444 async fn a_close_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
5445 let (tmp, talks) = store();
5446 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
5447 let cfg = config(spec);
5448 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5451
5452 let mut closed_elsewhere = talks.get(&in_flight.id).expect("reread");
5456 close(&mut closed_elsewhere, &talks).expect("close");
5457 assert_eq!(
5458 talks.get(&in_flight.id).expect("reread").status,
5459 TalkStatus::Closed,
5460 "the close landed on disk before the turn finished"
5461 );
5462
5463 assert_eq!(in_flight.status, TalkStatus::Open);
5467 respond(
5468 &talks.claim_turn(&in_flight.id).unwrap().unwrap(),
5469 &mut in_flight,
5470 &talks,
5471 &cfg,
5472 "one more question",
5473 )
5474 .await
5475 .expect("the turn itself still completes");
5476
5477 let on_disk = talks.get(&in_flight.id).expect("reread");
5478 assert_eq!(
5479 on_disk.status,
5480 TalkStatus::Closed,
5481 "a close must stick even when a turn that started before it finishes after it"
5482 );
5483 assert!(
5486 on_disk.turns.iter().any(|t| t.body == "here you go"),
5487 "the in-flight turn's own reply is still recorded: {:?}",
5488 on_disk.turns
5489 );
5490 }
5491
5492 #[test]
5493 fn a_close_that_lands_before_record_is_called_is_not_undone_by_it() {
5494 let (tmp, talks) = store();
5495 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5496 let cfg = config(spec);
5497 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5500
5501 let mut closed_elsewhere = talks.get(&stale.id).expect("reread");
5504 close(&mut closed_elsewhere, &talks).expect("close");
5505 assert_eq!(
5506 talks.get(&stale.id).expect("reread").status,
5507 TalkStatus::Closed,
5508 "the close landed on disk before record was called"
5509 );
5510
5511 assert_eq!(stale.status, TalkStatus::Open);
5515 let err = record(&mut stale, &talks, "still there?", Vec::new())
5516 .expect_err("a close that landed first must be honored, not overwritten");
5517 assert!(err.to_string().contains("closed"));
5518
5519 let on_disk = talks.get(&stale.id).expect("reread");
5520 assert_eq!(
5521 on_disk.status,
5522 TalkStatus::Closed,
5523 "record must not resurrect a conversation closed while its snapshot was stale"
5524 );
5525 assert!(
5526 on_disk.turns.is_empty(),
5527 "the rejected turn must not have been appended: {:?}",
5528 on_disk.turns
5529 );
5530 let _ = &cfg; }
5532
5533 #[test]
5534 fn consultation_queue_does_not_wait_on_either_conversation_lock() {
5535 for evidence in [true, false] {
5536 let (tmp, talks) = store();
5537 let cfg = config(mock_agent(tmp.path(), REPLY, env("hi")));
5538 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).unwrap();
5539 close(&mut talk, &talks).unwrap();
5540 let answer = evidence.then(|| talks.answer_guard().unwrap());
5541 let file = (!evidence).then(|| {
5542 TurnLock::take(&talks.root.join(".store.turn"))
5543 .unwrap()
5544 .unwrap()
5545 });
5546 assert!(
5547 try_queue_consult(&talks, &talk.id, "consultation", None)
5548 .unwrap()
5549 .is_none()
5550 );
5551 let saved = talks.get(&talk.id).unwrap();
5552 assert_eq!(saved.status, TalkStatus::Closed);
5553 assert!(saved.pending_messages.is_empty());
5554 drop(file);
5555 drop(answer);
5556 assert!(
5557 try_queue_consult(&talks, &talk.id, "consultation", None)
5558 .unwrap()
5559 .is_some()
5560 );
5561 let saved = talks.get(&talk.id).unwrap();
5562 assert_eq!(saved.status, TalkStatus::Open);
5563 assert_eq!(saved.pending, "consultation");
5564 assert_eq!(saved.pending_messages.len(), 1);
5565 assert_eq!(saved.pending_messages[0].sender, Some(Sender::Magi));
5566 }
5567 }
5568
5569 #[test]
5570 fn evidence_try_lock_does_not_wait_for_either_store_handle() {
5571 let (tmp, talks) = store();
5572 let guard = talks.answer_guard().unwrap();
5573 assert!(talks.try_answer_guard().unwrap().is_none());
5575 let other = Talks::at(tmp.path().join("talks"));
5576 assert!(other.try_answer_guard().unwrap().is_none());
5577 drop(guard);
5578 assert!(other.try_answer_guard().unwrap().is_some());
5579 }
5580
5581 #[test]
5582 fn owner_retraction_waits_until_the_authorized_question_is_persisted() {
5583 use crate::ask::{Answer, Question, Questions};
5584 use std::sync::mpsc;
5585
5586 let (tmp, talks) = store();
5587 let cfg = config(mock_agent(tmp.path(), REPLY, env("hi")));
5588 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).unwrap();
5589 let questions = Questions::at(tmp.path().join("questions"));
5590 let mut q = Question::new(
5591 "run".into(),
5592 crate::land::APPROVAL_NODE.into(),
5593 "land".into(),
5594 "Merge?".into(),
5595 String::new(),
5596 vec!["merge".into(), "hold".into()],
5597 );
5598 questions.put(&mut q).unwrap();
5599 let instruction = format!("{}: hold", q.id);
5600 record(&mut talk, &talks, &instruction, vec![]).unwrap();
5601 let (started_tx, started_rx) = mpsc::channel();
5602 let (finished_tx, finished_rx) = mpsc::channel();
5603 std::thread::scope(|scope| {
5604 let mut writer = None;
5605 let (saved, guard) = questions
5606 .update(&q.id, |q| {
5607 let guard = talks.try_answer_guard()?.context("evidence is busy")?;
5608 q.answer_authority = crate::consult::authorize_answer(
5609 q,
5610 &talks,
5611 &questions,
5612 &talk.id,
5613 crate::queue::CHAT_NODE,
5614 "hold",
5615 Some(&instruction),
5616 )?;
5617 assert!(q.answer_authority.is_some());
5618 let talks = talks.clone();
5619 let mut snapshot = talk.clone();
5620 writer = Some(scope.spawn(move || {
5621 started_tx.send(()).unwrap();
5622 queue(&mut snapshot, &talks, "wait", vec![]).unwrap();
5623 finished_tx.send(()).unwrap();
5624 }));
5625 started_rx.recv().unwrap();
5626 assert!(matches!(
5627 finished_rx.recv_timeout(Duration::from_millis(100)),
5628 Err(mpsc::RecvTimeoutError::Timeout)
5629 ));
5630 q.answer(Answer::Choice("hold".into()))?;
5631 Ok(guard)
5633 })
5634 .unwrap();
5635 assert_eq!(saved.resolution().as_deref(), Some("hold"));
5636 assert_eq!(
5637 questions.get(&q.id).unwrap().resolution().as_deref(),
5638 Some("hold")
5639 );
5640 assert!(matches!(
5641 finished_rx.recv_timeout(Duration::from_millis(100)),
5642 Err(mpsc::RecvTimeoutError::Timeout)
5643 ));
5644 drop(guard);
5645 writer.unwrap().join().unwrap();
5646 finished_rx.recv().unwrap();
5647 });
5648 assert_eq!(talks.get(&talk.id).unwrap().pending, "wait");
5649 }
5650
5651 #[test]
5652 fn close_blocks_on_records_guard_rather_than_interleaving_with_it() {
5653 let (tmp, talks) = store();
5654 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5655 let cfg = config(spec);
5656 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5657
5658 let held = talks.guard().unwrap();
5662
5663 let talks2 = talks.clone();
5664 let id = talk.id.clone();
5665 let closing = std::thread::spawn(move || {
5666 let mut talk = talks2.get(&id).expect("get");
5667 close(&mut talk, &talks2).expect("close");
5668 });
5669
5670 std::thread::sleep(Duration::from_millis(50));
5671 assert!(
5672 !closing.is_finished(),
5673 "close must wait for the guard, not read and write while it is held - \
5674 a re-read alone narrows this window without closing it"
5675 );
5676
5677 drop(held);
5678 closing.join().expect("close thread panicked");
5679
5680 assert_eq!(
5681 talks.get(&talk.id).expect("reread").status,
5682 TalkStatus::Closed,
5683 "once the guard is free, close still lands"
5684 );
5685 let _ = &cfg; }
5687
5688 #[test]
5689 fn reopening_a_closed_talk_lets_it_take_turns_again_and_reopening_twice_is_not_an_error() {
5690 let (tmp, talks) = store();
5691 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5692 let cfg = config(spec);
5693 let mut talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5694
5695 close(&mut talk, &talks).expect("close");
5696 assert_eq!(talk.status, TalkStatus::Closed);
5697
5698 reopen(&mut talk, &talks).expect("reopen");
5699 assert_eq!(talk.status, TalkStatus::Open);
5700 assert_eq!(
5701 talks.get(&talk.id).expect("reread").status,
5702 TalkStatus::Open
5703 );
5704
5705 reopen(&mut talk, &talks).expect("reopening an open talk is not an error");
5707 assert_eq!(talk.status, TalkStatus::Open);
5708
5709 record(&mut talk, &talks, "one more thing", Vec::new())
5710 .expect("a reopened talk takes turns again");
5711 let _ = &cfg; }
5713
5714 #[test]
5715 fn removing_a_talk_deletes_its_record_and_artifacts_and_refuses_an_unknown_id() {
5716 let (tmp, talks) = store();
5717 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5718 let cfg = config(spec);
5719 let talk = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5720
5721 let artifacts = talks.artifacts_of(&talk.id);
5722 std::fs::create_dir_all(&artifacts).expect("create artifacts dir");
5723 std::fs::write(artifacts.join("turn-1.txt"), "hello").expect("write artifact");
5724
5725 talks.remove(&talk.id).expect("remove");
5726 assert!(!talks.path_of(&talk.id).is_file(), "the record is gone");
5727 assert!(!artifacts.is_dir(), "the artifacts directory is gone");
5728 assert!(
5729 talks.get(&talk.id).is_err(),
5730 "a removed talk cannot be read back"
5731 );
5732
5733 let err = talks
5734 .remove("nonexistent-id")
5735 .expect_err("unknown id refused");
5736 assert!(err.to_string().contains("no talk matches"), "{err}");
5737 let _ = &cfg; }
5739
5740 #[tokio::test]
5741 async fn a_delete_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
5742 let (tmp, talks) = store();
5743 let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
5744 let cfg = config(spec);
5745 let mut in_flight = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5748
5749 talks.remove(&in_flight.id).expect("remove");
5750 assert!(
5751 talks.get(&in_flight.id).is_err(),
5752 "the delete landed on disk before the turn finished"
5753 );
5754
5755 respond(
5758 &talks.claim_turn(&in_flight.id).unwrap().unwrap(),
5759 &mut in_flight,
5760 &talks,
5761 &cfg,
5762 "one more question",
5763 )
5764 .await
5765 .expect("the turn itself still completes rather than erroring");
5766
5767 assert!(
5768 talks.get(&in_flight.id).is_err(),
5769 "a delete must stick even when a turn that started before it finishes after it"
5770 );
5771 }
5772
5773 #[test]
5774 fn a_delete_that_lands_before_record_is_called_is_not_undone_by_it() {
5775 let (tmp, talks) = store();
5776 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5777 let cfg = config(spec);
5778 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5781
5782 talks.remove(&stale.id).expect("remove");
5783
5784 let err = record(&mut stale, &talks, "still there?", Vec::new())
5788 .expect_err("a delete that landed first must be honored, not overwritten");
5789 assert!(err.to_string().contains("deleted"), "{err}");
5790
5791 assert!(
5792 talks.get(&stale.id).is_err(),
5793 "record must not resurrect a conversation deleted while its snapshot was stale"
5794 );
5795 let _ = &cfg; }
5797
5798 #[test]
5799 fn a_delete_that_lands_before_close_is_called_is_not_undone_by_it() {
5800 let (tmp, talks) = store();
5801 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5802 let cfg = config(spec);
5803 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5806
5807 talks.remove(&stale.id).expect("remove");
5808
5809 let err = close(&mut stale, &talks)
5813 .expect_err("a delete that landed first must be honored, not overwritten");
5814 assert!(err.to_string().contains("deleted"), "{err}");
5815
5816 assert!(
5817 talks.get(&stale.id).is_err(),
5818 "close must not resurrect a conversation deleted while its snapshot was stale"
5819 );
5820 let _ = &cfg; }
5822
5823 #[test]
5824 fn a_delete_that_lands_before_reopen_is_called_is_not_undone_by_it() {
5825 let (tmp, talks) = store();
5826 let spec = mock_agent(tmp.path(), REPLY, env("hi"));
5827 let cfg = config(spec);
5828 let mut stale = begin(&talks, &cfg, tmp.path().to_owned(), None).expect("begin");
5831 close(&mut stale, &talks).expect("close");
5832
5833 talks.remove(&stale.id).expect("remove");
5834
5835 let err = reopen(&mut stale, &talks)
5839 .expect_err("a delete that landed first must be honored, not overwritten");
5840 assert!(err.to_string().contains("deleted"), "{err}");
5841
5842 assert!(
5843 talks.get(&stale.id).is_err(),
5844 "reopen must not resurrect a conversation deleted while its snapshot was stale"
5845 );
5846 let _ = &cfg; }
5848
5849 #[test]
5850 fn list_puts_open_talks_before_closed_ones() {
5851 let (tmp, talks) = store();
5852 let make = |id: &str, status: TalkStatus| {
5853 let mut t = Talk {
5854 posts_read_through: None,
5855 pending_breaks: None,
5856 pending_messages: Vec::new(),
5857 schema: SCHEMA,
5858 id: id.to_owned(),
5859 repo: tmp.path().to_owned(),
5860 agent: "mock".to_owned(),
5861 status,
5862 turns: Vec::new(),
5863 pending: String::new(),
5864 pending_attachments: Vec::new(),
5865 fallback: false,
5866 persona: String::new(),
5867 persona_dirty: false,
5868 implementers: 1,
5869 implementers_dirty: false,
5870 notified: Vec::new(),
5871 completion_notices: Vec::new(),
5872 created_at: Timestamp::now(),
5873 updated_at: Timestamp::now(),
5874 seat: SeatState::new(SEAT, "mock", 7),
5875 };
5876 talks.put(&mut t).expect("put");
5877 };
5878 make("20260901-000000-0001", TalkStatus::Open);
5879 make("20260902-000000-0002", TalkStatus::Open);
5880 make("20260903-000000-0003", TalkStatus::Closed);
5881
5882 let ids: Vec<String> = talks.list().into_iter().map(|t| t.id).collect();
5883 assert_eq!(
5884 ids,
5885 [
5886 "20260902-000000-0002",
5887 "20260901-000000-0001",
5888 "20260903-000000-0003"
5889 ]
5890 );
5891 assert_eq!(talks.count_open(), 2);
5892 }
5893
5894 #[test]
5895 fn tasks_of_finds_only_this_talks_own_tasks() {
5896 let dir = tempfile::tempdir().expect("tempdir");
5897 let queue = Queue::at(dir.path().join("queue"));
5898
5899 let mut mine = Task::new(
5900 "rework the loader".to_owned(),
5901 "rework the loader".to_owned(),
5902 PathBuf::from("/repo"),
5903 Source::Agent {
5904 run: "20260904-014455-ab12".to_owned(),
5905 node: "chat".to_owned(),
5906 },
5907 );
5908 queue.put(&mut mine).expect("put mine");
5909
5910 let mut theirs = Task::new(
5911 "unrelated".to_owned(),
5912 "unrelated".to_owned(),
5913 PathBuf::from("/repo"),
5914 Source::Agent {
5915 run: "20260904-090000-zz99".to_owned(),
5916 node: "implement".to_owned(),
5917 },
5918 );
5919 queue.put(&mut theirs).expect("put theirs");
5920
5921 let mut human = Task::new(
5922 "typed by hand".to_owned(),
5923 "typed by hand".to_owned(),
5924 PathBuf::from("/repo"),
5925 Source::Human,
5926 );
5927 queue.put(&mut human).expect("put human");
5928
5929 let found = tasks_of(&queue, "20260904-014455-ab12");
5930 assert_eq!(found.len(), 1);
5931 assert_eq!(found[0].id, mine.id);
5932 }
5933
5934 #[test]
5935 fn the_briefing_names_solo_task_add() {
5936 let brief = briefing(Path::new("/repo"), "en", false);
5937 assert!(brief.contains("magi task add --solo"));
5938 assert!(brief.contains("/repo"));
5939 assert!(!brief.contains("Hold this conversation in"));
5940 }
5941
5942 #[test]
5948 fn the_briefing_explains_targeting_a_different_repository_by_name() {
5949 let brief = briefing(Path::new("/repo"), "en", false);
5950 assert!(brief.contains("--repo does not have to be a full path"));
5951 assert!(brief.contains("owner/repo"));
5952 assert!(brief.contains("magi repos"));
5953 assert!(brief.contains("ask the operator"));
5954 }
5955
5956 #[test]
5957 fn the_briefing_tells_the_assistant_to_pass_images_with_attach() {
5958 let brief = briefing(Path::new("/repo"), "en", false);
5959 assert!(brief.contains("--attach <path>"), "{brief}");
5960 assert!(brief.contains("deleting this conversation"), "{brief}");
5961 }
5962
5963 #[test]
5964 fn the_briefing_names_the_language_when_it_is_not_english() {
5965 let brief = briefing(Path::new("/repo"), "Japanese", false);
5966 assert!(brief.contains("Hold this conversation in Japanese"));
5967 }
5968
5969 #[test]
5970 fn the_briefing_forbids_writes_unless_the_repository_opted_in() {
5971 let read_only = briefing(Path::new("/repo"), "en", false);
5972 assert!(read_only.contains("Do not write files"));
5973 assert!(!read_only.contains("allow_write"));
5974
5975 let writable = briefing(Path::new("/repo"), "en", true);
5976 assert!(!writable.contains("Do not write files"));
5977 assert!(writable.contains("allow_write = true"));
5978 assert!(writable.contains("magi task add --solo"));
5981 assert!(writable.contains("say plainly what you"));
5982 }
5983
5984 #[test]
5985 fn the_briefing_says_the_chat_cannot_push_and_tasks_report_back() {
5986 let b = briefing_for(Path::new("/r"), "en", false, None, None, 1);
5987 assert!(
5988 b.contains("cannot send the operator a message on your own"),
5989 "{b}"
5990 );
5991 assert!(b.contains(crate::prompt::CHAT_TASK_NOTICE_HEADING));
5992 assert!(b.contains("magi talk watch --timeout 900"));
5993 assert!(b.contains("nor a successfully registered watch"));
5994 }
5995
5996 #[test]
5997 fn a_post_is_replayed_until_the_agent_answers_again() {
5998 let (_tmp, talks) = store();
5999 let mut talk = ctx_talk("mock", Vec::new());
6000 talks.put(&mut talk).expect("put");
6001 post(&talks, &talk.id, " found it ", "20260902-000000-aaaa").expect("post");
6002 let mut talk = talks.get(&talk.id).expect("get");
6003 let block = posts_since_last_reply(&talk).expect("block");
6004 assert!(block.starts_with("# Posted by a task"), "{block}");
6005 assert!(block.contains("found it"));
6006 talk.turns.push(reply("ok", None));
6007 assert!(posts_since_last_reply(&talk).is_none());
6008 assert!(post(&talks, &talk.id, " ", "r").is_err());
6009 close(&mut talk, &talks).expect("close");
6010 assert!(post(&talks, &talk.id, "late", "r").is_err());
6011 }
6012
6013 #[test]
6014 fn queue_once_accepts_a_key_a_single_time_even_after_a_drain() {
6015 let (_tmp, talks) = store();
6016 let mut talk = ctx_talk("mock", Vec::new());
6017 talks.put(&mut talk).expect("put");
6018 assert!(queue_once(&mut talk, &talks, "one", "k").expect("first"));
6019 drain(&mut talk, &talks).expect("drain");
6020 assert!(!queue_once(&mut talk, &talks, "one", "k").expect("again"));
6021 assert!(talks.get(&talk.id).expect("get").pending.is_empty());
6022 }
6023 #[test]
6024 fn sender_mixed_notifications_survive_reload_drain_and_reply() {
6025 let (_tmp, talks) = store();
6026 let mut talk = ctx_talk("mock", Vec::new());
6027 talks.put(&mut talk).unwrap();
6028 queue(&mut talk, &talks, "owner", Vec::new()).unwrap();
6029 assert!(queue_once(&mut talk, &talks, "notice", "n").unwrap());
6030 let mut talk = talks.get(&talk.id).unwrap();
6031 assert_eq!(talk.pending_messages[0].sender, Some(Sender::Operator));
6032 assert_eq!(talk.pending_messages[1].sender, Some(Sender::Magi));
6033 assert!(
6034 edit_pending_message(&mut talk, &talks, "forged", "owner\n\nnotice", &[], Some(1))
6035 .is_err()
6036 );
6037 drain(&mut talk, &talks).unwrap();
6038 let mut talk = talks.get(&talk.id).unwrap();
6039 let turn = talk.turns.last().unwrap();
6040 assert_eq!(turn.who, Who::Operator);
6041 assert_eq!(sender(turn, turn.messages.first()), Sender::Operator);
6042 assert_eq!(sender(turn, turn.messages.get(1)), Sender::Magi);
6043 let reply = resolve_reply(
6044 &talk,
6045 Some(ReplyTarget {
6046 turn: 0,
6047 message: 1,
6048 }),
6049 )
6050 .unwrap()
6051 .unwrap();
6052 assert_eq!(reply.sender, Some(Sender::Magi));
6053 assert!(!queue_once(&mut talk, &talks, "notice", "n").unwrap());
6054 }
6055
6056 #[test]
6057 fn sender_legacy_operator_prose_is_never_migrated_by_heading() {
6058 let mut turn = reply("# Task finished\n\nmagi: operator typed this", None);
6059 turn.who = Who::Operator;
6060 assert_eq!(sender(&turn, None), Sender::Operator);
6061 turn.who = Who::Agent;
6062 turn.body = "magi: failed".into();
6063 assert_eq!(sender(&turn, None), Sender::Magi);
6064 turn.body = "persona reply".into();
6065 assert_eq!(sender(&turn, None), Sender::Persona);
6066 turn.posted_by = Some("task".into());
6067 assert_eq!(sender(&turn, None), Sender::Magi);
6068 turn.posted_by = None;
6069 turn.body = "magi: actual persona words".into();
6070 turn.sender = Some(Sender::Persona);
6071 assert_eq!(sender(&turn, None), Sender::Persona);
6072 }
6073}