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