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 deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
118 let dispatch = loop {
119 let mut busy = false;
120 let updated = questions.update(&q.id, |r| {
121 if !r.status.open() || r.consult != q.consult {
122 return Ok(false);
123 }
124 if talk::try_queue_consult(
125 talks,
126 &talk.id,
127 &crate::prompt::chat_consult(r),
128 Some(crate::prompt::chat_consult_display(r)),
129 )?
130 .is_none()
131 {
132 busy = true;
133 bail!("the conversation store is locked by another process");
134 }
135 Ok(true)
136 });
137 if busy && std::time::Instant::now() < deadline {
138 std::thread::sleep(std::time::Duration::from_millis(10));
139 continue;
140 }
141 break updated;
142 };
143 if let Err(e) = dispatch {
144 let _ = questions.update(&q.id, |r| {
145 if r.status.open() && r.consult == q.consult {
146 r.consult = None;
147 }
148 Ok(())
149 });
150 return Err(e).context("queue the question into the chat");
151 }
152 Ok(dispatch?.1)
153}
154
155pub fn refuse_operator_held(q: &Question, node: &str, tasks: &[crate::queue::Task]) -> Result<()> {
162 if node != CHAT_NODE || QuestionKind::of(q) != QuestionKind::Merge {
163 return Ok(());
164 }
165 let release_run = if q.run.is_empty() && q.node == crate::bump::NOTICE_NODE {
166 crate::run::try_home()
167 .and_then(|home| crate::release_watch::state_for_question(&home, &q.id))
168 .and_then(|state| state.run)
169 } else {
170 None
171 };
172 let run = release_run.as_deref().unwrap_or(&q.run);
173 if let Some(task) = tasks
174 .iter()
175 .find(|t| t.id == run || t.runs.iter().any(|id| id == run))
176 && task.operator_held()
177 {
178 bail!(
179 "task {} is held by the operator; an answer cannot settle it",
180 task.short()
181 );
182 }
183 Ok(())
184}
185
186#[derive(Clone, Copy, PartialEq, Eq)]
188pub enum QuestionKind {
189 Ordinary,
191 Merge,
193 Posting,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
199pub struct AnswerAuthority {
200 pub talk: String,
202 pub turn: usize,
204 pub message: usize,
206 pub instruction: String,
208 pub instruction_fingerprint: String,
210 pub recommendation_turn: Option<usize>,
212 pub recommendation_fingerprint: Option<String>,
214 pub question: String,
216 pub choice: String,
218 #[serde(default)]
220 pub question_fingerprint: String,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
225pub struct RecommendationManifest {
226 pub body: String,
228 pub questions: std::collections::BTreeMap<String, String>,
230}
231
232pub fn question_fingerprint(q: &Question) -> String {
234 let facts = serde_json::json!({
235 "id": q.id, "run": q.run, "node": q.node, "seat": q.seat,
236 "summary": q.summary, "detail": q.detail, "choices": q.choices,
237 "actions": q.actions, "merge_target": q.merge_target,
238 "posting_comparison": q.posting_comparison,
239 "decision_context": q.decision_context, "triage_generation": q.triage_generation,
240 });
241 crate::github_text::fingerprint("", &facts.to_string())
242}
243
244pub fn owner_title<'a>(q: &Question, instruction: &'a str) -> Option<&'a str> {
247 if instruction.starts_with(" ") || instruction.starts_with('\t') {
248 return None;
249 }
250 let (command, title) = instruction.trim().split_once('\n')?;
251 let expected = format!(
252 "{}: {} {}",
253 q.id,
254 crate::github_text::USE_MY_TEXT,
255 question_fingerprint(q)
256 );
257 (command == expected && !title.trim().is_empty() && !title.contains('\n'))
258 .then_some(title.trim())
259}
260
261pub fn recommendation_manifest(body: &str, questions: &Questions) -> RecommendationManifest {
263 RecommendationManifest {
264 body: crate::github_text::fingerprint("", body),
265 questions: questions
266 .list()
267 .iter()
268 .filter(|q| q.status.open() && recommended_choice(body, q, questions).is_some())
269 .map(|q| (q.id.clone(), question_fingerprint(q)))
270 .collect(),
271 }
272}
273
274pub fn authorize_answer(
279 q: &Question,
280 talks: &Talks,
281 questions: &Questions,
282 run: &str,
283 node: &str,
284 reply: &str,
285 quote: Option<&str>,
286) -> Result<Option<AnswerAuthority>> {
287 if node != CHAT_NODE {
288 return Ok(None);
289 }
290 if reply == crate::land::HOLD && q.actions.contains_key(reply) {
291 bail!("hold cannot execute a question action");
292 }
293 let talk = talks.get(run)?;
294 if reply != crate::land::HOLD
295 && current_instruction(&talk).is_some_and(|(_, _, words)| {
296 words.starts_with(" ")
297 || words.starts_with('\t')
298 || words.trim_start().starts_with("```")
299 || words.trim_start().starts_with('>')
300 })
301 {
302 bail!("quoted or code-block owner text is not an explicit instruction for this action");
303 }
304 if let Some(evidence) = choice_authority(q, questions, &talk, reply, quote) {
305 validate_effect(
306 q,
307 reply,
308 &evidence.instruction,
309 quote.unwrap_or_default(),
310 true,
311 )?;
312 if reply != crate::land::HOLD
315 && let Some(consult) = &q.consult
316 && consult.talk != run
317 && talks.turn_held(&consult.talk)
318 {
319 bail!("original consultation is active; wait for its turn to finish before answering");
320 }
321 return Ok(Some(evidence));
322 }
323 if current_instruction(&talk).is_some_and(|(_, _, words)| batch_instruction(words)) {
324 validate_effect(
325 q,
326 reply,
327 current_instruction(&talk).unwrap().2,
328 quote.unwrap_or_default(),
329 true,
330 )?;
331 bail!(
332 "batch authorization requires a current, unambiguous recommendation for this exact offered choice and unchanged question target/content"
333 );
334 }
335 if q.consult.as_ref().is_some_and(|c| c.talk == run) {
338 validate_answer(q, talks, run, node, reply, quote)?;
339 if QuestionKind::of(q) == QuestionKind::Ordinary && consent(q, reply) != Consent::Delegable
340 {
341 let (_, _, instruction) = current_instruction(&talk)
342 .context("this action requires a current explicit owner instruction")?;
343 validate_effect(q, reply, instruction, quote.unwrap_or_default(), false)?;
344 }
345 return Ok(None);
346 }
347 if let Some((_, _, instruction)) = current_instruction(&talk) {
348 validate_effect(q, reply, instruction, quote.unwrap_or_default(), true)?;
349 }
350 bail!(
351 "answer requires --quote of the entire latest owner send bound to this question ID and exact offered choice, or explicit authorization of the immediately preceding question-by-question recommendations; persisted evidence must be current"
352 )
353}
354
355pub fn authorized_answers(questions: &Questions, talk: &Talk) -> bool {
358 let quote = current_instruction(talk).map(|(_, _, body)| body);
359 questions.list().iter().any(|q| {
360 if q.free_text() {
361 return quote
362 .and_then(|s| s.split_once(':'))
363 .is_some_and(|(_, reply)| {
364 choice_authority(q, questions, talk, reply.trim(), quote).is_some()
365 });
366 }
367 q.choices.iter().any(|choice| {
368 choice_authority(q, questions, talk, choice, quote).is_some_and(|e| {
369 validate_effect(q, choice, &e.instruction, quote.unwrap_or_default(), true).is_ok()
370 })
371 })
372 })
373}
374
375pub fn answer_write_roots(
380 questions: &Questions,
381 talks: &Talks,
382 talk: &Talk,
383) -> Vec<std::path::PathBuf> {
384 if !pending_consults(questions, &talk.id) && !authorized_answers(questions, talk) {
385 return Vec::new();
386 }
387 let Some(home) = questions.root().parent() else {
388 return Vec::new();
389 };
390 let quote = current_instruction(talk).map(|(_, _, words)| words);
391 let mut roots = std::collections::BTreeSet::from([
392 questions.root().to_path_buf(),
393 talks.answer_lock_root(),
394 ]);
395 for q in questions.list() {
396 if q.status.open()
397 && q.node == crate::land::APPROVAL_NODE
398 && q.choices
399 .iter()
400 .any(|choice| choice == crate::merge_decision::DISMISS)
401 && crate::run::is_run_id(&q.run)
402 && authorize_answer(
403 &q,
404 talks,
405 questions,
406 &talk.id,
407 CHAT_NODE,
408 crate::merge_decision::DISMISS,
409 quote,
410 )
411 .is_ok()
412 {
413 roots.insert(home.join("queue"));
414 roots.insert(home.join("runs").join(&q.run));
415 }
416 }
417 roots.into_iter().collect()
418}
419
420pub fn authorized_holds(questions: &Questions, talk: &Talk) -> bool {
422 authorized_answers(questions, talk)
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum Consent {
429 Delegable,
431 Merge,
433 Consequence,
435 Posting,
437}
438
439pub fn destructive_effect(q: &Question, label: &str) -> bool {
442 if label == crate::land::HOLD && !q.actions.contains_key(label) {
443 return false;
444 }
445 if q.node == crate::conduct::NODE
449 && q.decision_context
450 .as_deref()
451 .and_then(|context| serde_json::from_str::<serde_json::Value>(context).ok())
452 .is_some_and(|context| {
453 matches!(
454 context
455 .get(3)
456 .and_then(|v| v.get(0))
457 .and_then(|v| v.as_str()),
458 Some("complete" | "configure")
459 )
460 })
461 {
462 return true;
463 }
464 if matches!(q.actions.get(label), Some(crate::ask::ChoiceAction::Done)) {
465 return true;
466 }
467 let at = q.choices.iter().position(|c| c == label);
468 if q.node == crate::github_text::ASK_NODE {
470 return matches!(
471 label,
472 crate::github_text::POST_ORIGINAL
473 | crate::github_text::USE_REWRITE
474 | crate::github_text::USE_MY_TEXT
475 );
476 }
477 match q.node.as_str() {
478 crate::triage::NODE => at == Some(2),
479 crate::triage::DEPS_NODE => matches!(at, Some(1 | 2)),
480 n => n == crate::reconcile::NODE && q.seat == crate::reconcile::SEAT,
481 }
482}
483
484pub fn consent(q: &Question, choice: &str) -> Consent {
486 if choice == crate::land::APPROVE {
487 Consent::Merge
488 } else if q.node == crate::github_text::ASK_NODE
489 && matches!(
490 choice,
491 crate::github_text::POST_ORIGINAL
492 | crate::github_text::USE_REWRITE
493 | crate::github_text::USE_MY_TEXT
494 )
495 {
496 Consent::Posting
497 } else if choice == crate::merge_decision::WITHDRAW
498 || crate::merge_decision::selected_agent(choice).is_some()
499 || destructive_effect(q, choice)
500 || matches!(q.actions.get(choice), Some(crate::ask::ChoiceAction::Done))
501 {
502 Consent::Consequence
503 } else {
504 Consent::Delegable
505 }
506}
507
508pub(crate) fn validate_batch_effect(
511 q: &Question,
512 choice: &str,
513 message: &str,
514 quote: &str,
515) -> Result<()> {
516 if batch_instruction(message) {
517 validate_effect(q, choice, message, quote, false)?;
518 bail!(
519 "a deputy's one-question thread has no persisted question-by-question recommendation manifest; use the active standing conversation's bound owner evidence rather than inferring a choice from generic batch words"
520 );
521 }
522 Ok(())
523}
524
525pub(crate) fn validate_effect(
527 q: &Question,
528 choice: &str,
529 instruction: &str,
530 quote: &str,
531 bound: bool,
532) -> Result<()> {
533 let direct = format!("{}: {}", q.id, choice);
534 match consent(q, choice) {
535 Consent::Delegable => {}
536 Consent::Merge => {
537 let target = q.merge_target.as_ref().context("merge requires the specific reviewed PR and head binding; this legacy card has no recorded target")?;
538 let token = format!("merge {}", target.head);
539 if quote.trim() != instruction.trim()
540 || !(instruction.trim() == token
541 || instruction.trim() == format!("{}: {token}", q.id))
542 {
543 bail!(
544 "merge requires separate explicit confirmation of this specific reviewed PR and head: `{}: {token}`; a batch recommendation is insufficient",
545 q.id
546 );
547 }
548 }
549 Consent::Consequence => {
550 if quote.trim() != instruction.trim()
551 || !(instruction.trim() == direct || (!bound && instruction.trim() == choice))
552 {
553 bail!(
554 "`{choice}` requires an individual owner confirmation of the exact offered consequence: `{direct}`; batch authorization is insufficient"
555 );
556 }
557 }
558 Consent::Posting => {
559 if bound {
560 let token = format!("{}: {} {}", q.id, choice, question_fingerprint(q));
561 let content_matches = if choice == crate::github_text::USE_MY_TEXT {
562 owner_title(q, instruction).is_some()
563 } else {
564 instruction.trim() == token
565 };
566 if !content_matches || quote.trim() != instruction.trim() {
567 bail!(
568 "posting confirmation requires owner confirmation bound to the exact question and saved content: `{token}`; `use my text` additionally requires a newline and the literal replacement title; batch authorization is insufficient"
569 );
570 }
571 } else if !crate::github_text::posting_authorized(instruction, quote, choice) {
572 bail!(
573 "posting confirmation requires an explicit, unconditional instruction for the chosen text; batch authorization is insufficient"
574 );
575 }
576 }
577 }
578 Ok(())
579}
580
581fn current_instruction(talk: &Talk) -> Option<(usize, usize, &str)> {
582 let queued_owner = (!talk.pending.is_empty() || !talk.pending_messages.is_empty())
586 && (talk.pending_messages.is_empty()
587 || talk
588 .pending_messages
589 .iter()
590 .any(|m| m.sender != Some(talk::Sender::Magi)));
591 if !talk.status.open() || queued_owner {
592 return None;
593 }
594 for (index, turn) in talk.turns.iter().enumerate().rev() {
595 if talk::sender(turn, None) == talk::Sender::Magi {
596 continue;
597 }
598 if turn.who != talk::Who::Operator || turn.posted_by.is_some() {
601 return None;
602 }
603 let latest = turn
604 .messages
605 .iter()
606 .enumerate()
607 .rfind(|(_, send)| talk::sender(turn, Some(send)) == talk::Sender::Operator);
608 let Some((message, send)) = latest else {
609 if !turn.messages.is_empty()
610 && turn
611 .messages
612 .iter()
613 .all(|m| m.sender == Some(talk::Sender::Magi))
614 {
615 continue;
616 }
617 return None;
618 };
619 if send.sender.is_none()
623 && (turn.breaks.is_none()
624 || turn
625 .messages
626 .iter()
627 .map(|m| m.body.as_str())
628 .collect::<Vec<_>>()
629 .join("\n\n")
630 != turn.body)
631 {
632 return None;
633 }
634 return Some((index, message, &send.body));
635 }
636 None
637}
638
639fn choice_authority(
640 q: &Question,
641 questions: &Questions,
642 talk: &Talk,
643 reply: &str,
644 quote: Option<&str>,
645) -> Option<AnswerAuthority> {
646 if !q.status.open()
647 || !(q.free_text() || q.choices.iter().any(|c| c == reply))
648 || (reply == crate::land::HOLD && q.actions.contains_key(reply))
649 {
650 return None;
651 }
652 let (turn, message, instruction) = current_instruction(talk)?;
653 if reply != crate::land::HOLD
654 && (instruction.starts_with(" ") || instruction.starts_with('\t'))
655 {
656 return None;
657 }
658 if quote.map(str::trim) != Some(instruction.trim()) || talk.turns[turn].at < q.asked_at {
659 return None;
660 }
661 let direct = direct_choice(instruction, q, questions, reply)
662 || (reply == crate::github_text::USE_MY_TEXT
663 && q.node == crate::github_text::ASK_NODE
664 && owner_title(q, instruction).is_some())
665 || (consent(q, reply) == Consent::Posting
666 && instruction.trim() == format!("{}: {} {}", q.id, reply, question_fingerprint(q)))
667 || (consent(q, reply) == Consent::Merge
668 && q.merge_target
669 .as_ref()
670 .is_some_and(|t| instruction.trim() == format!("{}: merge {}", q.id, t.head)));
671 let recommendation_turn = if direct {
672 None
673 } else {
674 if message != 0 || q.free_text() || !batch_instruction(instruction) {
675 return None;
676 }
677 let index = turn.checked_sub(1)?;
678 let recommendation = &talk.turns[index];
679 if recommendation.who != talk::Who::Agent
680 || talk::sender(recommendation, None) != talk::Sender::Persona
681 || recommendation.posted_by.is_some()
682 || recommendation.at < q.asked_at
683 || recommended_choice(&recommendation.body, q, questions) != Some(reply)
684 {
685 return None;
686 }
687 if let Some(manifest) = talk.recommendations.get(&index) {
688 if manifest.body != crate::github_text::fingerprint("", &recommendation.body)
689 || manifest.questions.get(&q.id) != Some(&question_fingerprint(q))
690 {
691 return None;
692 }
693 } else if reply != crate::land::HOLD {
694 return None;
697 }
698 Some(index)
699 };
700 Some(AnswerAuthority {
701 talk: talk.id.clone(),
702 turn,
703 message,
704 instruction: instruction.to_owned(),
705 instruction_fingerprint: crate::github_text::fingerprint("", instruction),
706 recommendation_turn,
707 recommendation_fingerprint: recommendation_turn
708 .map(|i| crate::github_text::fingerprint("", &talk.turns[i].body)),
709 question: q.id.clone(),
710 choice: reply.into(),
711 question_fingerprint: question_fingerprint(q),
712 })
713}
714
715fn batch_instruction(text: &str) -> bool {
716 let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect();
719 let mut compact = compact.trim_end_matches(['!', '!', '。', '.', 'ー', '〜', '~']);
720 let prefixes = [
721 "おっけー",
722 "オッケー〜",
723 "オッケー",
724 "おっけ〜",
725 "おけ",
726 "オケ",
727 "ok",
728 "OK",
729 "Ok",
730 "ありがとうございます",
731 "ありがとう",
732 "ありがと",
733 "了解です",
734 "了解",
735 "はい",
736 "じゃあ",
737 "では",
738 ];
739 while let Some(rest) = prefixes
740 .iter()
741 .find_map(|prefix| compact.strip_prefix(prefix))
742 {
743 compact = rest.trim_start_matches(['!', '!', '、', ',', '。', '.', 'ー', '〜', '~']);
744 }
745 matches!(compact, "それぞれ回答して" | "それぞれ回答してください")
746 || matches!(
747 text.trim()
748 .trim_end_matches(['.', '!'])
749 .to_ascii_lowercase()
750 .as_str(),
751 "answer each as recommended" | "answer all as recommended"
752 )
753}
754
755fn refers_to(reference: &str, q: &Question, questions: &Questions) -> bool {
756 reference == q.id
757 || (reference.len() >= 4
758 && reference == q.short()
759 && questions.resolve_id(reference).is_ok_and(|id| id == q.id))
760}
761
762fn direct_choice(text: &str, q: &Question, questions: &Questions, reply: &str) -> bool {
763 let Some((reference, choice)) = text.trim().split_once(':') else {
766 return false;
767 };
768 let reference = reference.trim().trim_matches('`');
769 (reference == q.id || (reply == crate::land::HOLD && refers_to(reference, q, questions)))
770 && choice.trim() == reply
771}
772
773fn recommended_choice<'a>(body: &str, q: &'a Question, questions: &Questions) -> Option<&'a str> {
774 let mut rows = Vec::new();
779 use comrak::nodes::NodeValue;
782 let arena = comrak::Arena::new();
783 let mut options = comrak::Options::default();
784 options.extension.table = true;
785 let document = comrak::parse_document(&arena, body, &options);
786 let lines: Vec<_> = body.lines().collect();
787 let mut eligible = std::collections::BTreeSet::new();
788 for node in document.descendants() {
789 let data = node.data.borrow();
790 if node
791 .ancestors()
792 .any(|n| matches!(n.data.borrow().value, NodeValue::BlockQuote))
793 {
794 continue;
795 }
796 if matches!(
800 data.value,
801 NodeValue::Heading(_) | NodeValue::Paragraph | NodeValue::TableRow(_)
802 ) && lines
803 .get(data.sourcepos.start.line - 1..data.sourcepos.end.line)
804 .is_some_and(|block| conditional_recommendation(&block.join(" ")))
805 {
806 return None;
807 }
808 if matches!(
809 data.value,
810 NodeValue::Paragraph | NodeValue::TableRow(false)
811 ) {
812 eligible.extend(data.sourcepos.start.line..=data.sourcepos.end.line);
813 }
814 }
815 for (index, line) in body.lines().enumerate() {
816 if !eligible.contains(&(index + 1)) {
817 continue;
818 }
819 let line = line.trim();
820 let tokens: Vec<&str> = line
821 .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
822 .collect();
823 if !tokens.iter().any(|r| refers_to(r, q, questions)) {
824 continue;
825 }
826 if line.starts_with('|') {
827 let cells: Vec<_> = line
828 .trim_matches('|')
829 .split('|')
830 .map(|c| c.trim().trim_matches('`'))
831 .collect();
832 let references = cells.iter().filter(|c| refers_to(c, q, questions)).count();
833 let other_question = cells
834 .iter()
835 .any(|c| c.len() >= 4 && questions.resolve_id(c).is_ok_and(|id| id != q.id));
836 let choices: Vec<_> = cells
837 .iter()
838 .filter(|c| q.choices.iter().any(|choice| choice == **c))
839 .collect();
840 if other_question || references != 1 || choices.len() != 1 {
841 return None;
842 }
843 rows.push(
844 q.choices
845 .iter()
846 .find(|choice| choice.as_str() == *choices[0])?
847 .as_str(),
848 );
849 continue;
850 }
851 let row = line.trim_start_matches(['-', '*', ' ']);
852 let row = row.trim_start_matches('`');
853 let end = row
854 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
855 .unwrap_or(row.len());
856 let (reference, rest) = row.split_at(end);
857 let rest = rest.trim_start_matches('`').trim_start();
858 let choice = rest
859 .strip_prefix(':')
860 .or_else(|| rest.strip_prefix('→'))
861 .or_else(|| rest.strip_prefix('|'))
862 .map(str::trim);
863 if !refers_to(reference, q, questions) {
864 return None;
865 }
866 let choice = choice?;
867 let choice = if choice == "hold、その後PRを閉じる" {
869 crate::land::HOLD
870 } else {
871 choice.trim_matches('`')
872 };
873 rows.push(
874 q.choices
875 .iter()
876 .find(|offered| offered.as_str() == choice)?
877 .as_str(),
878 );
879 }
880 (rows.len() == 1).then(|| rows[0])
881}
882
883fn conditional_recommendation(line: &str) -> bool {
884 let lower = line.to_lowercase();
885 lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
886 matches!(
887 word,
888 "if" | "unless"
889 | "when"
890 | "maybe"
891 | "conditional"
892 | "otherwise"
893 | "only"
894 | "except"
895 | "or"
896 | "either"
897 | "provided"
898 | "assuming"
899 | "depending"
900 )
901 }) || [
902 "ただし",
903 "場合",
904 "なら",
905 "たら",
906 "限り",
907 "のみ",
908 "かも",
909 "次第",
910 "条件",
911 "または",
912 "もしくは",
913 "あるいは",
914 "れば",
915 "とき",
916 "際",
917 "時だけ",
918 "必要に応じて",
919 ]
920 .iter()
921 .any(|word| line.contains(word))
922}
923
924impl QuestionKind {
925 pub fn of(q: &Question) -> Self {
927 if crate::deputy::merge_gated(q) {
928 return Self::Merge;
929 }
930 match q.node.as_str() {
931 crate::github_text::ASK_NODE => Self::Posting,
932 _ => Self::Ordinary,
933 }
934 }
935}
936
937pub fn validate_answer(
941 q: &Question,
942 talks: &Talks,
943 run: &str,
944 node: &str,
945 reply: &str,
946 quote: Option<&str>,
947) -> Result<()> {
948 let kind = QuestionKind::of(q);
949 if node != CHAT_NODE || kind == QuestionKind::Ordinary {
950 return Ok(());
951 }
952 let posting = kind == QuestionKind::Posting;
953 if posting && !q.choices.iter().any(|c| c == reply) {
954 bail!("posting confirmation requires one of the offered choices");
955 }
956 if posting && reply == crate::github_text::USE_MY_TEXT {
957 bail!(
958 "use my text requires current question-bound owner evidence with the content fingerprint, a newline and the literal replacement title; an old question-thread title or hold is insufficient"
959 );
960 }
961 if posting
962 && !matches!(
963 reply,
964 crate::github_text::POST_ORIGINAL | crate::github_text::USE_REWRITE
965 )
966 {
967 return Ok(());
968 }
969 let subject = if posting {
970 "posting confirmation"
971 } else {
972 "merge approval"
973 };
974 let consult = q
975 .consult
976 .as_ref()
977 .with_context(|| format!("{subject} was not handed to a chat"))?;
978 if consult.talk != run {
979 bail!("{subject} belongs to a different chat");
980 }
981 if !q.status.open() {
982 bail!("{subject} is no longer open");
983 }
984 let talk = talks.get(run)?;
985 if !talk.status.open() {
986 bail!("the consulted chat is closed");
987 }
988 let at = talk
992 .turns
993 .iter()
994 .rposition(|t| {
995 t.who == talk::Who::Operator
996 && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
997 && t.body.contains(&q.id)
998 })
999 .context("the question was not delivered to the chat yet")?;
1000 let queued = latest_owner_message(
1002 &talk.pending,
1003 talk.pending_breaks.as_deref(),
1004 &talk.pending_messages,
1005 None,
1006 posting,
1007 );
1008 let stored = talk.turns[at..]
1009 .iter()
1010 .enumerate()
1011 .rev()
1012 .filter(|(_, t)| t.who == talk::Who::Operator)
1013 .find_map(|(i, t)| {
1014 latest_owner_message(
1015 &t.body,
1016 t.breaks.as_deref(),
1017 &t.messages,
1018 (i == 0).then_some(q.id.as_str()),
1019 posting,
1020 )
1021 });
1022 let latest = queued
1023 .or(stored)
1024 .context("no owner message after the question was handed to the chat")?;
1025 let quote = quote
1026 .map(str::trim)
1027 .filter(|s| !s.is_empty())
1028 .with_context(|| {
1029 format!("chat {subject} requires --quote from the owner's latest message")
1030 })?;
1031 if posting {
1032 if !latest.contains(quote) {
1033 bail!("posting confirmation requires a verbatim quote of the latest owner message");
1034 }
1035 if !crate::github_text::posting_authorized(&latest, quote, reply) {
1036 bail!(
1037 "posting confirmation requires an explicit, unconditional instruction for the chosen text; ask the owner to clarify"
1038 );
1039 }
1040 return Ok(());
1041 }
1042 if reply == crate::land::APPROVE && batch_instruction(&latest) {
1043 bail!(
1044 "a batch answer instruction is insufficient for merge; merging requires a separate explicit \
1045 confirmation of this specific reviewed PR and head through the existing approval driver"
1046 );
1047 }
1048 let valid = if reply == crate::land::APPROVE {
1049 match &q.merge_target {
1050 Some(target) => {
1051 latest.trim() == format!("merge {}", target.head) && quote == latest.trim()
1052 }
1053 None => latest.contains(quote),
1054 }
1055 } else {
1056 q.choices.iter().any(|c| c == reply)
1057 && crate::merge_decision::exact_owner_choice(reply, &latest, quote)
1058 };
1059 if !valid {
1060 if reply == crate::land::APPROVE {
1061 if let Some(target) = &q.merge_target {
1062 bail!(
1063 "merge requires the latest whole owner message and --quote to be exactly `merge {}` for the displayed PR/head",
1064 target.head
1065 );
1066 }
1067 bail!("merge requires a verbatim quote of the latest explicit owner merge instruction");
1068 }
1069 bail!(
1070 "`{reply}` requires the latest whole owner message and --quote to equal this exact offered choice, or current question-bound owner evidence under the action-specific consent policy"
1071 );
1072 }
1073 Ok(())
1074}
1075
1076#[cfg(test)]
1086fn latest_message(
1087 body: &str,
1088 breaks: Option<&[usize]>,
1089 after_block_of: Option<&str>,
1090) -> Option<String> {
1091 latest_message_for_policy(body, breaks, after_block_of, false)
1092}
1093
1094fn latest_owner_message(
1095 body: &str,
1096 breaks: Option<&[usize]>,
1097 messages: &[talk::Message],
1098 after: Option<&str>,
1099 posting: bool,
1100) -> Option<String> {
1101 if messages.is_empty() {
1102 return latest_message_for_policy(body, breaks, after, posting);
1103 }
1104 let first = after.map_or(0, |id| {
1105 messages
1106 .iter()
1107 .rposition(|m| {
1108 m.body.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.body.contains(id)
1109 })
1110 .unwrap_or(0)
1111 });
1112 let aligned = messages
1115 .iter()
1116 .map(|m| m.body.as_str())
1117 .collect::<Vec<_>>()
1118 .join("\n\n")
1119 == body;
1120 let valid_breaks = breaks.filter(|cuts| {
1121 cuts.windows(2).all(|w| w[0] < w[1])
1122 && cuts.iter().all(|&o| {
1123 o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
1124 })
1125 });
1126 let mut offset = 0;
1127 let mut latest = None;
1128 for (i, message) in messages.iter().enumerate() {
1129 let end = offset + message.body.len();
1130 if i >= first && message.sender != Some(talk::Sender::Magi) {
1131 let local_breaks = aligned.then_some(valid_breaks).flatten().map(|cuts| {
1132 cuts.iter()
1133 .copied()
1134 .filter(|&cut| cut > offset && cut <= end)
1135 .map(|cut| cut - offset)
1136 .collect::<Vec<_>>()
1137 });
1138 let message_breaks = if message.sender.is_some() {
1139 Some(&[][..])
1140 } else {
1141 local_breaks.as_deref()
1142 };
1143 if let Some(words) = latest_message_for_policy(
1144 &message.body,
1145 message_breaks,
1146 after.filter(|_| i == first),
1147 posting,
1148 ) {
1149 latest = Some(words);
1150 }
1151 }
1152 offset = end + 2;
1153 }
1154 latest
1155}
1156
1157fn latest_message_for_policy(
1158 body: &str,
1159 breaks: Option<&[usize]>,
1160 after_block_of: Option<&str>,
1161 posting: bool,
1162) -> Option<String> {
1163 let cuts = breaks.filter(|b| {
1164 b.windows(2).all(|w| w[0] < w[1])
1165 && b.iter().all(|&o| {
1166 o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
1167 })
1168 });
1169 let Some(cuts) = cuts else {
1170 if is_completion_notice(body) {
1171 return None;
1172 }
1173 let words = owner_words_for_policy(body, after_block_of, posting);
1174 if posting && words.contains("\n\n") {
1178 return Some(String::new());
1181 }
1182 if posting {
1183 return (!words.trim().is_empty()).then_some(words);
1184 }
1185 return words
1186 .rsplit("\n\n")
1187 .map(str::trim)
1188 .find(|p| !p.is_empty())
1189 .map(str::to_owned);
1190 };
1191 let mut starts = vec![0];
1192 starts.extend_from_slice(cuts);
1193 let messages: Vec<&str> = starts
1194 .iter()
1195 .enumerate()
1196 .map(|(i, &s)| {
1197 let e = starts.get(i + 1).map_or(body.len(), |&n| n - 2);
1198 &body[s..e]
1199 })
1200 .collect();
1201 let first = after_block_of.map_or(0, |id| {
1203 messages
1204 .iter()
1205 .rposition(|m| m.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.contains(id))
1206 .unwrap_or(0)
1207 });
1208 messages
1209 .iter()
1210 .enumerate()
1211 .skip(first)
1212 .rev()
1213 .filter(|(_, m)| !is_completion_notice(m))
1214 .map(|(i, m)| owner_words_for_policy(m, after_block_of.filter(|_| i == first), posting))
1215 .find(|w| !w.trim().is_empty())
1216}
1217
1218fn is_completion_notice(message: &str) -> bool {
1221 crate::prompt::is_completion_notice(message)
1222}
1223
1224fn legacy_len(rest: &str) -> usize {
1231 use crate::prompt::CHAT_CONSULT_HEADING;
1232 const CLOSERS: [&str; 2] = [
1233 "cannot be revived. Do not edit the repository.",
1234 "make: do not edit the repository.",
1235 ];
1236 let mut events: Vec<(usize, usize)> = Vec::new(); let first = rest.find(CHAT_CONSULT_HEADING).unwrap_or(0);
1238 for (i, _) in rest.match_indices(CHAT_CONSULT_HEADING) {
1239 if i > first
1240 && rest[..i].ends_with("# ")
1241 && rest[..i - 2].ends_with('\n')
1242 && rest[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
1243 {
1244 events.push((i, 0));
1245 }
1246 }
1247 for c in CLOSERS {
1248 events.extend(rest.match_indices(c).map(|(i, _)| (i, c.len())));
1249 }
1250 events.sort_unstable();
1251 let mut depth = 1usize;
1252 for (at, len) in events {
1253 if len == 0 {
1254 depth += 1;
1255 } else {
1256 depth -= 1;
1257 if depth == 0 {
1258 return at + len;
1259 }
1260 }
1261 }
1262 rest.len()
1263}
1264
1265#[cfg(test)]
1279fn owner_words(body: &str, after_block_of: Option<&str>) -> String {
1280 owner_words_for_policy(body, after_block_of, false)
1281}
1282
1283fn owner_words_for_policy(body: &str, after_block_of: Option<&str>, posting: bool) -> String {
1284 let trim = |text: &str| -> String {
1287 if posting {
1288 text.trim_matches(['\r', '\n']).to_owned()
1289 } else {
1290 text.trim().to_owned()
1291 }
1292 };
1293 use crate::prompt::{CHAT_CONSULT_END, CHAT_CONSULT_HEADING};
1294 let heads: Vec<usize> = body
1298 .match_indices(CHAT_CONSULT_HEADING)
1299 .map(|(i, _)| i)
1300 .filter(|&i| {
1301 let line_start = body[..i]
1302 .strip_suffix("# ")
1303 .is_some_and(|b| b.is_empty() || b.ends_with('\n'));
1304 line_start
1305 && body[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
1306 })
1307 .collect();
1308 if heads.is_empty() {
1309 return trim(body);
1310 }
1311 let mut blocks: Vec<(usize, usize)> = Vec::new();
1318 for &h in &heads {
1319 let start = if body[..h].ends_with("# ") { h - 2 } else { h };
1320 if blocks.last().is_some_and(|&(_, e)| start < e) {
1321 continue; }
1323 let rest = &body[h..];
1324 let head = rest.split("\n\n## ").next().unwrap_or(rest);
1325 let end = if head.contains(crate::prompt::CHAT_CONSULT_DEFUSED) {
1326 rest.find(CHAT_CONSULT_END)
1329 .map_or(body.len(), |p| h + p + CHAT_CONSULT_END.len())
1330 } else {
1331 h + legacy_len(rest)
1332 };
1333 blocks.push((start, end));
1334 }
1335 let mut from = 0;
1336 if let Some(id) = after_block_of {
1337 if let Some(&(_, end)) = blocks.iter().rfind(|&&(s, e)| body[s..e].contains(id)) {
1338 from = end;
1339 } else if let Some(&(_, end)) = blocks.last() {
1340 from = end;
1341 }
1342 }
1343 let mut parts = Vec::new();
1344 let mut at = from;
1345 for &(s, e) in &blocks {
1346 if s >= at {
1347 parts.push(trim(&body[at..s]));
1348 }
1349 at = at.max(e);
1350 }
1351 parts.push(trim(&body[at..]));
1352 parts
1353 .into_iter()
1354 .filter(|p| !p.trim().is_empty())
1355 .collect::<Vec<_>>()
1356 .join("\n\n")
1357}
1358
1359#[derive(Debug, PartialEq, Eq)]
1361pub enum Started {
1362 Answered,
1364 Busy,
1366 Nothing,
1368}
1369
1370pub async fn start_turn(
1377 questions: &Questions,
1378 talks: &Talks,
1379 q: &Question,
1380 talk: &Talk,
1381 cfg: &Config,
1382) -> Result<Started> {
1383 if q.consult.is_some() {
1384 return Ok(Started::Nothing);
1385 }
1386 let Some(lease) = talks.claim_turn(&talk.id)? else {
1387 return Ok(Started::Busy);
1388 };
1389 if !begin(questions, talks, q, talk)? {
1390 return Ok(Started::Nothing);
1391 }
1392 drain_turns(talks, &talk.id, lease, cfg).await?;
1393 Ok(Started::Answered)
1394}
1395
1396pub async fn drain_turns(
1403 talks: &Talks,
1404 talk_id: &str,
1405 mut lease: talk::TurnLease,
1406 cfg: &Config,
1407) -> Result<()> {
1408 let mut talk = talks.get(talk_id)?;
1409 let mut failed = None;
1410 loop {
1411 while let Some(text) = talk::drain(&mut talk, talks)? {
1412 if let Err(e) = talk::respond(&lease, &mut talk, talks, cfg, &text).await {
1413 failed.get_or_insert(e);
1414 }
1415 if !lease.beat()? {
1416 bail!("the turn lease for chat {} was lost", talk.short());
1417 }
1418 }
1419 drop(lease);
1424 talk = talks.get(&talk.id)?;
1425 let owed = talk.status.open()
1426 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
1427 if !owed {
1428 break;
1429 }
1430 let Some(again) = talks.claim_turn(&talk.id)? else {
1432 break;
1433 };
1434 lease = again;
1435 }
1436 if let Some(e) = failed {
1437 return Err(e);
1438 }
1439 Ok(())
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use std::collections::BTreeMap;
1445 use std::path::PathBuf;
1446
1447 use super::*;
1448 use crate::config::{AgentKind, AgentSpec, Config};
1449 use crate::queue::Source;
1450 use crate::talk::TalkStatus;
1451
1452 const RUN: &str = "20260902-000000-beef";
1453
1454 fn talks() -> (tempfile::TempDir, Talks, Talk) {
1455 let tmp = tempfile::tempdir().expect("tempdir");
1456 let store = Talks::at(tmp.path().join("talks"));
1457 let cfg = Config {
1458 agents: vec![AgentSpec {
1459 id: "mock".to_owned(),
1460 kind: AgentKind::Command,
1461 model: None,
1462 command: vec!["true".to_owned()],
1463 extra_args: Vec::new(),
1464 env: BTreeMap::new(),
1465 prompt_delivery: None,
1466 }],
1467 ..Config::default()
1468 };
1469 let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
1470 (tmp, store, talk)
1471 }
1472
1473 fn task(source: Source) -> Task {
1474 let mut t = Task::new(
1475 "t".to_owned(),
1476 "Do it".to_owned(),
1477 PathBuf::from("/repo"),
1478 source,
1479 );
1480 t.start(RUN.to_owned());
1481 t
1482 }
1483
1484 fn from_chat(talk: &Talk) -> Task {
1485 task(Source::Agent {
1486 run: talk.id.clone(),
1487 node: CHAT_NODE.to_owned(),
1488 })
1489 }
1490
1491 fn question(node: &str) -> Question {
1492 Question::new(
1493 RUN.to_owned(),
1494 node.to_owned(),
1495 "impl-A".to_owned(),
1496 "Which backend?".to_owned(),
1497 "SQLite is simpler.".to_owned(),
1498 vec!["SQLite".to_owned(), "Redis".to_owned()],
1499 )
1500 }
1501
1502 #[test]
1503 fn batch_instruction_normalizes_repeated_japanese_prefixes() {
1504 for instruction in [
1505 "おっけーそれぞれ回答してー",
1506 "了解です、ありがとう!では、それぞれ回答してください。",
1507 "ありがとうございます!はい、じゃあそれぞれ回答して〜",
1508 "オッケー〜!おけ、それぞれ回答して",
1509 "OK!それぞれ回答して",
1510 "answer all as recommended!",
1511 ] {
1512 assert!(batch_instruction(instruction), "{instruction}");
1513 }
1514 for instruction in [
1515 "了解です",
1516 "ありがとう!それぞれ回答してもいい?",
1517 "もし大丈夫ならそれぞれ回答して",
1518 "それぞれ回答して、いや待って",
1519 "「それぞれ回答して」",
1520 ] {
1521 assert!(!batch_instruction(instruction), "{instruction}");
1522 }
1523 }
1524
1525 #[test]
1526 fn recommendation_returns_only_one_exact_offered_choice() {
1527 let tmp = tempfile::tempdir().unwrap();
1528 let questions = Questions::at(tmp.path().join("questions"));
1529 let mut q = question("implement");
1530 questions.put(&mut q).unwrap();
1531 let table = |rows: &str| format!("| Question | Choice |\n| --- | --- |\n{rows}");
1532 let row = format!("| {} | SQLite |", q.id);
1533 assert_eq!(
1534 recommended_choice(&table(&row), &q, &questions),
1535 Some("SQLite")
1536 );
1537 assert_eq!(
1538 recommended_choice(&format!("{}: Redis", q.id), &q, &questions),
1539 Some("Redis")
1540 );
1541 for body in [
1542 table(&format!("{row}\n| {} | Redis |", q.id)),
1543 table(&format!("{row}\n{row}")),
1544 table(&format!("| {} | SQLite | Redis |", q.id)),
1545 table(&format!("| {} | SQLite maybe |", q.id)),
1546 format!("> {}: SQLite", q.id),
1547 format!("If approved:\n\n{}: SQLite", q.id),
1548 "No recommendation".into(),
1549 ] {
1550 assert_eq!(recommended_choice(&body, &q, &questions), None, "{body}");
1551 }
1552 }
1553
1554 #[test]
1555 fn chat_answer_is_refused_for_an_operator_held_task() {
1556 let mut task = crate::queue::Task::new(
1557 "t".into(),
1558 "i".into(),
1559 std::path::PathBuf::from("."),
1560 crate::queue::Source::Human,
1561 );
1562 task.runs.push("run-1".into());
1563 let mut q = question(crate::land::APPROVAL_NODE);
1564 q.run = "run-1".into();
1565 let check = |t: &crate::queue::Task, node: &str| {
1566 refuse_operator_held(&q, node, std::slice::from_ref(t))
1567 };
1568 assert!(check(&task, CHAT_NODE).is_ok());
1569 task.hold_machine(None);
1570 assert!(check(&task, CHAT_NODE).is_ok());
1571 task.hold_manual(None);
1572 assert!(check(&task, CHAT_NODE).is_err());
1573 assert!(check(&task, "implement").is_ok());
1575 assert!(refuse_operator_held(&q, CHAT_NODE, &[]).is_ok());
1576 }
1577
1578 #[tokio::test]
1579 async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
1580 let (tmp, store, talk) = talks();
1581 let questions = Questions::at(tmp.path().join("questions"));
1582 let mut q = question("implement");
1583 questions.put(&mut q).expect("put");
1584 let held = store.claim_turn(&talk.id).expect("claim").expect("first");
1585
1586 let got = start_turn(&questions, &store, &q, &talk, &Config::default())
1587 .await
1588 .expect("start");
1589 assert_eq!(got, Started::Busy);
1590 assert!(questions.get(&q.id).unwrap().consult.is_none());
1591 assert!(store.get(&talk.id).unwrap().pending.is_empty());
1592 drop(held);
1593 }
1594
1595 fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
1598 let path = tmp.join("mock-consult-agent.sh");
1599 std::fs::write(&path, script).expect("write mock");
1600 Config {
1601 agents: vec![AgentSpec {
1602 id: "mock".to_owned(),
1603 kind: AgentKind::Command,
1604 model: None,
1605 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
1606 extra_args: Vec::new(),
1607 env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
1608 prompt_delivery: None,
1609 }],
1610 ..Config::default()
1611 }
1612 }
1613
1614 async fn run_turn(
1617 script: &str,
1618 ) -> (
1619 tempfile::TempDir,
1620 Result<Started>,
1621 Questions,
1622 Question,
1623 Talk,
1624 ) {
1625 crate::run::set_home(crate::run::test_home());
1626 let (tmp, store, talk) = talks();
1627 let questions = Questions::at(tmp.path().join("questions"));
1628 let mut q = question("implement");
1629 questions.put(&mut q).expect("put");
1630 let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
1631 let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
1632 (tmp, got, questions, q, talk)
1633 }
1634
1635 #[tokio::test]
1636 async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
1637 let (tmp, got, _questions, _q, talk) =
1639 run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
1640 .await;
1641 assert_eq!(got.expect("turn"), Started::Answered);
1642 let other = Talks::at(tmp.path().join("talks"));
1643 assert!(
1644 other.claim_turn(&talk.id).expect("claim").is_some(),
1645 "free once the turn ended"
1646 );
1647 }
1648
1649 #[tokio::test]
1650 async fn the_lease_is_free_again_after_a_failed_turn() {
1651 let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
1652 assert!(got.is_err(), "the agent failed");
1653 assert!(
1654 questions.get(&q.id).unwrap().consult.is_some(),
1655 "the turn got as far as running"
1656 );
1657 let other = Talks::at(tmp.path().join("talks"));
1658 assert!(
1659 other.claim_turn(&talk.id).expect("claim").is_some(),
1660 "free after the failed turn"
1661 );
1662 }
1663
1664 #[test]
1665 fn origin_talk_is_decided_in_one_table() {
1666 let (_tmp, _store, talk) = talks();
1667 let mut closed = talk.clone();
1668 closed.status = TalkStatus::Closed;
1669 let chat = from_chat(&talk);
1670 let q = question("implement");
1671
1672 let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
1673 assert_eq!(
1674 hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
1675 Some(talk.id.clone())
1676 );
1677 assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
1679 let other = task(Source::Agent {
1680 run: talk.id.clone(),
1681 node: "implement".to_owned(),
1682 });
1683 assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
1684 assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
1686 assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
1688 assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
1690 assert!(
1692 hit(
1693 std::slice::from_ref(&chat),
1694 std::slice::from_ref(&talk),
1695 &question(crate::land::APPROVAL_NODE)
1696 )
1697 .is_some()
1698 );
1699 assert!(
1700 hit(
1701 std::slice::from_ref(&chat),
1702 std::slice::from_ref(&talk),
1703 &question(crate::bump::NOTICE_NODE)
1704 )
1705 .is_none()
1706 );
1707 let mut answered = question("implement");
1709 answered
1710 .answer(crate::ask::Answer::Choice("Redis".to_owned()))
1711 .unwrap();
1712 assert!(hit(&[chat], &[talk], &answered).is_none());
1713 }
1714
1715 #[test]
1716 fn associated_chat_remains_navigable_after_consultation_and_settlement() {
1717 let (tmp, store, mut talk) = talks();
1718 let chat = from_chat(&talk);
1719 let tasks = std::slice::from_ref(&chat);
1720 let mut q = question("implement");
1721 let questions = Questions::at(tmp.path().join("questions"));
1722 questions.put(&mut q).unwrap();
1723 assert!(begin(&questions, &store, &q, &talk).unwrap());
1724 q = questions.get(&q.id).unwrap();
1725 let associated = |q: &Question, talk: &Talk| {
1726 associated_talk(tasks, std::slice::from_ref(talk), q).map(|t| t.id)
1727 };
1728 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1729 assert!(q.status.open());
1730 assert!(q.answer.is_none());
1731 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_some());
1732 q.answer(crate::ask::Answer::Choice("Redis".into()))
1733 .unwrap();
1734 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1735 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
1736 talk.status = TalkStatus::Closed;
1737 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1738 let mut q = question("implement");
1739 q.abandon("expired");
1740 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
1741 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
1742 assert!(associated_talk(tasks, &[], &q).is_none());
1743 assert!(associated_talk(&[], std::slice::from_ref(&talk), &q).is_none());
1744 let notice = question(crate::bump::NOTICE_NODE);
1745 assert_eq!(associated(¬ice, &talk), Some(talk.id.clone()));
1746 assert!(origin_talk(tasks, &[talk], ¬ice).is_none());
1747 }
1748
1749 #[test]
1750 fn approval_origin_requires_an_open_chat_task_and_question() {
1751 let (_tmp, _store, talk) = talks();
1752 let chat = from_chat(&talk);
1753 let mut q = question(crate::land::APPROVAL_NODE);
1754 assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
1755 assert!(
1756 origin_talk(
1757 &[task(Source::Agent {
1758 run: talk.id.clone(),
1759 node: "implement".into(),
1760 })],
1761 std::slice::from_ref(&talk),
1762 &q
1763 )
1764 .is_none()
1765 );
1766 assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
1767 let mut closed = talk.clone();
1768 closed.status = TalkStatus::Closed;
1769 assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
1770 q.abandon("expired");
1771 assert!(
1772 origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
1773 );
1774 let mut q = question(crate::land::APPROVAL_NODE);
1775 q.answer(crate::ask::Answer::Choice("Redis".into()))
1776 .unwrap();
1777 assert!(origin_talk(&[chat], &[talk], &q).is_none());
1778 }
1779
1780 #[test]
1781 fn a_conductor_question_is_found_through_the_task_id() {
1782 let (_tmp, _store, talk) = talks();
1783 let chat = from_chat(&talk);
1784 let mut q = question(crate::conduct::NODE);
1785 q.run = chat.id.clone();
1786 assert!(origin_talk(&[chat], &[talk], &q).is_some());
1787 }
1788
1789 fn followup_of(parent: &Task, n: u32) -> Task {
1790 let mut t = Task::new(
1791 "f".to_owned(),
1792 "Fix".to_owned(),
1793 PathBuf::from("/repo"),
1794 Source::Agent {
1795 run: format!("merged-{n}"),
1796 node: "followup".to_owned(),
1797 },
1798 );
1799 t.start(format!("run-f{n}"));
1800 t.followup = Some(crate::queue::FollowUp {
1801 run: format!("merged-{n}"),
1802 origin_task: Some(parent.id.clone()),
1803 pr: "https://example.invalid/pr/1".to_owned(),
1804 findings: Vec::new(),
1805 generation: n,
1806 });
1807 t
1808 }
1809
1810 fn q_for(t: &Task) -> Question {
1811 let mut q = question("implement");
1812 q.run = t.runs[0].clone();
1813 q
1814 }
1815
1816 #[test]
1817 fn a_followup_traces_back_to_the_chat() {
1818 let (_tmp, _store, talk) = talks();
1819 let mut chat = from_chat(&talk);
1820 chat.runs = vec!["chat-run".to_owned()];
1821 let f1 = followup_of(&chat, 1);
1822 let f2 = followup_of(&f1, 2);
1823 let ts = [chat, f1.clone(), f2.clone()];
1824 let id = Some(talk.id.clone());
1825 let tk = std::slice::from_ref(&talk);
1826 assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
1827 assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
1828 }
1829
1830 #[test]
1831 fn a_followup_without_origin_task_is_found_through_runs() {
1832 let (_tmp, _store, talk) = talks();
1833 let mut chat = from_chat(&talk);
1834 chat.runs = vec!["merged-1".to_owned()];
1835 let mut f1 = followup_of(&chat, 1);
1836 f1.followup.as_mut().unwrap().origin_task = None;
1837 let q = q_for(&f1);
1838 assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
1839 }
1840
1841 #[test]
1842 fn a_dangling_origin_task_has_no_chat() {
1843 let (_tmp, _store, talk) = talks();
1844 let mut chat = from_chat(&talk);
1845 chat.runs = vec!["chat-run".to_owned()];
1846 let mut f1 = followup_of(&chat, 1);
1847 f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
1848 let q = q_for(&f1);
1849 assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
1850 }
1851
1852 #[test]
1853 fn a_recorded_chat_survives_deleted_ancestors() {
1854 let (_tmp, _store, talk) = talks();
1855 let chat = from_chat(&talk);
1856 assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
1857 let mut f1 = followup_of(&chat, 1);
1858 f1.origin_chat = chat.origin_chat.clone();
1859 let mut f2 = followup_of(&f1, 2);
1860 f2.origin_chat = f1.origin_chat.clone();
1861 let q = q_for(&f2);
1863 let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
1864 assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
1865 assert!(origin_talk(&[f2], &[], &q).is_none());
1867 }
1868
1869 #[test]
1870 fn a_task_without_the_field_still_walks_the_ancestry() {
1871 let (_tmp, _store, talk) = talks();
1872 let mut chat = from_chat(&talk);
1873 chat.origin_chat = None;
1874 let mut f1 = followup_of(&chat, 1);
1875 f1.origin_chat = None;
1876 let q = q_for(&f1);
1877 assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
1878 let mut v = serde_json::to_value(&f1).unwrap();
1880 v.as_object_mut().unwrap().remove("origin_chat");
1881 let back: Task = serde_json::from_value(v).unwrap();
1882 assert!(back.origin_chat.is_none());
1883 }
1884
1885 #[test]
1886 fn a_followup_cycle_ends_without_a_chat() {
1887 let (_tmp, _store, talk) = talks();
1888 let mut a = followup_of(&from_chat(&talk), 1);
1889 let mut b = followup_of(&a, 2);
1890 a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
1891 b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
1892 let q = q_for(&a);
1893 assert!(origin_talk(&[a, b], &[talk], &q).is_none());
1894 }
1895
1896 #[test]
1897 fn pending_consults_follow_the_store_not_the_turn_text() {
1898 let (tmp, store, talk) = talks();
1899 let questions = Questions::at(tmp.path().join("questions"));
1900 assert!(!pending_consults(&questions, &talk.id), "empty store");
1901
1902 let mut plain = question("implement");
1903 questions.put(&mut plain).unwrap();
1904 assert!(!pending_consults(&questions, &talk.id), "no consult record");
1905
1906 let mut q = question("implement");
1907 questions.put(&mut q).unwrap();
1908 begin(&questions, &store, &q, &talk).unwrap();
1909 assert!(pending_consults(&questions, &talk.id));
1910 assert!(!pending_consults(&questions, "other-talk"));
1911
1912 questions
1913 .update(&q.id, |q| {
1914 q.abandon("test");
1915 Ok(())
1916 })
1917 .unwrap();
1918 assert!(!pending_consults(&questions, &talk.id), "closed question");
1919 }
1920
1921 #[test]
1922 fn begin_reopens_a_closed_chat_before_queueing() {
1923 let (tmp, store, mut talk) = talks();
1924 let questions = Questions::at(tmp.path().join("questions"));
1925 let mut q = question("implement");
1926 questions.put(&mut q).unwrap();
1927 talk::close(&mut talk, &store).unwrap();
1928 assert!(!store.get(&talk.id).unwrap().status.open());
1929
1930 assert!(begin(&questions, &store, &q, &talk).unwrap());
1931
1932 let after = store.get(&talk.id).unwrap();
1933 assert!(after.status.open(), "the chat was reopened");
1934 assert!(after.pending.contains(&q.id), "{}", after.pending);
1935 assert!(questions.get(&q.id).unwrap().consult.is_some());
1936 }
1937
1938 #[test]
1939 fn begin_queues_once_and_leaves_the_question_open() {
1940 let (tmp, store, talk) = talks();
1941 let questions = Questions::at(tmp.path().join("questions"));
1942 let mut q = question("implement");
1943 questions.put(&mut q).unwrap();
1944
1945 assert!(begin(&questions, &store, &q, &talk).unwrap());
1946 assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
1947
1948 let after = questions.get(&q.id).unwrap();
1949 assert!(after.status.open());
1950 assert!(after.thread.is_empty());
1951 assert_eq!(after.choices, q.choices, "choices are not touched");
1952 assert_eq!(
1953 after.consult.as_ref().map(|c| c.talk.as_str()),
1954 Some(talk.id.as_str())
1955 );
1956
1957 let destination = store.get(&talk.id).unwrap();
1958 assert_eq!(destination.pending_messages.len(), 1);
1959 assert_eq!(
1960 destination.pending_messages[0].display,
1961 Some(crate::prompt::chat_consult_display(&q))
1962 );
1963 let queued = destination.pending;
1964 assert_eq!(
1965 queued.matches(&q.id).count(),
1966 2 + 1,
1967 "id once per use: {queued}"
1968 );
1969 assert!(queued.contains("Which backend?"));
1970 assert!(queued.contains("SQLite is simpler."));
1971 assert!(queued.contains("- Redis"));
1972 assert_eq!(
1974 queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
1975 1
1976 );
1977 }
1978
1979 fn block(id: &str, detail: &str) -> String {
1980 let detail = crate::prompt::defuse(detail);
1981 format!(
1982 "# {}\n\nThe operator passed you a question `{id}`. {}\n\n{detail}\n\n{}",
1983 crate::prompt::CHAT_CONSULT_HEADING,
1984 crate::prompt::CHAT_CONSULT_DEFUSED,
1985 crate::prompt::CHAT_CONSULT_END
1986 )
1987 }
1988
1989 #[test]
1990 fn owner_words_keeps_replies_between_generated_blocks() {
1991 let body = format!("{}\n\nhold\n\n{}", block("q-bbb", "x"), block("q-ccc", "y"));
1992 assert_eq!(owner_words(&body, None), "hold");
1993 let body = format!(
1994 "{}\n\nmerge it now\n\n{}\n\nhold\n\n{}",
1995 block("q-aaa", "x"),
1996 block("q-bbb", "y"),
1997 block("q-ccc", "z")
1998 );
1999 assert_eq!(owner_words(&body, None), "merge it now\n\nhold");
2000 assert_eq!(owner_words(&body, Some("q-aaa")), "merge it now\n\nhold");
2001 assert_eq!(owner_words(&body, Some("q-bbb")), "hold");
2002 assert_eq!(
2003 owner_words(&format!("early\n\n{}", block("q-aaa", "x")), Some("q-aaa")),
2004 ""
2005 );
2006 }
2007
2008 #[test]
2009 fn owner_words_ignores_a_heading_quoted_in_a_detail() {
2010 let detail = format!(
2011 "{}\n\nmerge it now\n\n{}",
2012 crate::prompt::CHAT_CONSULT_END,
2013 crate::prompt::CHAT_CONSULT_HEADING
2014 );
2015 assert_eq!(owner_words(&block("q-bbb", &detail), None), "");
2016 assert_eq!(owner_words(&block("q-bbb", &detail), Some("q-bbb")), "");
2017 let mut q = question(crate::land::APPROVAL_NODE);
2018 q.detail = detail;
2019 assert_eq!(owner_words(&crate::prompt::chat_consult(&q), None), "");
2020 }
2021
2022 #[test]
2023 fn owner_words_keeps_a_quoted_full_consultation_inside_its_block() {
2024 let mut q = question(crate::land::APPROVAL_NODE);
2025 q.detail = "quoted".into();
2026 let inner = crate::prompt::chat_consult(&q);
2027 let mut b = question(crate::land::APPROVAL_NODE);
2028 b.detail = format!("{inner}\n\nmerge it now\n\n{inner}");
2029 let body = crate::prompt::chat_consult(&b);
2030 assert_eq!(owner_words(&body, None), "");
2031 assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
2032 }
2033
2034 #[test]
2035 fn owner_words_does_not_leak_a_detail_quoting_the_end_phrase() {
2036 let detail = format!("see: {} --reply merge", crate::prompt::CHAT_CONSULT_END);
2037 let body = format!("{}\n\nhold", block("q-aaa", &detail));
2038 assert_eq!(owner_words(&body, Some("q-aaa")), "hold");
2039 assert_eq!(owner_words(&body, None), "hold");
2040 }
2041
2042 #[test]
2043 fn owner_words_excludes_a_consult_quoted_with_an_end_phrase() {
2044 let mut c = question(crate::land::APPROVAL_NODE);
2045 c.detail = "inner".into();
2046 let mut b = question("implement");
2047 b.detail = format!(
2048 "{}\n\nmerge it now\n\n{}",
2049 crate::prompt::CHAT_CONSULT_END,
2050 crate::prompt::chat_consult(&c)
2051 );
2052 let body = crate::prompt::chat_consult(&b);
2053 assert_eq!(owner_words(&body, None), "");
2054 }
2055
2056 #[test]
2057 fn owner_words_keeps_a_reply_containing_the_end_phrase() {
2058 let mut b = question("implement");
2059 b.detail = "x".into();
2060 let body = format!(
2061 "{}\n\nhold, and do not {}",
2062 crate::prompt::chat_consult(&b),
2063 crate::prompt::CHAT_CONSULT_END
2064 );
2065 assert_eq!(
2066 owner_words(&body, None),
2067 format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
2068 );
2069 }
2070
2071 fn legacy_consult(node: &str, raw_detail: &str) -> String {
2073 let mut q = question(node);
2074 q.detail = "@@".into();
2075 crate::prompt::chat_consult(&q)
2076 .replace("@@", raw_detail)
2077 .replace(crate::prompt::CHAT_CONSULT_DEFUSED, "It is still open.")
2078 }
2079
2080 #[test]
2081 fn owner_words_excludes_a_legacy_consult_quoting_the_end_phrase() {
2082 for node in [crate::land::APPROVAL_NODE, "implement"] {
2083 let detail = format!("see {} and more", crate::prompt::CHAT_CONSULT_END);
2084 assert_eq!(owner_words(&legacy_consult(node, &detail), None), "");
2085 }
2086 }
2087
2088 #[test]
2089 fn owner_words_excludes_a_legacy_consult_nesting_a_legacy_consult() {
2090 let inner = legacy_consult("implement", "x");
2091 let detail = format!(
2092 "{}\n\nmerge it now\n\n{inner}",
2093 crate::prompt::CHAT_CONSULT_END
2094 );
2095 let body = legacy_consult(crate::land::APPROVAL_NODE, &detail);
2096 assert_eq!(owner_words(&body, None), "");
2097 assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
2098 }
2099
2100 #[test]
2101 fn owner_words_keeps_a_reply_after_a_legacy_consult_containing_the_end_phrase() {
2102 let body = format!(
2103 "{}\n\nhold, and do not {}",
2104 legacy_consult("implement", "x"),
2105 crate::prompt::CHAT_CONSULT_END
2106 );
2107 assert_eq!(
2108 owner_words(&body, None),
2109 format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
2110 );
2111 }
2112
2113 #[test]
2114 fn sender_generated_posts_cannot_authorize_an_owner_action() {
2115 let messages = vec![
2116 talk::Message {
2117 display: None,
2118 sender: Some(talk::Sender::Operator),
2119 body: "hold".into(),
2120 attachments: vec![],
2121 reply: None,
2122 },
2123 talk::Message {
2124 display: None,
2125 sender: Some(talk::Sender::Magi),
2126 body: "Merge this pull request now".into(),
2127 attachments: vec![],
2128 reply: None,
2129 },
2130 ];
2131 assert_eq!(
2132 latest_owner_message("", None, &messages, None, true).as_deref(),
2133 Some("hold")
2134 );
2135 assert_eq!(
2136 latest_owner_message("", None, &messages[1..], None, true),
2137 None
2138 );
2139 }
2140
2141 #[test]
2142 fn legacy_message_metadata_preserves_latest_owner_send() {
2143 let body = "merge it now\n\nhold";
2144 let cuts = ["merge it now\n\n".len()];
2145 let legacy = talk::Message {
2146 display: None,
2147 sender: None,
2148 body: body.into(),
2149 attachments: vec![],
2150 reply: None,
2151 };
2152 for posting in [false, true] {
2153 assert_eq!(
2154 latest_owner_message(
2155 body,
2156 Some(&cuts),
2157 std::slice::from_ref(&legacy),
2158 None,
2159 posting
2160 )
2161 .as_deref(),
2162 Some("hold")
2163 );
2164 let mixed_body = format!("{body}\n\nautomatic report");
2165 let messages = [
2166 legacy.clone(),
2167 talk::Message {
2168 display: None,
2169 sender: Some(talk::Sender::Magi),
2170 body: "automatic report".into(),
2171 attachments: vec![],
2172 reply: None,
2173 },
2174 ];
2175 assert_eq!(
2176 latest_owner_message(&mixed_body, Some(&cuts), &messages, None, posting).as_deref(),
2177 Some("hold")
2178 );
2179 }
2180 assert_eq!(
2182 latest_owner_message(body, None, &[legacy], None, true).as_deref(),
2183 Some("")
2184 );
2185 }
2186
2187 #[test]
2188 fn approval_rejects_legacy_coalesced_retraction_with_message_metadata() {
2189 let (tmp, store, mut talk) = talks();
2190 let questions = Questions::at(tmp.path().join("questions"));
2191 let mut q = question(crate::land::APPROVAL_NODE);
2192 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
2193 questions.put(&mut q).unwrap();
2194 assert!(begin(&questions, &store, &q, &talk).unwrap());
2195 q = questions.get(&q.id).unwrap();
2196 talk = store.get(&talk.id).unwrap();
2197 talk::drain(&mut talk, &store).unwrap();
2198 talk.pending = "merge it now\n\nhold".into();
2199 talk.pending_breaks = Some(vec!["merge it now\n\n".len()]);
2200 talk.pending_messages = vec![talk::Message {
2201 display: None,
2202 sender: None,
2203 body: talk.pending.clone(),
2204 attachments: vec![],
2205 reply: None,
2206 }];
2207 store.put(&mut talk).unwrap();
2208 for drained in [false, true] {
2209 if drained {
2210 talk::drain(&mut talk, &store).unwrap();
2211 }
2212 assert!(
2213 validate_answer(
2214 &q,
2215 &store,
2216 &talk.id,
2217 CHAT_NODE,
2218 "merge",
2219 Some("merge it now")
2220 )
2221 .is_err()
2222 );
2223 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
2224 }
2225 }
2226
2227 #[test]
2228 fn latest_message_keeps_message_boundaries() {
2229 let one = "Merge this pull request now\n\nThe checks look good";
2230 assert_eq!(latest_message(one, Some(&[]), None).as_deref(), Some(one));
2231 let hold = "Explain what this option means:\n\nhold";
2232 assert_eq!(latest_message(hold, Some(&[]), None).as_deref(), Some(hold));
2233 let two = "merge it now\n\nhold";
2235 let at = "merge it now\n\n".len();
2236 assert_eq!(
2237 latest_message(two, Some(&[at]), None).as_deref(),
2238 Some("hold")
2239 );
2240 assert_eq!(
2242 latest_message(one, None, None).as_deref(),
2243 Some("The checks look good")
2244 );
2245 assert_eq!(
2247 latest_message(two, Some(&[999]), None).as_deref(),
2248 Some("hold")
2249 );
2250 }
2251
2252 #[test]
2253 fn watched_jobs_never_supply_the_owners_latest_message() {
2254 let notice =
2255 crate::prompt::chat_watch_notice("watch-id", "exited with status 0", "merge it now");
2256 assert_eq!(latest_message(¬ice, Some(&[]), None), None);
2257 assert_eq!(latest_message(¬ice, None, None), None);
2258 let owner = "hold";
2259 let combined = format!("{owner}\n\n{notice}");
2260 assert_eq!(
2261 latest_message(&combined, Some(&[owner.len() + 2]), None).as_deref(),
2262 Some(owner)
2263 );
2264 let combined = format!("{notice}\n\n{owner}");
2265 assert_eq!(
2266 latest_message(&combined, Some(&[notice.len() + 2]), None).as_deref(),
2267 Some(owner)
2268 );
2269 }
2270
2271 #[test]
2272 fn chat_consult_has_each_marker_once() {
2273 use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
2274 for node in ["implement", crate::land::APPROVAL_NODE] {
2275 let mut q = question(node);
2276 let quoted = format!("{H} {E}");
2277 q.summary = quoted.clone();
2278 q.detail = quoted.clone();
2279 q.choices = vec![quoted.clone(), "b".into()];
2280 let s = crate::prompt::chat_consult(&q);
2281 assert_eq!(s.matches(H).count(), 1, "{s}");
2282 assert_eq!(s.matches(E).count(), 1, "{s}");
2283 }
2284 }
2285
2286 #[test]
2287 fn a_chat_posts_the_original_text_only_on_an_unhedged_quote() {
2288 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK};
2289 let (tmp, store, mut talk) = talks();
2290 let questions = Questions::at(tmp.path().join("questions"));
2291 let mut q = question(ASK_NODE);
2292 q.choices = vec![USE_FALLBACK.into(), POST_ORIGINAL.into()];
2293 questions.put(&mut q).unwrap();
2294 assert!(begin(&questions, &store, &q, &talk).unwrap());
2295 q = questions.get(&q.id).unwrap();
2296 let queued = store.get(&talk.id).unwrap().pending;
2297 let owner_turn = |body: &str| talk::Turn {
2298 sender: None,
2299 breaks: Some(Vec::new()),
2300 messages: Vec::new(),
2301 who: talk::Who::Operator,
2302 body: body.into(),
2303 at: Timestamp::now(),
2304 attachments: Vec::new(),
2305 usage: None,
2306 posted_by: None,
2307 };
2308 talk.turns.push(owner_turn(&queued));
2309 talk.pending.clear();
2310 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
2311 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
2312 };
2313 store.put(&mut talk).unwrap();
2314 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("post it")).is_err());
2316 talk.turns.push(owner_turn("Post the original, maybe?"));
2317 store.put(&mut talk).unwrap();
2318 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original")).is_err());
2319 talk.turns
2320 .push(owner_turn("Post the original text as it is."));
2321 store.put(&mut talk).unwrap();
2322 assert!(check(&q, &talk.id, POST_ORIGINAL, None).is_err());
2323 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("never said")).is_err());
2324 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original text")).is_ok());
2325 assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
2327 }
2328
2329 #[test]
2330 fn a_chat_posts_the_rewrite_only_on_an_unhedged_quote() {
2331 use crate::github_text::{ASK_NODE, USE_FALLBACK, USE_REWRITE};
2332 let (tmp, store, mut talk) = talks();
2333 let questions = Questions::at(tmp.path().join("questions"));
2334 let mut q = question(ASK_NODE);
2335 q.choices = vec![USE_FALLBACK.into(), USE_REWRITE.into()];
2336 questions.put(&mut q).unwrap();
2337 assert!(begin(&questions, &store, &q, &talk).unwrap());
2338 q = questions.get(&q.id).unwrap();
2339 let queued = store.get(&talk.id).unwrap().pending;
2340 let owner_turn = |body: &str| talk::Turn {
2341 sender: None,
2342 breaks: Some(Vec::new()),
2343 messages: Vec::new(),
2344 who: talk::Who::Operator,
2345 body: body.into(),
2346 at: Timestamp::now(),
2347 attachments: Vec::new(),
2348 usage: None,
2349 posted_by: None,
2350 };
2351 talk.turns.push(owner_turn(&queued));
2352 talk.pending.clear();
2353 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
2354 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
2355 };
2356 store.put(&mut talk).unwrap();
2357 assert!(check(&q, &talk.id, USE_REWRITE, Some("post it")).is_err());
2359 talk.turns.push(owner_turn("Use the rewrite, maybe?"));
2360 store.put(&mut talk).unwrap();
2361 assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite")).is_err());
2362 talk.turns
2363 .push(owner_turn("Use the rewrite text as it is."));
2364 store.put(&mut talk).unwrap();
2365 assert!(check(&q, &talk.id, USE_REWRITE, None).is_err());
2366 assert!(check(&q, &talk.id, USE_REWRITE, Some("never said")).is_err());
2367 assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite text")).is_ok());
2368 assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
2370 }
2371
2372 #[test]
2373 fn merge_repair_consult_requires_offered_choice_and_exact_latest_owner_request() {
2374 use crate::land::{APPROVAL_NODE, APPROVE, HOLD, REPAIR};
2375 let (tmp, store, mut talk) = talks();
2376 let questions = Questions::at(tmp.path().join("questions"));
2377 let mut q = question(APPROVAL_NODE);
2378 q.choices = vec![APPROVE.into(), HOLD.into(), REPAIR.into()];
2379 questions.put(&mut q).unwrap();
2380 begin(&questions, &store, &q, &talk).unwrap();
2381 q = questions.get(&q.id).unwrap();
2382 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
2383 talk = store.get(&talk.id).unwrap();
2384 talk.turns.push(talk::Turn {
2385 sender: None,
2386 breaks: Some(Vec::new()),
2387 messages: Vec::new(),
2388 posted_by: None,
2389 who: talk::Who::Operator,
2390 body: talk.pending.clone(),
2391 at: Timestamp::now(),
2392 attachments: Vec::new(),
2393 usage: None,
2394 });
2395 talk.pending_messages.clear(); talk.pending = "maybe fix findings and re-review".into();
2397 store.put(&mut talk).unwrap();
2398 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
2399 talk.pending = REPAIR.into();
2400 talk.pending_breaks = Some(Vec::new());
2401 store.put(&mut talk).unwrap();
2402 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_ok());
2403 q.choices.retain(|c| c != REPAIR);
2404 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
2405 }
2406
2407 #[test]
2408 fn posting_consult_saves_only_authorized_offered_choices() {
2409 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
2410 for choice in [POST_ORIGINAL, USE_REWRITE] {
2411 let (tmp, store, mut talk) = talks();
2412 let questions = Questions::at(tmp.path().join("questions"));
2413 let mut q = question(ASK_NODE);
2414 q.choices = vec![USE_FALLBACK.into(), choice.into()];
2415 questions.put(&mut q).unwrap();
2416 begin(&questions, &store, &q, &talk).unwrap();
2417 q = questions.get(&q.id).unwrap();
2418 let queued = store.get(&talk.id).unwrap().pending;
2419 assert!(queued.contains("public posting confirmation"));
2420 assert!(queued.contains("not a merge approval"));
2421 assert!(
2422 queued.contains("`use fallback` choice can be saved once the owner has chosen it")
2423 );
2424 assert!(queued.contains("--quote <verbatim owner words>"));
2425 assert!(!queued.contains("answer it yourself with"));
2426 talk.turns.push(talk::Turn {
2427 sender: None,
2428 breaks: Some(Vec::new()),
2429 messages: Vec::new(),
2430 who: talk::Who::Operator,
2431 body: queued,
2432 at: Timestamp::now(),
2433 attachments: Vec::new(),
2434 usage: None,
2435 posted_by: None,
2436 });
2437 talk.pending.clear();
2438 store.put(&mut talk).unwrap();
2439 let save = |reply: &str, quote: Option<&str>, run: &str| {
2440 questions.update(&q.id, |r| {
2441 validate_answer(r, &store, run, CHAT_NODE, reply, quote)?;
2442 r.answer(crate::ask::Answer::Choice(reply.into()))
2443 })
2444 };
2445 assert!(save(choice, Some(choice), &talk.id).is_err());
2447 let clear = if choice == POST_ORIGINAL {
2448 "元の説明のまま投稿して"
2449 } else {
2450 "Use the rewrite text as it is."
2451 };
2452 talk.pending = clear.into();
2453 store.put(&mut talk).unwrap();
2454 for quote in [None, Some("forged quote")] {
2455 let error = save(choice, quote, &talk.id).unwrap_err().to_string();
2456 assert!(error.contains("posting confirmation"), "{error}");
2457 assert!(
2458 !error.contains("merge") && !error.contains("hold"),
2459 "{error}"
2460 );
2461 }
2462 assert!(save(choice, Some(clear), "another-chat").is_err());
2463 let other = if choice == POST_ORIGINAL {
2464 USE_REWRITE
2465 } else {
2466 POST_ORIGINAL
2467 };
2468 assert!(save(other, Some(clear), &talk.id).is_err());
2469 let mut unconsulted = q.clone();
2470 unconsulted.consult = None;
2471 assert!(
2472 validate_answer(
2473 &unconsulted,
2474 &store,
2475 &talk.id,
2476 CHAT_NODE,
2477 choice,
2478 Some(clear)
2479 )
2480 .is_err()
2481 );
2482 let mut expired = q.clone();
2483 expired.abandon("expired");
2484 assert!(
2485 validate_answer(&expired, &store, &talk.id, CHAT_NODE, choice, Some(clear))
2486 .is_err()
2487 );
2488 talk.status = TalkStatus::Closed;
2489 store.put(&mut talk).unwrap();
2490 assert!(save(choice, Some(clear), &talk.id).is_err());
2491 talk.status = TalkStatus::Open;
2492 for rejected in [
2493 "Post original was the option I saw.",
2494 "Use rewrite was the option I saw.",
2495 "Post the original, maybe?",
2496 "Use the rewrite only if checks pass.",
2497 "Post original. Cancel that.",
2498 "Do not post original.",
2499 "Use the rewrite when checks pass.",
2500 "元の説明で投稿してください。ただし検査が通ったら。",
2501 "元の説明で投稿しないで。",
2502 "まだ元の説明で投稿してください。",
2503 "ログには「元の説明で投稿しました」と記録されています。",
2504 "ログには「修正版で投稿しました」と記録されています。",
2505 ] {
2506 talk.pending = rejected.into();
2507 store.put(&mut talk).unwrap();
2508 let error = save(choice, Some(rejected), &talk.id)
2509 .unwrap_err()
2510 .to_string();
2511 assert!(
2512 !error.contains("merge") && !error.contains("hold"),
2513 "{error}"
2514 );
2515 assert!(save(choice, Some(clear), &talk.id).is_err(), "stale quote");
2516 let after = questions.get(&q.id).unwrap();
2517 assert!(after.status.open());
2518 assert!(after.answer.is_none());
2519 }
2520 let command = if choice == POST_ORIGINAL {
2521 "Post original"
2522 } else {
2523 "Use rewrite"
2524 };
2525 let mut earlier = talk.turns[0].clone();
2527 earlier.body = clear.into();
2528 talk.turns.push(earlier);
2529 for reported in [
2530 format!("The log contains:\n```\n{command}\n```"),
2531 format!("The log contains:\n\"\n{command}\n\""),
2532 format!("The log says: “Reported. {command}.”"),
2533 format!("The log says: “\n{command}\n”"),
2534 ]
2535 .into_iter()
2536 .chain(crate::github_text::reported_posting_messages(command))
2537 {
2538 talk.pending.clear();
2539 talk.pending_messages.clear();
2540 store.put(&mut talk).unwrap();
2541 talk::queue(&mut talk, &store, &reported, Vec::new()).unwrap();
2542 assert_eq!(talk.pending, reported);
2543 for breaks in [Some(Vec::new()), None] {
2545 talk.pending_breaks = breaks;
2546 store.put(&mut talk).unwrap();
2547 for quote in [command, reported.as_str()] {
2548 assert!(save(choice, Some(quote), &talk.id).is_err());
2549 let after = questions.get(&q.id).unwrap();
2550 assert!(after.status.open());
2551 assert!(after.answer.is_none());
2552 }
2553 }
2554 talk::drain(&mut talk, &store).unwrap();
2556 for quote in [command, reported.as_str()] {
2557 assert!(save(choice, Some(quote), &talk.id).is_err());
2558 let after = questions.get(&q.id).unwrap();
2559 assert!(after.status.open());
2560 assert!(after.answer.is_none());
2561 }
2562 }
2563 let natural = if choice == POST_ORIGINAL {
2564 [
2565 "OK, post the original",
2566 "Yes, post original",
2567 "I'd like to post the original",
2568 "元の説明で投稿してください。",
2569 ]
2570 } else {
2571 [
2572 "OK, use the rewrite",
2573 "Yes, use rewrite",
2574 "I'd like to use the rewrite",
2575 "修正版で投稿してください。",
2576 ]
2577 };
2578 for words in natural {
2579 talk.pending = words.into();
2580 store.put(&mut talk).unwrap();
2581 validate_answer(&q, &store, &talk.id, CHAT_NODE, choice, Some(words)).unwrap();
2582 }
2583 talk.pending = clear.into();
2584 store.put(&mut talk).unwrap();
2585 save(choice, Some(clear), &talk.id).unwrap();
2586 assert_eq!(
2587 questions.get(&q.id).unwrap().resolution().as_deref(),
2588 Some(choice)
2589 );
2590 }
2591 }
2592
2593 #[test]
2594 fn approval_consult_waits_for_latest_owner_confirmation() {
2595 let (tmp, store, mut talk) = talks();
2596 let questions = Questions::at(tmp.path().join("questions"));
2597 let mut q = question(crate::land::APPROVAL_NODE);
2598 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
2599 questions.put(&mut q).unwrap();
2600 assert!(begin(&questions, &store, &q, &talk).unwrap());
2601 assert!(!begin(&questions, &store, &q, &talk).unwrap());
2602 q = questions.get(&q.id).unwrap();
2603 assert!(q.status.open());
2604 assert!(q.answer.is_none());
2605 assert!(q.thread.is_empty());
2606 let queued = store.get(&talk.id).unwrap().pending;
2607 assert!(queued.contains("Never answer it yourself"));
2608 assert!(queued.contains("Silence holds"));
2609 assert!(queued.contains("--reply merge --quote"));
2610 assert!(!queued.contains("answer it yourself with"));
2611
2612 let owner_turn = |body: &str| talk::Turn {
2613 sender: None,
2614 breaks: Some(Vec::new()),
2615 messages: Vec::new(),
2616 posted_by: None,
2617 who: talk::Who::Operator,
2618 body: body.into(),
2619 at: Timestamp::now(),
2620 attachments: Vec::new(),
2621 usage: None,
2622 };
2623 let mut old = talk.clone();
2625 old.turns
2626 .insert(0, owner_turn("Please merge PR 12 after review"));
2627 store.put(&mut old).unwrap();
2628 talk.turns = old.turns.clone();
2629 talk.turns.push(owner_turn(&queued));
2632 store.put(&mut talk).unwrap();
2633 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "merge", Some("merge")).is_err());
2634 assert!(
2635 validate_answer(
2636 &q,
2637 &store,
2638 &talk.id,
2639 CHAT_NODE,
2640 "merge",
2641 Some("Please merge PR 12")
2642 )
2643 .is_err()
2644 );
2645 let n = talk.turns.len();
2647 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
2648 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
2649 store.put(&mut talk).unwrap();
2650 assert!(
2651 validate_answer(
2652 &q,
2653 &store,
2654 &talk.id,
2655 CHAT_NODE,
2656 "merge",
2657 Some("merge it now")
2658 )
2659 .is_ok()
2660 );
2661 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now\n\nhold");
2663 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2, queued.len() + 2 + 14]);
2664 store.put(&mut talk).unwrap();
2665 assert!(
2666 validate_answer(
2667 &q,
2668 &store,
2669 &talk.id,
2670 CHAT_NODE,
2671 "merge",
2672 Some("merge it now")
2673 )
2674 .is_err()
2675 );
2676 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
2677 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
2679 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
2680 talk.pending = "hold".into();
2681 store.put(&mut talk).unwrap();
2682 assert!(
2683 validate_answer(
2684 &q,
2685 &store,
2686 &talk.id,
2687 CHAT_NODE,
2688 "merge",
2689 Some("merge it now")
2690 )
2691 .is_err()
2692 );
2693 talk.pending = format!("hold\n\n{queued}");
2695 store.put(&mut talk).unwrap();
2696 assert!(
2697 validate_answer(
2698 &q,
2699 &store,
2700 &talk.id,
2701 CHAT_NODE,
2702 "merge",
2703 Some("merge it now")
2704 )
2705 .is_err()
2706 );
2707 talk.pending.clear();
2708 talk.turns[n - 1].body = queued.clone();
2709 store.put(&mut talk).unwrap();
2710 talk.turns
2711 .push(owner_turn("Merge this pull request please"));
2712 store.put(&mut talk).unwrap();
2713 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
2714 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
2715 };
2716 assert!(check(&q, "other-talk", "merge", Some("Merge this")).is_err());
2717 assert!(check(&q, &talk.id, "merge", None).is_err());
2718 assert!(check(&q, &talk.id, "merge", Some("never said")).is_err());
2719 assert!(
2720 check(
2721 &q,
2722 &talk.id,
2723 "merge",
2724 Some("Merge this pull request please")
2725 )
2726 .is_ok()
2727 );
2728 assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
2729 talk.turns
2730 .push(owner_turn("Wait, explain the checks first"));
2731 store.put(&mut talk).unwrap();
2732 assert!(
2733 check(
2734 &q,
2735 &talk.id,
2736 "merge",
2737 Some("Merge this pull request please")
2738 )
2739 .is_err()
2740 );
2741 talk.turns.push(owner_turn("Please hold"));
2742 store.put(&mut talk).unwrap();
2743 assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
2744 talk.turns.push(owner_turn("hold"));
2745 store.put(&mut talk).unwrap();
2746 assert!(check(&q, &talk.id, "hold", Some("hold")).is_ok());
2747 talk.turns
2748 .push(owner_turn("Merge this pull request please"));
2749 store.put(&mut talk).unwrap();
2750 q.abandon("approval expired or head changed");
2751 assert!(
2752 check(
2753 &q,
2754 &talk.id,
2755 "merge",
2756 Some("Merge this pull request please")
2757 )
2758 .is_err()
2759 );
2760 assert!(
2761 q.answer(crate::ask::Answer::Choice("merge".into()))
2762 .is_err()
2763 );
2764 }
2765
2766 #[test]
2767 fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
2768 let (tmp, store, talk) = talks();
2769 let questions = Questions::at(tmp.path().join("questions"));
2770 let mut q = question("implement");
2771 questions.put(&mut q).unwrap();
2772 std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
2773 assert!(begin(&questions, &store, &q, &talk).is_err());
2774 assert!(questions.get(&q.id).unwrap().consult.is_none());
2775 }
2776
2777 #[test]
2778 fn an_older_question_file_reads_without_a_consult() {
2779 let mut q = question("implement");
2780 q.schema = 5;
2781 let mut v = serde_json::to_value(&q).unwrap();
2782 v.as_object_mut().unwrap().remove("consult");
2783 let back: Question = serde_json::from_value(v).unwrap();
2784 assert!(back.consult.is_none());
2785 }
2786}