1use anyhow::{Context, Result, bail};
21use jiff::Timestamp;
22
23use crate::ask::{ChatConsult, Question, Questions};
24use crate::config::Config;
25use crate::queue::{CHAT_NODE, Task};
26use crate::talk::{self, Talk, Talks};
27
28pub fn origin_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
34 if !q.status.open() || q.node == crate::bump::NOTICE_NODE {
35 return None;
36 }
37 let task = crate::daemon::task_of_question(tasks, q)?;
38 let run = chat_talk_of(tasks, task)?;
39 talks.iter().find(|t| t.id == run).cloned()
40}
41
42pub fn chat_talk_of(tasks: &[Task], start: &Task) -> Option<String> {
54 let mut seen = std::collections::HashSet::new();
55 let mut cur = start;
56 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
57 if !seen.insert(cur.id.as_str()) {
58 return None;
59 }
60 if let Some(id) = cur.chat_talk() {
61 return Some(id.to_owned());
62 }
63 let f = cur.followup.as_ref()?;
64 cur = match &f.origin_task {
65 Some(id) => tasks.iter().find(|t| &t.id == id)?,
66 None => tasks.iter().find(|t| t.runs.contains(&f.run))?,
67 };
68 }
69 None
70}
71
72pub fn pending_consults(questions: &Questions, talk_id: &str) -> bool {
79 questions
80 .list()
81 .iter()
82 .any(|q| q.status.open() && q.consult.as_ref().is_some_and(|c| c.talk == talk_id))
83}
84
85pub fn begin(questions: &Questions, talks: &Talks, q: &Question, talk: &Talk) -> Result<bool> {
91 let (q, fresh) = questions.update(&q.id, |r| {
92 if !r.status.open() {
93 bail!("question {} is already {}", r.short(), r.status.as_str());
94 }
95 if r.consult.is_some() {
96 return Ok(false);
97 }
98 r.consult = Some(ChatConsult {
99 talk: talk.id.clone(),
100 at: Timestamp::now(),
101 });
102 Ok(true)
103 })?;
104 if !fresh {
105 return Ok(false);
106 }
107 let mut talk = talk.clone();
108 if !talk.status.open()
111 && let Err(e) = talk::reopen(&mut talk, talks)
112 {
113 let _ = questions.update(&q.id, |r| {
114 r.consult = None;
115 Ok(())
116 });
117 return Err(e).context("reopen the chat");
118 }
119 if let Err(e) = talk::queue_magi(&mut talk, talks, &crate::prompt::chat_consult(&q)) {
120 let _ = questions.update(&q.id, |r| {
121 r.consult = None;
122 Ok(())
123 });
124 return Err(e).context("queue the question into the chat");
125 }
126 Ok(true)
127}
128
129pub fn refuse_operator_held(q: &Question, node: &str, tasks: &[crate::queue::Task]) -> Result<()> {
136 if q.node != crate::land::APPROVAL_NODE || node != CHAT_NODE {
137 return Ok(());
138 }
139 if let Some(task) = tasks
140 .iter()
141 .find(|t| t.id == q.run || t.runs.contains(&q.run))
142 && task.operator_held()
143 {
144 bail!(
145 "task {} is held by the operator; an answer cannot settle it",
146 task.short()
147 );
148 }
149 Ok(())
150}
151
152#[derive(Clone, Copy, PartialEq, Eq)]
154pub enum QuestionKind {
155 Ordinary,
157 Merge,
159 Posting,
161}
162
163impl QuestionKind {
164 pub fn of(q: &Question) -> Self {
166 match q.node.as_str() {
167 crate::land::APPROVAL_NODE => Self::Merge,
168 crate::github_text::ASK_NODE => Self::Posting,
169 _ => Self::Ordinary,
170 }
171 }
172}
173
174pub fn validate_answer(
178 q: &Question,
179 talks: &Talks,
180 run: &str,
181 node: &str,
182 reply: &str,
183 quote: Option<&str>,
184) -> Result<()> {
185 let kind = QuestionKind::of(q);
186 if node != CHAT_NODE || kind == QuestionKind::Ordinary {
187 return Ok(());
188 }
189 let posting = kind == QuestionKind::Posting;
190 if posting && !q.choices.iter().any(|c| c == reply) {
191 bail!("posting confirmation requires one of the offered choices");
192 }
193 if posting
194 && !matches!(
195 reply,
196 crate::github_text::POST_ORIGINAL | crate::github_text::USE_REWRITE
197 )
198 {
199 return Ok(());
200 }
201 let subject = if posting {
202 "posting confirmation"
203 } else {
204 "merge approval"
205 };
206 let consult = q
207 .consult
208 .as_ref()
209 .with_context(|| format!("{subject} was not handed to a chat"))?;
210 if consult.talk != run {
211 bail!("{subject} belongs to a different chat");
212 }
213 if !q.status.open() {
214 bail!("{subject} is no longer open");
215 }
216 let talk = talks.get(run)?;
217 if !talk.status.open() {
218 bail!("the consulted chat is closed");
219 }
220 let at = talk
224 .turns
225 .iter()
226 .rposition(|t| {
227 t.who == talk::Who::Operator
228 && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
229 && t.body.contains(&q.id)
230 })
231 .context("the question was not delivered to the chat yet")?;
232 let queued = latest_owner_message(
234 &talk.pending,
235 talk.pending_breaks.as_deref(),
236 &talk.pending_messages,
237 None,
238 posting,
239 );
240 let stored = talk.turns[at..]
241 .iter()
242 .enumerate()
243 .rev()
244 .filter(|(_, t)| t.who == talk::Who::Operator)
245 .find_map(|(i, t)| {
246 latest_owner_message(
247 &t.body,
248 t.breaks.as_deref(),
249 &t.messages,
250 (i == 0).then_some(q.id.as_str()),
251 posting,
252 )
253 });
254 let latest = queued
255 .or(stored)
256 .context("no owner message after the question was handed to the chat")?;
257 let quote = quote
258 .map(str::trim)
259 .filter(|s| !s.is_empty())
260 .with_context(|| {
261 format!("chat {subject} requires --quote from the owner's latest message")
262 })?;
263 if posting {
264 if !latest.contains(quote) {
265 bail!("posting confirmation requires a verbatim quote of the latest owner message");
266 }
267 if !crate::github_text::posting_authorized(&latest, quote, reply) {
268 bail!(
269 "posting confirmation requires an explicit, unconditional instruction for the chosen text; ask the owner to clarify"
270 );
271 }
272 return Ok(());
273 }
274 let valid = match reply {
275 crate::land::APPROVE => latest.contains(quote),
276 crate::land::HOLD => {
277 latest.trim().eq_ignore_ascii_case(crate::land::HOLD)
278 && quote.eq_ignore_ascii_case(crate::land::HOLD)
279 }
280 crate::land::REPAIR => {
281 q.choices.iter().any(|c| c == reply) && latest.trim() == reply && quote == reply
282 }
283 _ => false,
284 };
285 if !valid {
286 bail!(
287 "merge requires a verbatim quote of the latest owner message; hold requires the whole message to be hold"
288 );
289 }
290 Ok(())
291}
292
293#[cfg(test)]
303fn latest_message(
304 body: &str,
305 breaks: Option<&[usize]>,
306 after_block_of: Option<&str>,
307) -> Option<String> {
308 latest_message_for_policy(body, breaks, after_block_of, false)
309}
310
311fn latest_owner_message(
312 body: &str,
313 breaks: Option<&[usize]>,
314 messages: &[talk::Message],
315 after: Option<&str>,
316 posting: bool,
317) -> Option<String> {
318 if messages.is_empty() {
319 return latest_message_for_policy(body, breaks, after, posting);
320 }
321 let first = after.map_or(0, |id| {
322 messages
323 .iter()
324 .rposition(|m| {
325 m.body.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.body.contains(id)
326 })
327 .unwrap_or(0)
328 });
329 let aligned = messages
332 .iter()
333 .map(|m| m.body.as_str())
334 .collect::<Vec<_>>()
335 .join("\n\n")
336 == body;
337 let valid_breaks = breaks.filter(|cuts| {
338 cuts.windows(2).all(|w| w[0] < w[1])
339 && cuts.iter().all(|&o| {
340 o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
341 })
342 });
343 let mut offset = 0;
344 let mut latest = None;
345 for (i, message) in messages.iter().enumerate() {
346 let end = offset + message.body.len();
347 if i >= first && message.sender != Some(talk::Sender::Magi) {
348 let local_breaks = aligned.then_some(valid_breaks).flatten().map(|cuts| {
349 cuts.iter()
350 .copied()
351 .filter(|&cut| cut > offset && cut <= end)
352 .map(|cut| cut - offset)
353 .collect::<Vec<_>>()
354 });
355 let message_breaks = if message.sender.is_some() {
356 Some(&[][..])
357 } else {
358 local_breaks.as_deref()
359 };
360 if let Some(words) = latest_message_for_policy(
361 &message.body,
362 message_breaks,
363 after.filter(|_| i == first),
364 posting,
365 ) {
366 latest = Some(words);
367 }
368 }
369 offset = end + 2;
370 }
371 latest
372}
373
374fn latest_message_for_policy(
375 body: &str,
376 breaks: Option<&[usize]>,
377 after_block_of: Option<&str>,
378 posting: bool,
379) -> Option<String> {
380 let cuts = breaks.filter(|b| {
381 b.windows(2).all(|w| w[0] < w[1])
382 && b.iter().all(|&o| {
383 o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
384 })
385 });
386 let Some(cuts) = cuts else {
387 if is_completion_notice(body) {
388 return None;
389 }
390 let words = owner_words_for_policy(body, after_block_of, posting);
391 if posting && words.contains("\n\n") {
395 return Some(String::new());
398 }
399 if posting {
400 return (!words.trim().is_empty()).then_some(words);
401 }
402 return words
403 .rsplit("\n\n")
404 .map(str::trim)
405 .find(|p| !p.is_empty())
406 .map(str::to_owned);
407 };
408 let mut starts = vec![0];
409 starts.extend_from_slice(cuts);
410 let messages: Vec<&str> = starts
411 .iter()
412 .enumerate()
413 .map(|(i, &s)| {
414 let e = starts.get(i + 1).map_or(body.len(), |&n| n - 2);
415 &body[s..e]
416 })
417 .collect();
418 let first = after_block_of.map_or(0, |id| {
420 messages
421 .iter()
422 .rposition(|m| m.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.contains(id))
423 .unwrap_or(0)
424 });
425 messages
426 .iter()
427 .enumerate()
428 .skip(first)
429 .rev()
430 .filter(|(_, m)| !is_completion_notice(m))
431 .map(|(i, m)| owner_words_for_policy(m, after_block_of.filter(|_| i == first), posting))
432 .find(|w| !w.trim().is_empty())
433}
434
435fn is_completion_notice(message: &str) -> bool {
438 crate::prompt::is_completion_notice(message)
439}
440
441fn legacy_len(rest: &str) -> usize {
448 use crate::prompt::CHAT_CONSULT_HEADING;
449 const CLOSERS: [&str; 2] = [
450 "cannot be revived. Do not edit the repository.",
451 "make: do not edit the repository.",
452 ];
453 let mut events: Vec<(usize, usize)> = Vec::new(); let first = rest.find(CHAT_CONSULT_HEADING).unwrap_or(0);
455 for (i, _) in rest.match_indices(CHAT_CONSULT_HEADING) {
456 if i > first
457 && rest[..i].ends_with("# ")
458 && rest[..i - 2].ends_with('\n')
459 && rest[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
460 {
461 events.push((i, 0));
462 }
463 }
464 for c in CLOSERS {
465 events.extend(rest.match_indices(c).map(|(i, _)| (i, c.len())));
466 }
467 events.sort_unstable();
468 let mut depth = 1usize;
469 for (at, len) in events {
470 if len == 0 {
471 depth += 1;
472 } else {
473 depth -= 1;
474 if depth == 0 {
475 return at + len;
476 }
477 }
478 }
479 rest.len()
480}
481
482#[cfg(test)]
496fn owner_words(body: &str, after_block_of: Option<&str>) -> String {
497 owner_words_for_policy(body, after_block_of, false)
498}
499
500fn owner_words_for_policy(body: &str, after_block_of: Option<&str>, posting: bool) -> String {
501 let trim = |text: &str| -> String {
504 if posting {
505 text.trim_matches(['\r', '\n']).to_owned()
506 } else {
507 text.trim().to_owned()
508 }
509 };
510 use crate::prompt::{CHAT_CONSULT_END, CHAT_CONSULT_HEADING};
511 let heads: Vec<usize> = body
515 .match_indices(CHAT_CONSULT_HEADING)
516 .map(|(i, _)| i)
517 .filter(|&i| {
518 let line_start = body[..i]
519 .strip_suffix("# ")
520 .is_some_and(|b| b.is_empty() || b.ends_with('\n'));
521 line_start
522 && body[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
523 })
524 .collect();
525 if heads.is_empty() {
526 return trim(body);
527 }
528 let mut blocks: Vec<(usize, usize)> = Vec::new();
535 for &h in &heads {
536 let start = if body[..h].ends_with("# ") { h - 2 } else { h };
537 if blocks.last().is_some_and(|&(_, e)| start < e) {
538 continue; }
540 let rest = &body[h..];
541 let head = rest.split("\n\n## ").next().unwrap_or(rest);
542 let end = if head.contains(crate::prompt::CHAT_CONSULT_DEFUSED) {
543 rest.find(CHAT_CONSULT_END)
546 .map_or(body.len(), |p| h + p + CHAT_CONSULT_END.len())
547 } else {
548 h + legacy_len(rest)
549 };
550 blocks.push((start, end));
551 }
552 let mut from = 0;
553 if let Some(id) = after_block_of {
554 if let Some(&(_, end)) = blocks.iter().rfind(|&&(s, e)| body[s..e].contains(id)) {
555 from = end;
556 } else if let Some(&(_, end)) = blocks.last() {
557 from = end;
558 }
559 }
560 let mut parts = Vec::new();
561 let mut at = from;
562 for &(s, e) in &blocks {
563 if s >= at {
564 parts.push(trim(&body[at..s]));
565 }
566 at = at.max(e);
567 }
568 parts.push(trim(&body[at..]));
569 parts
570 .into_iter()
571 .filter(|p| !p.trim().is_empty())
572 .collect::<Vec<_>>()
573 .join("\n\n")
574}
575
576#[derive(Debug, PartialEq, Eq)]
578pub enum Started {
579 Answered,
581 Busy,
583 Nothing,
585}
586
587pub async fn start_turn(
594 questions: &Questions,
595 talks: &Talks,
596 q: &Question,
597 talk: &Talk,
598 cfg: &Config,
599) -> Result<Started> {
600 if q.consult.is_some() {
601 return Ok(Started::Nothing);
602 }
603 let Some(lease) = talks.claim_turn(&talk.id)? else {
604 return Ok(Started::Busy);
605 };
606 if !begin(questions, talks, q, talk)? {
607 return Ok(Started::Nothing);
608 }
609 drain_turns(talks, &talk.id, lease, cfg).await?;
610 Ok(Started::Answered)
611}
612
613pub async fn drain_turns(
620 talks: &Talks,
621 talk_id: &str,
622 mut lease: talk::TurnLease,
623 cfg: &Config,
624) -> Result<()> {
625 let mut talk = talks.get(talk_id)?;
626 let mut failed = None;
627 loop {
628 while let Some(text) = talk::drain(&mut talk, talks)? {
629 if let Err(e) = talk::respond(&lease, &mut talk, talks, cfg, &text).await {
630 failed.get_or_insert(e);
631 }
632 if !lease.beat()? {
633 bail!("the turn lease for chat {} was lost", talk.short());
634 }
635 }
636 drop(lease);
641 talk = talks.get(&talk.id)?;
642 let owed = talk.status.open()
643 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
644 if !owed {
645 break;
646 }
647 let Some(again) = talks.claim_turn(&talk.id)? else {
649 break;
650 };
651 lease = again;
652 }
653 if let Some(e) = failed {
654 return Err(e);
655 }
656 Ok(())
657}
658
659#[cfg(test)]
660mod tests {
661 use std::collections::BTreeMap;
662 use std::path::PathBuf;
663
664 use super::*;
665 use crate::config::{AgentKind, AgentSpec, Config};
666 use crate::queue::Source;
667 use crate::talk::TalkStatus;
668
669 const RUN: &str = "20260902-000000-beef";
670
671 fn talks() -> (tempfile::TempDir, Talks, Talk) {
672 let tmp = tempfile::tempdir().expect("tempdir");
673 let store = Talks::at(tmp.path().join("talks"));
674 let cfg = Config {
675 agents: vec![AgentSpec {
676 id: "mock".to_owned(),
677 kind: AgentKind::Command,
678 model: None,
679 command: vec!["true".to_owned()],
680 extra_args: Vec::new(),
681 env: BTreeMap::new(),
682 prompt_delivery: None,
683 }],
684 ..Config::default()
685 };
686 let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
687 (tmp, store, talk)
688 }
689
690 fn task(source: Source) -> Task {
691 let mut t = Task::new(
692 "t".to_owned(),
693 "Do it".to_owned(),
694 PathBuf::from("/repo"),
695 source,
696 );
697 t.start(RUN.to_owned());
698 t
699 }
700
701 fn from_chat(talk: &Talk) -> Task {
702 task(Source::Agent {
703 run: talk.id.clone(),
704 node: CHAT_NODE.to_owned(),
705 })
706 }
707
708 fn question(node: &str) -> Question {
709 Question::new(
710 RUN.to_owned(),
711 node.to_owned(),
712 "impl-A".to_owned(),
713 "Which backend?".to_owned(),
714 "SQLite is simpler.".to_owned(),
715 vec!["SQLite".to_owned(), "Redis".to_owned()],
716 )
717 }
718
719 #[test]
720 fn chat_answer_is_refused_for_an_operator_held_task() {
721 let mut task = crate::queue::Task::new(
722 "t".into(),
723 "i".into(),
724 std::path::PathBuf::from("."),
725 crate::queue::Source::Human,
726 );
727 task.runs.push("run-1".into());
728 let mut q = question(crate::land::APPROVAL_NODE);
729 q.run = "run-1".into();
730 let check = |t: &crate::queue::Task, node: &str| {
731 refuse_operator_held(&q, node, std::slice::from_ref(t))
732 };
733 assert!(check(&task, CHAT_NODE).is_ok());
734 task.hold_machine(None);
735 assert!(check(&task, CHAT_NODE).is_ok());
736 task.hold_manual(None);
737 assert!(check(&task, CHAT_NODE).is_err());
738 assert!(check(&task, "implement").is_ok());
740 assert!(refuse_operator_held(&q, CHAT_NODE, &[]).is_ok());
741 }
742
743 #[tokio::test]
744 async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
745 let (tmp, store, talk) = talks();
746 let questions = Questions::at(tmp.path().join("questions"));
747 let mut q = question("implement");
748 questions.put(&mut q).expect("put");
749 let held = store.claim_turn(&talk.id).expect("claim").expect("first");
750
751 let got = start_turn(&questions, &store, &q, &talk, &Config::default())
752 .await
753 .expect("start");
754 assert_eq!(got, Started::Busy);
755 assert!(questions.get(&q.id).unwrap().consult.is_none());
756 assert!(store.get(&talk.id).unwrap().pending.is_empty());
757 drop(held);
758 }
759
760 fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
763 let path = tmp.join("mock-consult-agent.sh");
764 std::fs::write(&path, script).expect("write mock");
765 Config {
766 agents: vec![AgentSpec {
767 id: "mock".to_owned(),
768 kind: AgentKind::Command,
769 model: None,
770 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
771 extra_args: Vec::new(),
772 env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
773 prompt_delivery: None,
774 }],
775 ..Config::default()
776 }
777 }
778
779 async fn run_turn(
782 script: &str,
783 ) -> (
784 tempfile::TempDir,
785 Result<Started>,
786 Questions,
787 Question,
788 Talk,
789 ) {
790 crate::run::set_home(crate::run::test_home());
791 let (tmp, store, talk) = talks();
792 let questions = Questions::at(tmp.path().join("questions"));
793 let mut q = question("implement");
794 questions.put(&mut q).expect("put");
795 let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
796 let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
797 (tmp, got, questions, q, talk)
798 }
799
800 #[tokio::test]
801 async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
802 let (tmp, got, _questions, _q, talk) =
804 run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
805 .await;
806 assert_eq!(got.expect("turn"), Started::Answered);
807 let other = Talks::at(tmp.path().join("talks"));
808 assert!(
809 other.claim_turn(&talk.id).expect("claim").is_some(),
810 "free once the turn ended"
811 );
812 }
813
814 #[tokio::test]
815 async fn the_lease_is_free_again_after_a_failed_turn() {
816 let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
817 assert!(got.is_err(), "the agent failed");
818 assert!(
819 questions.get(&q.id).unwrap().consult.is_some(),
820 "the turn got as far as running"
821 );
822 let other = Talks::at(tmp.path().join("talks"));
823 assert!(
824 other.claim_turn(&talk.id).expect("claim").is_some(),
825 "free after the failed turn"
826 );
827 }
828
829 #[test]
830 fn origin_talk_is_decided_in_one_table() {
831 let (_tmp, _store, talk) = talks();
832 let mut closed = talk.clone();
833 closed.status = TalkStatus::Closed;
834 let chat = from_chat(&talk);
835 let q = question("implement");
836
837 let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
838 assert_eq!(
839 hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
840 Some(talk.id.clone())
841 );
842 assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
844 let other = task(Source::Agent {
845 run: talk.id.clone(),
846 node: "implement".to_owned(),
847 });
848 assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
849 assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
851 assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
853 assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
855 assert!(
857 hit(
858 std::slice::from_ref(&chat),
859 std::slice::from_ref(&talk),
860 &question(crate::land::APPROVAL_NODE)
861 )
862 .is_some()
863 );
864 assert!(
865 hit(
866 std::slice::from_ref(&chat),
867 std::slice::from_ref(&talk),
868 &question(crate::bump::NOTICE_NODE)
869 )
870 .is_none()
871 );
872 let mut answered = question("implement");
874 answered
875 .answer(crate::ask::Answer::Choice("Redis".to_owned()))
876 .unwrap();
877 assert!(hit(&[chat], &[talk], &answered).is_none());
878 }
879
880 #[test]
881 fn approval_origin_requires_an_open_chat_task_and_question() {
882 let (_tmp, _store, talk) = talks();
883 let chat = from_chat(&talk);
884 let mut q = question(crate::land::APPROVAL_NODE);
885 assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
886 assert!(
887 origin_talk(
888 &[task(Source::Agent {
889 run: talk.id.clone(),
890 node: "implement".into(),
891 })],
892 std::slice::from_ref(&talk),
893 &q
894 )
895 .is_none()
896 );
897 assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
898 let mut closed = talk.clone();
899 closed.status = TalkStatus::Closed;
900 assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
901 q.abandon("expired");
902 assert!(
903 origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
904 );
905 let mut q = question(crate::land::APPROVAL_NODE);
906 q.answer(crate::ask::Answer::Choice("Redis".into()))
907 .unwrap();
908 assert!(origin_talk(&[chat], &[talk], &q).is_none());
909 }
910
911 #[test]
912 fn a_conductor_question_is_found_through_the_task_id() {
913 let (_tmp, _store, talk) = talks();
914 let chat = from_chat(&talk);
915 let mut q = question(crate::conduct::NODE);
916 q.run = chat.id.clone();
917 assert!(origin_talk(&[chat], &[talk], &q).is_some());
918 }
919
920 fn followup_of(parent: &Task, n: u32) -> Task {
921 let mut t = Task::new(
922 "f".to_owned(),
923 "Fix".to_owned(),
924 PathBuf::from("/repo"),
925 Source::Agent {
926 run: format!("merged-{n}"),
927 node: "followup".to_owned(),
928 },
929 );
930 t.start(format!("run-f{n}"));
931 t.followup = Some(crate::queue::FollowUp {
932 run: format!("merged-{n}"),
933 origin_task: Some(parent.id.clone()),
934 pr: "https://example.invalid/pr/1".to_owned(),
935 findings: Vec::new(),
936 generation: n,
937 });
938 t
939 }
940
941 fn q_for(t: &Task) -> Question {
942 let mut q = question("implement");
943 q.run = t.runs[0].clone();
944 q
945 }
946
947 #[test]
948 fn a_followup_traces_back_to_the_chat() {
949 let (_tmp, _store, talk) = talks();
950 let mut chat = from_chat(&talk);
951 chat.runs = vec!["chat-run".to_owned()];
952 let f1 = followup_of(&chat, 1);
953 let f2 = followup_of(&f1, 2);
954 let ts = [chat, f1.clone(), f2.clone()];
955 let id = Some(talk.id.clone());
956 let tk = std::slice::from_ref(&talk);
957 assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
958 assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
959 }
960
961 #[test]
962 fn a_followup_without_origin_task_is_found_through_runs() {
963 let (_tmp, _store, talk) = talks();
964 let mut chat = from_chat(&talk);
965 chat.runs = vec!["merged-1".to_owned()];
966 let mut f1 = followup_of(&chat, 1);
967 f1.followup.as_mut().unwrap().origin_task = None;
968 let q = q_for(&f1);
969 assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
970 }
971
972 #[test]
973 fn a_dangling_origin_task_has_no_chat() {
974 let (_tmp, _store, talk) = talks();
975 let mut chat = from_chat(&talk);
976 chat.runs = vec!["chat-run".to_owned()];
977 let mut f1 = followup_of(&chat, 1);
978 f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
979 let q = q_for(&f1);
980 assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
981 }
982
983 #[test]
984 fn a_recorded_chat_survives_deleted_ancestors() {
985 let (_tmp, _store, talk) = talks();
986 let chat = from_chat(&talk);
987 assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
988 let mut f1 = followup_of(&chat, 1);
989 f1.origin_chat = chat.origin_chat.clone();
990 let mut f2 = followup_of(&f1, 2);
991 f2.origin_chat = f1.origin_chat.clone();
992 let q = q_for(&f2);
994 let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
995 assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
996 assert!(origin_talk(&[f2], &[], &q).is_none());
998 }
999
1000 #[test]
1001 fn a_task_without_the_field_still_walks_the_ancestry() {
1002 let (_tmp, _store, talk) = talks();
1003 let mut chat = from_chat(&talk);
1004 chat.origin_chat = None;
1005 let mut f1 = followup_of(&chat, 1);
1006 f1.origin_chat = None;
1007 let q = q_for(&f1);
1008 assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
1009 let mut v = serde_json::to_value(&f1).unwrap();
1011 v.as_object_mut().unwrap().remove("origin_chat");
1012 let back: Task = serde_json::from_value(v).unwrap();
1013 assert!(back.origin_chat.is_none());
1014 }
1015
1016 #[test]
1017 fn a_followup_cycle_ends_without_a_chat() {
1018 let (_tmp, _store, talk) = talks();
1019 let mut a = followup_of(&from_chat(&talk), 1);
1020 let mut b = followup_of(&a, 2);
1021 a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
1022 b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
1023 let q = q_for(&a);
1024 assert!(origin_talk(&[a, b], &[talk], &q).is_none());
1025 }
1026
1027 #[test]
1028 fn pending_consults_follow_the_store_not_the_turn_text() {
1029 let (tmp, store, talk) = talks();
1030 let questions = Questions::at(tmp.path().join("questions"));
1031 assert!(!pending_consults(&questions, &talk.id), "empty store");
1032
1033 let mut plain = question("implement");
1034 questions.put(&mut plain).unwrap();
1035 assert!(!pending_consults(&questions, &talk.id), "no consult record");
1036
1037 let mut q = question("implement");
1038 questions.put(&mut q).unwrap();
1039 begin(&questions, &store, &q, &talk).unwrap();
1040 assert!(pending_consults(&questions, &talk.id));
1041 assert!(!pending_consults(&questions, "other-talk"));
1042
1043 questions
1044 .update(&q.id, |q| {
1045 q.abandon("test");
1046 Ok(())
1047 })
1048 .unwrap();
1049 assert!(!pending_consults(&questions, &talk.id), "closed question");
1050 }
1051
1052 #[test]
1053 fn begin_reopens_a_closed_chat_before_queueing() {
1054 let (tmp, store, mut talk) = talks();
1055 let questions = Questions::at(tmp.path().join("questions"));
1056 let mut q = question("implement");
1057 questions.put(&mut q).unwrap();
1058 talk::close(&mut talk, &store).unwrap();
1059 assert!(!store.get(&talk.id).unwrap().status.open());
1060
1061 assert!(begin(&questions, &store, &q, &talk).unwrap());
1062
1063 let after = store.get(&talk.id).unwrap();
1064 assert!(after.status.open(), "the chat was reopened");
1065 assert!(after.pending.contains(&q.id), "{}", after.pending);
1066 assert!(questions.get(&q.id).unwrap().consult.is_some());
1067 }
1068
1069 #[test]
1070 fn begin_queues_once_and_leaves_the_question_open() {
1071 let (tmp, store, talk) = talks();
1072 let questions = Questions::at(tmp.path().join("questions"));
1073 let mut q = question("implement");
1074 questions.put(&mut q).unwrap();
1075
1076 assert!(begin(&questions, &store, &q, &talk).unwrap());
1077 assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
1078
1079 let after = questions.get(&q.id).unwrap();
1080 assert!(after.status.open());
1081 assert!(after.thread.is_empty());
1082 assert_eq!(after.choices, q.choices, "choices are not touched");
1083 assert_eq!(
1084 after.consult.as_ref().map(|c| c.talk.as_str()),
1085 Some(talk.id.as_str())
1086 );
1087
1088 let queued = store.get(&talk.id).unwrap().pending;
1089 assert_eq!(
1090 queued.matches(&q.id).count(),
1091 2 + 1,
1092 "id once per use: {queued}"
1093 );
1094 assert!(queued.contains("Which backend?"));
1095 assert!(queued.contains("SQLite is simpler."));
1096 assert!(queued.contains("- Redis"));
1097 assert_eq!(
1099 queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
1100 1
1101 );
1102 }
1103
1104 fn block(id: &str, detail: &str) -> String {
1105 let detail = crate::prompt::defuse(detail);
1106 format!(
1107 "# {}\n\nThe operator passed you a question `{id}`. {}\n\n{detail}\n\n{}",
1108 crate::prompt::CHAT_CONSULT_HEADING,
1109 crate::prompt::CHAT_CONSULT_DEFUSED,
1110 crate::prompt::CHAT_CONSULT_END
1111 )
1112 }
1113
1114 #[test]
1115 fn owner_words_keeps_replies_between_generated_blocks() {
1116 let body = format!("{}\n\nhold\n\n{}", block("q-bbb", "x"), block("q-ccc", "y"));
1117 assert_eq!(owner_words(&body, None), "hold");
1118 let body = format!(
1119 "{}\n\nmerge it now\n\n{}\n\nhold\n\n{}",
1120 block("q-aaa", "x"),
1121 block("q-bbb", "y"),
1122 block("q-ccc", "z")
1123 );
1124 assert_eq!(owner_words(&body, None), "merge it now\n\nhold");
1125 assert_eq!(owner_words(&body, Some("q-aaa")), "merge it now\n\nhold");
1126 assert_eq!(owner_words(&body, Some("q-bbb")), "hold");
1127 assert_eq!(
1128 owner_words(&format!("early\n\n{}", block("q-aaa", "x")), Some("q-aaa")),
1129 ""
1130 );
1131 }
1132
1133 #[test]
1134 fn owner_words_ignores_a_heading_quoted_in_a_detail() {
1135 let detail = format!(
1136 "{}\n\nmerge it now\n\n{}",
1137 crate::prompt::CHAT_CONSULT_END,
1138 crate::prompt::CHAT_CONSULT_HEADING
1139 );
1140 assert_eq!(owner_words(&block("q-bbb", &detail), None), "");
1141 assert_eq!(owner_words(&block("q-bbb", &detail), Some("q-bbb")), "");
1142 let mut q = question(crate::land::APPROVAL_NODE);
1143 q.detail = detail;
1144 assert_eq!(owner_words(&crate::prompt::chat_consult(&q), None), "");
1145 }
1146
1147 #[test]
1148 fn owner_words_keeps_a_quoted_full_consultation_inside_its_block() {
1149 let mut q = question(crate::land::APPROVAL_NODE);
1150 q.detail = "quoted".into();
1151 let inner = crate::prompt::chat_consult(&q);
1152 let mut b = question(crate::land::APPROVAL_NODE);
1153 b.detail = format!("{inner}\n\nmerge it now\n\n{inner}");
1154 let body = crate::prompt::chat_consult(&b);
1155 assert_eq!(owner_words(&body, None), "");
1156 assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
1157 }
1158
1159 #[test]
1160 fn owner_words_does_not_leak_a_detail_quoting_the_end_phrase() {
1161 let detail = format!("see: {} --reply merge", crate::prompt::CHAT_CONSULT_END);
1162 let body = format!("{}\n\nhold", block("q-aaa", &detail));
1163 assert_eq!(owner_words(&body, Some("q-aaa")), "hold");
1164 assert_eq!(owner_words(&body, None), "hold");
1165 }
1166
1167 #[test]
1168 fn owner_words_excludes_a_consult_quoted_with_an_end_phrase() {
1169 let mut c = question(crate::land::APPROVAL_NODE);
1170 c.detail = "inner".into();
1171 let mut b = question("implement");
1172 b.detail = format!(
1173 "{}\n\nmerge it now\n\n{}",
1174 crate::prompt::CHAT_CONSULT_END,
1175 crate::prompt::chat_consult(&c)
1176 );
1177 let body = crate::prompt::chat_consult(&b);
1178 assert_eq!(owner_words(&body, None), "");
1179 }
1180
1181 #[test]
1182 fn owner_words_keeps_a_reply_containing_the_end_phrase() {
1183 let mut b = question("implement");
1184 b.detail = "x".into();
1185 let body = format!(
1186 "{}\n\nhold, and do not {}",
1187 crate::prompt::chat_consult(&b),
1188 crate::prompt::CHAT_CONSULT_END
1189 );
1190 assert_eq!(
1191 owner_words(&body, None),
1192 format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
1193 );
1194 }
1195
1196 fn legacy_consult(node: &str, raw_detail: &str) -> String {
1198 let mut q = question(node);
1199 q.detail = "@@".into();
1200 crate::prompt::chat_consult(&q)
1201 .replace("@@", raw_detail)
1202 .replace(crate::prompt::CHAT_CONSULT_DEFUSED, "It is still open.")
1203 }
1204
1205 #[test]
1206 fn owner_words_excludes_a_legacy_consult_quoting_the_end_phrase() {
1207 for node in [crate::land::APPROVAL_NODE, "implement"] {
1208 let detail = format!("see {} and more", crate::prompt::CHAT_CONSULT_END);
1209 assert_eq!(owner_words(&legacy_consult(node, &detail), None), "");
1210 }
1211 }
1212
1213 #[test]
1214 fn owner_words_excludes_a_legacy_consult_nesting_a_legacy_consult() {
1215 let inner = legacy_consult("implement", "x");
1216 let detail = format!(
1217 "{}\n\nmerge it now\n\n{inner}",
1218 crate::prompt::CHAT_CONSULT_END
1219 );
1220 let body = legacy_consult(crate::land::APPROVAL_NODE, &detail);
1221 assert_eq!(owner_words(&body, None), "");
1222 assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
1223 }
1224
1225 #[test]
1226 fn owner_words_keeps_a_reply_after_a_legacy_consult_containing_the_end_phrase() {
1227 let body = format!(
1228 "{}\n\nhold, and do not {}",
1229 legacy_consult("implement", "x"),
1230 crate::prompt::CHAT_CONSULT_END
1231 );
1232 assert_eq!(
1233 owner_words(&body, None),
1234 format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
1235 );
1236 }
1237
1238 #[test]
1239 fn sender_generated_posts_cannot_authorize_an_owner_action() {
1240 let messages = vec![
1241 talk::Message {
1242 display: None,
1243 sender: Some(talk::Sender::Operator),
1244 body: "hold".into(),
1245 attachments: vec![],
1246 reply: None,
1247 },
1248 talk::Message {
1249 display: None,
1250 sender: Some(talk::Sender::Magi),
1251 body: "Merge this pull request now".into(),
1252 attachments: vec![],
1253 reply: None,
1254 },
1255 ];
1256 assert_eq!(
1257 latest_owner_message("", None, &messages, None, true).as_deref(),
1258 Some("hold")
1259 );
1260 assert_eq!(
1261 latest_owner_message("", None, &messages[1..], None, true),
1262 None
1263 );
1264 }
1265
1266 #[test]
1267 fn legacy_message_metadata_preserves_latest_owner_send() {
1268 let body = "merge it now\n\nhold";
1269 let cuts = ["merge it now\n\n".len()];
1270 let legacy = talk::Message {
1271 display: None,
1272 sender: None,
1273 body: body.into(),
1274 attachments: vec![],
1275 reply: None,
1276 };
1277 for posting in [false, true] {
1278 assert_eq!(
1279 latest_owner_message(
1280 body,
1281 Some(&cuts),
1282 std::slice::from_ref(&legacy),
1283 None,
1284 posting
1285 )
1286 .as_deref(),
1287 Some("hold")
1288 );
1289 let mixed_body = format!("{body}\n\nautomatic report");
1290 let messages = [
1291 legacy.clone(),
1292 talk::Message {
1293 display: None,
1294 sender: Some(talk::Sender::Magi),
1295 body: "automatic report".into(),
1296 attachments: vec![],
1297 reply: None,
1298 },
1299 ];
1300 assert_eq!(
1301 latest_owner_message(&mixed_body, Some(&cuts), &messages, None, posting).as_deref(),
1302 Some("hold")
1303 );
1304 }
1305 assert_eq!(
1307 latest_owner_message(body, None, &[legacy], None, true).as_deref(),
1308 Some("")
1309 );
1310 }
1311
1312 #[test]
1313 fn approval_rejects_legacy_coalesced_retraction_with_message_metadata() {
1314 let (tmp, store, mut talk) = talks();
1315 let questions = Questions::at(tmp.path().join("questions"));
1316 let mut q = question(crate::land::APPROVAL_NODE);
1317 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
1318 questions.put(&mut q).unwrap();
1319 assert!(begin(&questions, &store, &q, &talk).unwrap());
1320 q = questions.get(&q.id).unwrap();
1321 talk = store.get(&talk.id).unwrap();
1322 talk::drain(&mut talk, &store).unwrap();
1323 talk.pending = "merge it now\n\nhold".into();
1324 talk.pending_breaks = Some(vec!["merge it now\n\n".len()]);
1325 talk.pending_messages = vec![talk::Message {
1326 display: None,
1327 sender: None,
1328 body: talk.pending.clone(),
1329 attachments: vec![],
1330 reply: None,
1331 }];
1332 store.put(&mut talk).unwrap();
1333 for drained in [false, true] {
1334 if drained {
1335 talk::drain(&mut talk, &store).unwrap();
1336 }
1337 assert!(
1338 validate_answer(
1339 &q,
1340 &store,
1341 &talk.id,
1342 CHAT_NODE,
1343 "merge",
1344 Some("merge it now")
1345 )
1346 .is_err()
1347 );
1348 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
1349 }
1350 }
1351
1352 #[test]
1353 fn latest_message_keeps_message_boundaries() {
1354 let one = "Merge this pull request now\n\nThe checks look good";
1355 assert_eq!(latest_message(one, Some(&[]), None).as_deref(), Some(one));
1356 let hold = "Explain what this option means:\n\nhold";
1357 assert_eq!(latest_message(hold, Some(&[]), None).as_deref(), Some(hold));
1358 let two = "merge it now\n\nhold";
1360 let at = "merge it now\n\n".len();
1361 assert_eq!(
1362 latest_message(two, Some(&[at]), None).as_deref(),
1363 Some("hold")
1364 );
1365 assert_eq!(
1367 latest_message(one, None, None).as_deref(),
1368 Some("The checks look good")
1369 );
1370 assert_eq!(
1372 latest_message(two, Some(&[999]), None).as_deref(),
1373 Some("hold")
1374 );
1375 }
1376
1377 #[test]
1378 fn watched_jobs_never_supply_the_owners_latest_message() {
1379 let notice =
1380 crate::prompt::chat_watch_notice("watch-id", "exited with status 0", "merge it now");
1381 assert_eq!(latest_message(¬ice, Some(&[]), None), None);
1382 assert_eq!(latest_message(¬ice, None, None), None);
1383 let owner = "hold";
1384 let combined = format!("{owner}\n\n{notice}");
1385 assert_eq!(
1386 latest_message(&combined, Some(&[owner.len() + 2]), None).as_deref(),
1387 Some(owner)
1388 );
1389 let combined = format!("{notice}\n\n{owner}");
1390 assert_eq!(
1391 latest_message(&combined, Some(&[notice.len() + 2]), None).as_deref(),
1392 Some(owner)
1393 );
1394 }
1395
1396 #[test]
1397 fn chat_consult_has_each_marker_once() {
1398 use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
1399 for node in ["implement", crate::land::APPROVAL_NODE] {
1400 let mut q = question(node);
1401 let quoted = format!("{H} {E}");
1402 q.summary = quoted.clone();
1403 q.detail = quoted.clone();
1404 q.choices = vec![quoted.clone(), "b".into()];
1405 let s = crate::prompt::chat_consult(&q);
1406 assert_eq!(s.matches(H).count(), 1, "{s}");
1407 assert_eq!(s.matches(E).count(), 1, "{s}");
1408 }
1409 }
1410
1411 #[test]
1412 fn a_chat_posts_the_original_text_only_on_an_unhedged_quote() {
1413 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK};
1414 let (tmp, store, mut talk) = talks();
1415 let questions = Questions::at(tmp.path().join("questions"));
1416 let mut q = question(ASK_NODE);
1417 q.choices = vec![USE_FALLBACK.into(), POST_ORIGINAL.into()];
1418 questions.put(&mut q).unwrap();
1419 assert!(begin(&questions, &store, &q, &talk).unwrap());
1420 q = questions.get(&q.id).unwrap();
1421 let queued = store.get(&talk.id).unwrap().pending;
1422 let owner_turn = |body: &str| talk::Turn {
1423 sender: None,
1424 breaks: Some(Vec::new()),
1425 messages: Vec::new(),
1426 who: talk::Who::Operator,
1427 body: body.into(),
1428 at: Timestamp::now(),
1429 attachments: Vec::new(),
1430 usage: None,
1431 posted_by: None,
1432 };
1433 talk.turns.push(owner_turn(&queued));
1434 talk.pending.clear();
1435 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
1436 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
1437 };
1438 store.put(&mut talk).unwrap();
1439 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("post it")).is_err());
1441 talk.turns.push(owner_turn("Post the original, maybe?"));
1442 store.put(&mut talk).unwrap();
1443 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original")).is_err());
1444 talk.turns
1445 .push(owner_turn("Post the original text as it is."));
1446 store.put(&mut talk).unwrap();
1447 assert!(check(&q, &talk.id, POST_ORIGINAL, None).is_err());
1448 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("never said")).is_err());
1449 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original text")).is_ok());
1450 assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
1452 }
1453
1454 #[test]
1455 fn a_chat_posts_the_rewrite_only_on_an_unhedged_quote() {
1456 use crate::github_text::{ASK_NODE, USE_FALLBACK, USE_REWRITE};
1457 let (tmp, store, mut talk) = talks();
1458 let questions = Questions::at(tmp.path().join("questions"));
1459 let mut q = question(ASK_NODE);
1460 q.choices = vec![USE_FALLBACK.into(), USE_REWRITE.into()];
1461 questions.put(&mut q).unwrap();
1462 assert!(begin(&questions, &store, &q, &talk).unwrap());
1463 q = questions.get(&q.id).unwrap();
1464 let queued = store.get(&talk.id).unwrap().pending;
1465 let owner_turn = |body: &str| talk::Turn {
1466 sender: None,
1467 breaks: Some(Vec::new()),
1468 messages: Vec::new(),
1469 who: talk::Who::Operator,
1470 body: body.into(),
1471 at: Timestamp::now(),
1472 attachments: Vec::new(),
1473 usage: None,
1474 posted_by: None,
1475 };
1476 talk.turns.push(owner_turn(&queued));
1477 talk.pending.clear();
1478 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
1479 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
1480 };
1481 store.put(&mut talk).unwrap();
1482 assert!(check(&q, &talk.id, USE_REWRITE, Some("post it")).is_err());
1484 talk.turns.push(owner_turn("Use the rewrite, maybe?"));
1485 store.put(&mut talk).unwrap();
1486 assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite")).is_err());
1487 talk.turns
1488 .push(owner_turn("Use the rewrite text as it is."));
1489 store.put(&mut talk).unwrap();
1490 assert!(check(&q, &talk.id, USE_REWRITE, None).is_err());
1491 assert!(check(&q, &talk.id, USE_REWRITE, Some("never said")).is_err());
1492 assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite text")).is_ok());
1493 assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
1495 }
1496
1497 #[test]
1498 fn merge_repair_consult_requires_offered_choice_and_exact_latest_owner_request() {
1499 use crate::land::{APPROVAL_NODE, APPROVE, HOLD, REPAIR};
1500 let (tmp, store, mut talk) = talks();
1501 let questions = Questions::at(tmp.path().join("questions"));
1502 let mut q = question(APPROVAL_NODE);
1503 q.choices = vec![APPROVE.into(), HOLD.into(), REPAIR.into()];
1504 questions.put(&mut q).unwrap();
1505 begin(&questions, &store, &q, &talk).unwrap();
1506 q = questions.get(&q.id).unwrap();
1507 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
1508 talk = store.get(&talk.id).unwrap();
1509 talk.turns.push(talk::Turn {
1510 sender: None,
1511 breaks: Some(Vec::new()),
1512 messages: Vec::new(),
1513 posted_by: None,
1514 who: talk::Who::Operator,
1515 body: talk.pending.clone(),
1516 at: Timestamp::now(),
1517 attachments: Vec::new(),
1518 usage: None,
1519 });
1520 talk.pending_messages.clear(); talk.pending = "maybe fix findings and re-review".into();
1522 store.put(&mut talk).unwrap();
1523 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
1524 talk.pending = REPAIR.into();
1525 talk.pending_breaks = Some(Vec::new());
1526 store.put(&mut talk).unwrap();
1527 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_ok());
1528 q.choices.retain(|c| c != REPAIR);
1529 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
1530 }
1531
1532 #[test]
1533 fn posting_consult_saves_only_authorized_offered_choices() {
1534 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
1535 for choice in [POST_ORIGINAL, USE_REWRITE] {
1536 let (tmp, store, mut talk) = talks();
1537 let questions = Questions::at(tmp.path().join("questions"));
1538 let mut q = question(ASK_NODE);
1539 q.choices = vec![USE_FALLBACK.into(), choice.into()];
1540 questions.put(&mut q).unwrap();
1541 begin(&questions, &store, &q, &talk).unwrap();
1542 q = questions.get(&q.id).unwrap();
1543 let queued = store.get(&talk.id).unwrap().pending;
1544 assert!(queued.contains("public posting confirmation"));
1545 assert!(queued.contains("not a merge approval"));
1546 assert!(
1547 queued.contains("`use fallback` choice can be saved once the owner has chosen it")
1548 );
1549 assert!(queued.contains("--quote <verbatim owner words>"));
1550 assert!(!queued.contains("answer it yourself with"));
1551 talk.turns.push(talk::Turn {
1552 sender: None,
1553 breaks: Some(Vec::new()),
1554 messages: Vec::new(),
1555 who: talk::Who::Operator,
1556 body: queued,
1557 at: Timestamp::now(),
1558 attachments: Vec::new(),
1559 usage: None,
1560 posted_by: None,
1561 });
1562 talk.pending.clear();
1563 store.put(&mut talk).unwrap();
1564 let save = |reply: &str, quote: Option<&str>, run: &str| {
1565 questions.update(&q.id, |r| {
1566 validate_answer(r, &store, run, CHAT_NODE, reply, quote)?;
1567 r.answer(crate::ask::Answer::Choice(reply.into()))
1568 })
1569 };
1570 assert!(save(choice, Some(choice), &talk.id).is_err());
1572 let clear = if choice == POST_ORIGINAL {
1573 "元の説明のまま投稿して"
1574 } else {
1575 "Use the rewrite text as it is."
1576 };
1577 talk.pending = clear.into();
1578 store.put(&mut talk).unwrap();
1579 for quote in [None, Some("forged quote")] {
1580 let error = save(choice, quote, &talk.id).unwrap_err().to_string();
1581 assert!(error.contains("posting confirmation"), "{error}");
1582 assert!(
1583 !error.contains("merge") && !error.contains("hold"),
1584 "{error}"
1585 );
1586 }
1587 assert!(save(choice, Some(clear), "another-chat").is_err());
1588 let other = if choice == POST_ORIGINAL {
1589 USE_REWRITE
1590 } else {
1591 POST_ORIGINAL
1592 };
1593 assert!(save(other, Some(clear), &talk.id).is_err());
1594 let mut unconsulted = q.clone();
1595 unconsulted.consult = None;
1596 assert!(
1597 validate_answer(
1598 &unconsulted,
1599 &store,
1600 &talk.id,
1601 CHAT_NODE,
1602 choice,
1603 Some(clear)
1604 )
1605 .is_err()
1606 );
1607 let mut expired = q.clone();
1608 expired.abandon("expired");
1609 assert!(
1610 validate_answer(&expired, &store, &talk.id, CHAT_NODE, choice, Some(clear))
1611 .is_err()
1612 );
1613 talk.status = TalkStatus::Closed;
1614 store.put(&mut talk).unwrap();
1615 assert!(save(choice, Some(clear), &talk.id).is_err());
1616 talk.status = TalkStatus::Open;
1617 for rejected in [
1618 "Post original was the option I saw.",
1619 "Use rewrite was the option I saw.",
1620 "Post the original, maybe?",
1621 "Use the rewrite only if checks pass.",
1622 "Post original. Cancel that.",
1623 "Do not post original.",
1624 "Use the rewrite when checks pass.",
1625 "元の説明で投稿してください。ただし検査が通ったら。",
1626 "元の説明で投稿しないで。",
1627 "まだ元の説明で投稿してください。",
1628 "ログには「元の説明で投稿しました」と記録されています。",
1629 "ログには「修正版で投稿しました」と記録されています。",
1630 ] {
1631 talk.pending = rejected.into();
1632 store.put(&mut talk).unwrap();
1633 let error = save(choice, Some(rejected), &talk.id)
1634 .unwrap_err()
1635 .to_string();
1636 assert!(
1637 !error.contains("merge") && !error.contains("hold"),
1638 "{error}"
1639 );
1640 assert!(save(choice, Some(clear), &talk.id).is_err(), "stale quote");
1641 let after = questions.get(&q.id).unwrap();
1642 assert!(after.status.open());
1643 assert!(after.answer.is_none());
1644 }
1645 let command = if choice == POST_ORIGINAL {
1646 "Post original"
1647 } else {
1648 "Use rewrite"
1649 };
1650 let mut earlier = talk.turns[0].clone();
1652 earlier.body = clear.into();
1653 talk.turns.push(earlier);
1654 for reported in [
1655 format!("The log contains:\n```\n{command}\n```"),
1656 format!("The log contains:\n\"\n{command}\n\""),
1657 format!("The log says: “Reported. {command}.”"),
1658 format!("The log says: “\n{command}\n”"),
1659 ]
1660 .into_iter()
1661 .chain(crate::github_text::reported_posting_messages(command))
1662 {
1663 talk.pending.clear();
1664 talk.pending_messages.clear();
1665 store.put(&mut talk).unwrap();
1666 talk::queue(&mut talk, &store, &reported, Vec::new()).unwrap();
1667 assert_eq!(talk.pending, reported);
1668 for breaks in [Some(Vec::new()), None] {
1670 talk.pending_breaks = breaks;
1671 store.put(&mut talk).unwrap();
1672 for quote in [command, reported.as_str()] {
1673 assert!(save(choice, Some(quote), &talk.id).is_err());
1674 let after = questions.get(&q.id).unwrap();
1675 assert!(after.status.open());
1676 assert!(after.answer.is_none());
1677 }
1678 }
1679 talk::drain(&mut talk, &store).unwrap();
1681 for quote in [command, reported.as_str()] {
1682 assert!(save(choice, Some(quote), &talk.id).is_err());
1683 let after = questions.get(&q.id).unwrap();
1684 assert!(after.status.open());
1685 assert!(after.answer.is_none());
1686 }
1687 }
1688 let natural = if choice == POST_ORIGINAL {
1689 [
1690 "OK, post the original",
1691 "Yes, post original",
1692 "I'd like to post the original",
1693 "元の説明で投稿してください。",
1694 ]
1695 } else {
1696 [
1697 "OK, use the rewrite",
1698 "Yes, use rewrite",
1699 "I'd like to use the rewrite",
1700 "修正版で投稿してください。",
1701 ]
1702 };
1703 for words in natural {
1704 talk.pending = words.into();
1705 store.put(&mut talk).unwrap();
1706 validate_answer(&q, &store, &talk.id, CHAT_NODE, choice, Some(words)).unwrap();
1707 }
1708 talk.pending = clear.into();
1709 store.put(&mut talk).unwrap();
1710 save(choice, Some(clear), &talk.id).unwrap();
1711 assert_eq!(
1712 questions.get(&q.id).unwrap().resolution().as_deref(),
1713 Some(choice)
1714 );
1715 }
1716 }
1717
1718 #[test]
1719 fn approval_consult_waits_for_latest_owner_confirmation() {
1720 let (tmp, store, mut talk) = talks();
1721 let questions = Questions::at(tmp.path().join("questions"));
1722 let mut q = question(crate::land::APPROVAL_NODE);
1723 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
1724 questions.put(&mut q).unwrap();
1725 assert!(begin(&questions, &store, &q, &talk).unwrap());
1726 assert!(!begin(&questions, &store, &q, &talk).unwrap());
1727 q = questions.get(&q.id).unwrap();
1728 assert!(q.status.open());
1729 assert!(q.answer.is_none());
1730 assert!(q.thread.is_empty());
1731 let queued = store.get(&talk.id).unwrap().pending;
1732 assert!(queued.contains("Never answer it yourself"));
1733 assert!(queued.contains("Silence holds"));
1734 assert!(queued.contains("--reply merge --quote"));
1735 assert!(!queued.contains("answer it yourself with"));
1736
1737 let owner_turn = |body: &str| talk::Turn {
1738 sender: None,
1739 breaks: Some(Vec::new()),
1740 messages: Vec::new(),
1741 posted_by: None,
1742 who: talk::Who::Operator,
1743 body: body.into(),
1744 at: Timestamp::now(),
1745 attachments: Vec::new(),
1746 usage: None,
1747 };
1748 let mut old = talk.clone();
1750 old.turns
1751 .insert(0, owner_turn("Please merge PR 12 after review"));
1752 store.put(&mut old).unwrap();
1753 talk.turns = old.turns.clone();
1754 talk.turns.push(owner_turn(&queued));
1757 store.put(&mut talk).unwrap();
1758 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "merge", Some("merge")).is_err());
1759 assert!(
1760 validate_answer(
1761 &q,
1762 &store,
1763 &talk.id,
1764 CHAT_NODE,
1765 "merge",
1766 Some("Please merge PR 12")
1767 )
1768 .is_err()
1769 );
1770 let n = talk.turns.len();
1772 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
1773 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
1774 store.put(&mut talk).unwrap();
1775 assert!(
1776 validate_answer(
1777 &q,
1778 &store,
1779 &talk.id,
1780 CHAT_NODE,
1781 "merge",
1782 Some("merge it now")
1783 )
1784 .is_ok()
1785 );
1786 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now\n\nhold");
1788 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2, queued.len() + 2 + 14]);
1789 store.put(&mut talk).unwrap();
1790 assert!(
1791 validate_answer(
1792 &q,
1793 &store,
1794 &talk.id,
1795 CHAT_NODE,
1796 "merge",
1797 Some("merge it now")
1798 )
1799 .is_err()
1800 );
1801 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
1802 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
1804 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
1805 talk.pending = "hold".into();
1806 store.put(&mut talk).unwrap();
1807 assert!(
1808 validate_answer(
1809 &q,
1810 &store,
1811 &talk.id,
1812 CHAT_NODE,
1813 "merge",
1814 Some("merge it now")
1815 )
1816 .is_err()
1817 );
1818 talk.pending = format!("hold\n\n{queued}");
1820 store.put(&mut talk).unwrap();
1821 assert!(
1822 validate_answer(
1823 &q,
1824 &store,
1825 &talk.id,
1826 CHAT_NODE,
1827 "merge",
1828 Some("merge it now")
1829 )
1830 .is_err()
1831 );
1832 talk.pending.clear();
1833 talk.turns[n - 1].body = queued.clone();
1834 store.put(&mut talk).unwrap();
1835 talk.turns
1836 .push(owner_turn("Merge this pull request please"));
1837 store.put(&mut talk).unwrap();
1838 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
1839 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
1840 };
1841 assert!(check(&q, "other-talk", "merge", Some("Merge this")).is_err());
1842 assert!(check(&q, &talk.id, "merge", None).is_err());
1843 assert!(check(&q, &talk.id, "merge", Some("never said")).is_err());
1844 assert!(
1845 check(
1846 &q,
1847 &talk.id,
1848 "merge",
1849 Some("Merge this pull request please")
1850 )
1851 .is_ok()
1852 );
1853 assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
1854 talk.turns
1855 .push(owner_turn("Wait, explain the checks first"));
1856 store.put(&mut talk).unwrap();
1857 assert!(
1858 check(
1859 &q,
1860 &talk.id,
1861 "merge",
1862 Some("Merge this pull request please")
1863 )
1864 .is_err()
1865 );
1866 talk.turns.push(owner_turn("Please hold"));
1867 store.put(&mut talk).unwrap();
1868 assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
1869 talk.turns.push(owner_turn("hold"));
1870 store.put(&mut talk).unwrap();
1871 assert!(check(&q, &talk.id, "hold", Some("hold")).is_ok());
1872 talk.turns
1873 .push(owner_turn("Merge this pull request please"));
1874 store.put(&mut talk).unwrap();
1875 q.abandon("approval expired or head changed");
1876 assert!(
1877 check(
1878 &q,
1879 &talk.id,
1880 "merge",
1881 Some("Merge this pull request please")
1882 )
1883 .is_err()
1884 );
1885 assert!(
1886 q.answer(crate::ask::Answer::Choice("merge".into()))
1887 .is_err()
1888 );
1889 }
1890
1891 #[test]
1892 fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
1893 let (tmp, store, talk) = talks();
1894 let questions = Questions::at(tmp.path().join("questions"));
1895 let mut q = question("implement");
1896 questions.put(&mut q).unwrap();
1897 std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
1898 assert!(begin(&questions, &store, &q, &talk).is_err());
1899 assert!(questions.get(&q.id).unwrap().consult.is_none());
1900 }
1901
1902 #[test]
1903 fn an_older_question_file_reads_without_a_consult() {
1904 let mut q = question("implement");
1905 q.schema = 5;
1906 let mut v = serde_json::to_value(&q).unwrap();
1907 v.as_object_mut().unwrap().remove("consult");
1908 let back: Question = serde_json::from_value(v).unwrap();
1909 assert!(back.consult.is_none());
1910 }
1911}