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 let task = crate::daemon::task_of_question(tasks, q)?;
40 let chat = chat_talk_of(tasks, task)?;
41 talks.iter().find(|talk| talk.id == chat).cloned()
42}
43
44pub fn associated_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
47 associated_talk_with_watches(tasks, talks, q, &[])
48}
49
50#[derive(Clone, Copy)]
52pub(crate) enum QuestionTaskRef<'a> {
53 Task(&'a str),
54 Run(&'a str),
55}
56
57pub(crate) fn question_task_refs<'a>(
58 q: &'a Question,
59 watches: &'a [crate::release_watch::WatchState],
60) -> Vec<QuestionTaskRef<'a>> {
61 let names_task = q.run_names_task()
64 || (q.node == crate::reconcile::NODE && q.seat == crate::reconcile::SEAT);
65 let mut refs = vec![if names_task {
66 QuestionTaskRef::Task(&q.run)
67 } else {
68 QuestionTaskRef::Run(&q.run)
69 }];
70 let mut matches = watches
71 .iter()
72 .filter(|w| w.question.as_deref() == Some(&q.id));
73 if let Some(watch) = matches.next()
75 && matches.next().is_none()
76 {
77 refs.extend(watch.run.as_deref().map(QuestionTaskRef::Run));
78 refs.extend(watch.held_task.as_deref().map(QuestionTaskRef::Task));
79 }
80 refs
81}
82
83pub(crate) fn associated_talk_with_watches(
86 tasks: &[Task],
87 talks: &[Talk],
88 q: &Question,
89 watches: &[crate::release_watch::WatchState],
90) -> Option<Talk> {
91 let mut owner: Option<&Task> = None;
92 for reference in question_task_refs(q, watches) {
93 for task in tasks.iter().filter(|task| match reference {
94 QuestionTaskRef::Task(id) => task.id == id,
95 QuestionTaskRef::Run(id) => task.runs.iter().any(|run| run == id),
96 }) {
97 if owner.is_some_and(|previous| previous.id != task.id) {
98 return None;
99 }
100 owner = Some(task);
101 }
102 }
103 let chat = chat_talk_of(tasks, owner?)?;
104 talks.iter().find(|talk| talk.id == chat).cloned()
105}
106
107pub fn chat_talk_of(tasks: &[Task], start: &Task) -> Option<String> {
119 let mut seen = std::collections::HashSet::new();
120 let mut cur = start;
121 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
122 if !seen.insert(cur.id.as_str()) {
123 return None;
124 }
125 if let Some(id) = cur.chat_talk() {
126 return Some(id.to_owned());
127 }
128 let f = cur.followup.as_ref()?;
129 cur = match &f.origin_task {
130 Some(id) => tasks.iter().find(|t| &t.id == id)?,
131 None => tasks.iter().find(|t| t.runs.contains(&f.run))?,
132 };
133 }
134 None
135}
136
137pub fn pending_consults(questions: &Questions, talk_id: &str) -> bool {
145 questions.list().iter().any(|q| active_consult(q, talk_id))
146}
147
148pub fn active_consult(q: &Question, talk_id: &str) -> bool {
150 q.routed_task.is_none()
151 && q.status.open()
152 && q.consult_outcome.is_none()
153 && q.consult.as_ref().is_some_and(|c| c.talk == talk_id)
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
158pub struct ConsultOutcome {
159 pub text: String,
161 pub instruction: String,
163 pub talk: String,
165 pub turn: usize,
167 pub message: usize,
169 pub at: Timestamp,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176#[serde(rename_all = "snake_case", deny_unknown_fields)]
177pub enum TaskOperation {
178 Add {
180 instruction: String,
182 solo: bool,
184 },
185 Release {
187 task: String,
189 },
190 Hold {
192 task: String,
194 reason: String,
196 },
197 Done {
199 task: String,
201 },
202 Priority {
204 task: String,
206 priority: i32,
208 },
209}
210
211impl TaskOperation {
212 pub fn instruction(&self) -> String {
214 format!(
215 "task-operation: {}",
216 serde_json::to_string(self).expect("task operation")
217 )
218 }
219}
220
221fn delivered_instruction<'a>(
222 q: &Question,
223 talk: &'a Talk,
224 quote: Option<&str>,
225) -> Result<(usize, usize, &'a str)> {
226 if !active_consult(q, &talk.id) {
227 bail!("no active consultation in this conversation");
228 }
229 let (turn, message, words) = current_instruction(talk)
230 .context("no current persisted owner instruction; drain newer messages first")?;
231 if quote != Some(words) {
232 bail!("--quote must equal the entire latest persisted owner message");
233 }
234 let delivery = talk
235 .turns
236 .iter()
237 .position(|t| {
238 t.who == talk::Who::Operator
239 && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
240 && t.body.contains(&q.id)
241 })
242 .context("question has not been delivered")?;
243 let consult = q.consult.as_ref().expect("active consult");
244 if turn < delivery
245 || talk.turns[turn].at < consult.at
246 || (turn == delivery
247 && !talk.turns[turn].messages[..message].iter().any(|m| {
248 m.body.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.body.contains(&q.id)
249 }))
250 {
251 bail!("owner instruction must follow consultation delivery");
252 }
253 Ok((turn, message, words))
254}
255
256pub fn authorize_task_operation(
258 questions: &Questions,
259 talk: &Talk,
260 operation: &TaskOperation,
261 quote: Option<&str>,
262) -> Result<()> {
263 let expected = operation.instruction();
264 if !questions.list().iter().any(|q| {
265 delivered_instruction(q, talk, quote).is_ok_and(|(_, _, words)| {
266 words
267 .strip_prefix("task-operation: ")
268 .and_then(|json| serde_json::from_str::<TaskOperation>(json).ok())
269 .as_ref()
270 == Some(operation)
271 })
272 }) {
273 bail!(
274 "task operation requires individual post-delivery owner evidence and --quote of the entire latest send; required instruction: {expected}"
275 );
276 }
277 Ok(())
278}
279
280pub fn record_other(q: &mut Question, talk: &Talk, text: &str, quote: Option<&str>) -> Result<()> {
283 if text.trim().is_empty() || q.free_text() || q.choices.iter().any(|c| c == text) {
284 bail!("--reply-other requires a nonempty outcome outside the offered choices");
285 }
286 let (turn, message, words) = delivered_instruction(q, talk, quote)?;
287 if words != text {
290 bail!("--reply-other must equal the owner's entire latest message");
291 }
292 q.schema = crate::ask::SCHEMA;
293 q.consult_outcome = Some(ConsultOutcome {
294 text: text.to_owned(),
295 instruction: words.to_owned(),
296 talk: talk.id.clone(),
297 turn,
298 message,
299 at: Timestamp::now(),
300 });
301 Ok(())
302}
303
304pub fn begin(questions: &Questions, talks: &Talks, q: &Question, talk: &Talk) -> Result<bool> {
310 let (q, fresh) = questions.update(&q.id, |r| {
311 anyhow::ensure!(
312 r.routed_task.is_none(),
313 "task decision is delivered by its own chat turn"
314 );
315 if !r.status.open() {
316 bail!("question {} is already {}", r.short(), r.status.as_str());
317 }
318 if r.consult.is_some() {
319 return Ok(false);
320 }
321 r.consult = Some(ChatConsult {
322 talk: talk.id.clone(),
323 at: Timestamp::now(),
324 });
325 Ok(true)
326 })?;
327 if !fresh {
328 return Ok(false);
329 }
330 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
333 let dispatch = loop {
334 let mut busy = false;
335 let updated = questions.update(&q.id, |r| {
336 if !r.status.open() || r.consult != q.consult {
337 return Ok(false);
338 }
339 if talk::try_queue_consult(
340 talks,
341 &talk.id,
342 &crate::prompt::chat_consult(r),
343 Some(crate::prompt::chat_consult_display(r)),
344 )?
345 .is_none()
346 {
347 busy = true;
348 bail!("the conversation store is locked by another process");
349 }
350 Ok(true)
351 });
352 if busy && std::time::Instant::now() < deadline {
353 std::thread::sleep(std::time::Duration::from_millis(10));
354 continue;
355 }
356 break updated;
357 };
358 if let Err(e) = dispatch {
359 let _ = questions.update(&q.id, |r| {
360 if r.status.open() && r.consult == q.consult {
361 r.consult = None;
362 }
363 Ok(())
364 });
365 return Err(e).context("queue the question into the chat");
366 }
367 Ok(dispatch?.1)
368}
369
370pub fn refuse_operator_held(q: &Question, node: &str, tasks: &[crate::queue::Task]) -> Result<()> {
377 if node != CHAT_NODE || QuestionKind::of(q) != QuestionKind::Merge {
378 return Ok(());
379 }
380 let release_run = if q.run.is_empty() && q.node == crate::bump::NOTICE_NODE {
381 crate::run::try_home()
382 .and_then(|home| crate::release_watch::state_for_question(&home, &q.id))
383 .and_then(|state| state.run)
384 } else {
385 None
386 };
387 let run = release_run.as_deref().unwrap_or(&q.run);
388 if let Some(task) = tasks
389 .iter()
390 .find(|t| t.id == run || t.runs.iter().any(|id| id == run))
391 && task.operator_held()
392 {
393 bail!(
394 "task {} is held by the operator; an answer cannot settle it",
395 task.short()
396 );
397 }
398 Ok(())
399}
400
401#[derive(Clone, Copy, PartialEq, Eq)]
403pub enum QuestionKind {
404 Ordinary,
406 Merge,
408 Posting,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
414pub struct AnswerAuthority {
415 pub talk: String,
417 pub turn: usize,
419 pub message: usize,
421 pub instruction: String,
423 pub instruction_fingerprint: String,
425 pub recommendation_turn: Option<usize>,
427 pub recommendation_fingerprint: Option<String>,
429 pub question: String,
431 pub choice: String,
433 #[serde(default)]
435 pub question_fingerprint: String,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub recovery_interpretation: Option<String>,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
444pub struct RecommendationManifest {
445 pub body: String,
447 pub questions: std::collections::BTreeMap<String, String>,
449 #[serde(default)]
452 pub recovery: std::collections::BTreeMap<String, RecoveryRecommendation>,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
457pub struct RecoveryRecommendation {
458 pub choice: String,
460 pub action: Option<crate::ask::ChoiceAction>,
462 pub run: String,
464}
465
466pub fn question_fingerprint(q: &Question) -> String {
468 let facts = serde_json::json!({
469 "id": q.id, "run": q.run, "node": q.node, "seat": q.seat,
470 "summary": q.summary, "detail": q.detail, "choices": q.choices,
471 "actions": q.actions, "merge_target": q.merge_target,
472 "posting_comparison": q.posting_comparison,
473 "decision_context": q.decision_context, "triage_generation": q.triage_generation,
474 });
475 crate::github_text::fingerprint("", &facts.to_string())
476}
477
478pub fn owner_title<'a>(q: &Question, instruction: &'a str) -> Option<&'a str> {
481 if instruction.starts_with(" ") || instruction.starts_with('\t') {
482 return None;
483 }
484 let (command, title) = instruction.trim().split_once('\n')?;
485 let expected = format!(
486 "{}: {} {}",
487 q.id,
488 crate::github_text::USE_MY_TEXT,
489 question_fingerprint(q)
490 );
491 (command == expected && !title.trim().is_empty() && !title.contains('\n'))
492 .then_some(title.trim())
493}
494
495pub fn recommendation_manifest(body: &str, questions: &Questions) -> RecommendationManifest {
497 let snapshot = questions.list();
498 RecommendationManifest {
499 body: crate::github_text::fingerprint("", body),
500 recovery: snapshot
501 .iter()
502 .filter_map(|q| {
503 let choice = recommended_choice(body, q, questions)?;
504 (q.status.open() && contextual_recovery(q, choice)).then(|| {
505 (
506 q.id.clone(),
507 RecoveryRecommendation {
508 choice: choice.into(),
509 action: q.actions.get(choice).cloned(),
510 run: q.run.clone(),
511 },
512 )
513 })
514 })
515 .collect(),
516 questions: snapshot
517 .iter()
518 .filter(|q| q.status.open() && recommended_choice(body, q, questions).is_some())
519 .map(|q| (q.id.clone(), question_fingerprint(q)))
520 .collect(),
521 }
522}
523
524pub fn authorize_answer(
529 q: &Question,
530 talks: &Talks,
531 questions: &Questions,
532 run: &str,
533 node: &str,
534 reply: &str,
535 quote: Option<&str>,
536) -> Result<Option<AnswerAuthority>> {
537 if node != CHAT_NODE {
538 return Ok(None);
539 }
540 if q.routed_task.is_some() && q.routed_chat.is_some() {
542 validate_routed_answer(q, talks, run, reply)?;
543 return Ok(None);
544 }
545 if reply == crate::land::HOLD && q.actions.contains_key(reply) {
546 bail!("hold cannot execute a question action");
547 }
548 let talk = talks.get(run)?;
549 if reply != crate::land::HOLD
550 && current_instruction(&talk).is_some_and(|(_, _, words)| {
551 words.starts_with(" ")
552 || words.starts_with('\t')
553 || words.trim_start().starts_with("```")
554 || words.trim_start().starts_with('>')
555 })
556 {
557 bail!("quoted or code-block owner text is not an explicit instruction for this action");
558 }
559 if let Some(evidence) = choice_authority(q, questions, &talk, reply, quote) {
560 validate_effect(
561 q,
562 reply,
563 &evidence.instruction,
564 quote.unwrap_or_default(),
565 true,
566 )?;
567 if reply != crate::land::HOLD
570 && let Some(consult) = &q.consult
571 && consult.talk != run
572 && talks.turn_held(&consult.talk)
573 {
574 bail!("original consultation is active; wait for its turn to finish before answering");
575 }
576 return Ok(Some(evidence));
577 }
578 if current_instruction(&talk).is_some_and(|(_, _, words)| batch_instruction(words)) {
579 validate_effect(
580 q,
581 reply,
582 current_instruction(&talk).unwrap().2,
583 quote.unwrap_or_default(),
584 true,
585 )?;
586 bail!(
587 "batch authorization requires a current, unambiguous recommendation for this exact offered choice and unchanged question target/content"
588 );
589 }
590 if q.consult.as_ref().is_some_and(|c| c.talk == run) {
593 validate_answer(q, talks, run, node, reply, quote)?;
594 if QuestionKind::of(q) == QuestionKind::Ordinary && consent(q, reply) != Consent::Delegable
595 {
596 let (_, _, instruction) = current_instruction(&talk)
597 .context("this action requires a current explicit owner instruction")?;
598 validate_effect(q, reply, instruction, quote.unwrap_or_default(), false)?;
599 }
600 return Ok(None);
601 }
602 if let Some((_, _, instruction)) = current_instruction(&talk) {
603 validate_effect(q, reply, instruction, quote.unwrap_or_default(), true)?;
604 }
605 bail!("{}", diagnose_authorization_failure(q, &talk, reply, quote))
606}
607
608fn diagnose_authorization_failure(
610 q: &Question,
611 talk: &Talk,
612 reply: &str,
613 quote: Option<&str>,
614) -> &'static str {
615 if !q.free_text() && !q.choices.iter().any(|choice| choice == reply) {
616 return "alias mismatch: resolve an offered label or supported action alias with `magi answer --list`";
617 }
618 let queued_owner = (!talk.pending.is_empty() || !talk.pending_messages.is_empty())
619 && (talk.pending_messages.is_empty()
620 || talk
621 .pending_messages
622 .iter()
623 .any(|send| send.sender != Some(talk::Sender::Magi)));
624 if queued_owner {
625 return "stale persisted evidence: a queued owner send may retract consent; wait for it to be drained and read it again";
626 }
627 let Some((turn, _, instruction)) = current_instruction(talk) else {
628 return "missing context: no current persisted owner send; read the conversation again";
629 };
630 if quote.is_some_and(|quote| quote != instruction) {
631 return "stale persisted evidence: --quote must preserve the entire latest owner send; read it again";
632 }
633 if talk.turns[turn].at < q.asked_at {
634 return "stale persisted evidence: the latest owner send predates this question; obtain a current decision";
635 }
636 if contextual_recovery(q, reply) {
637 return "insufficient consent: bind an explicit question/action reply, or interpret approval of a single saved recommendation with --recovery-consent <entire latest owner send>; clarify only if the owner's intent is ambiguous";
638 }
639 "insufficient consent: 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"
640}
641
642fn contextual_recovery(q: &Question, reply: &str) -> bool {
645 QuestionKind::of(q) == QuestionKind::Ordinary
646 && consent(q, reply) == Consent::Delegable
647 && (matches!(q.actions.get(reply), Some(crate::ask::ChoiceAction::Resume { run }) if !run.is_empty())
648 || matches!(q.actions.get(reply), Some(crate::ask::ChoiceAction::Hold))
649 || crate::triage::contextual_keep_held(q, reply))
650}
651
652pub fn authorize_recovery(
656 q: &Question,
657 talks: &Talks,
658 questions: &Questions,
659 seat: (&str, &str),
660 reply: &str,
661 instruction: &str,
662) -> Result<Option<AnswerAuthority>> {
663 let (run, node) = seat;
664 if node != CHAT_NODE || !contextual_recovery(q, reply) {
665 bail!(
666 "insufficient consent: contextual interpretation is limited to structured internal resume/keep-held actions; use this decision's explicit consent workflow"
667 );
668 }
669 let talk = talks.get(run)?;
670 let evidence = recovery_authority(q, questions, &talk, reply, instruction)?;
671 if q.consult
672 .as_ref()
673 .is_some_and(|c| c.talk != run && talks.turn_held(&c.talk))
674 {
675 bail!("original consultation is active; wait for its turn to finish before answering");
676 }
677 Ok(Some(evidence))
678}
679
680fn recovery_authority(
681 q: &Question,
682 questions: &Questions,
683 talk: &Talk,
684 reply: &str,
685 instruction: &str,
686) -> Result<AnswerAuthority> {
687 let (turn, message, latest) = current_instruction(talk)
688 .context("missing context: wait for the current owner send to be persisted and drained")?;
689 if instruction != latest {
690 bail!(
691 "stale persisted evidence: the complete latest owner send differs; read the conversation again, including any retraction"
692 );
693 }
694 let owner_sends = talk.turns[turn]
695 .messages
696 .iter()
697 .filter(|send| talk::sender(&talk.turns[turn], Some(send)) != talk::Sender::Magi)
698 .count();
699 if owner_sends != 1
700 || latest.trim().is_empty()
701 || latest.starts_with(" ")
702 || latest.starts_with('\t')
703 || latest.trim_start().starts_with(['>', '`'])
704 {
705 bail!("insufficient consent: quoted text or multiple owner sends require clarification");
706 }
707 let index = talk.turns[..turn]
708 .iter()
709 .rposition(|t| talk::sender(t, None) != talk::Sender::Magi)
710 .context("missing context: present one explicit recovery recommendation first")?;
711 let recommendation = &talk.turns[index];
712 let manifest = talk.recommendations.get(&index).context(
713 "missing context: no persisted recommendation binding; present the recommendation now",
714 )?;
715 if recommendation.who != talk::Who::Agent
716 || talk::sender(recommendation, None) != talk::Sender::Persona
717 || recommendation.posted_by.is_some()
718 || manifest.body != crate::github_text::fingerprint("", &recommendation.body)
719 || manifest.questions.get(&q.id) != Some(&question_fingerprint(q))
720 || recommendation.at < q.asked_at
721 || talk.turns[turn].at < recommendation.at
722 || !q.status.open()
723 {
724 bail!(
725 "stale persisted evidence: recommendation or question target changed; present the current recommendation again"
726 );
727 }
728 if manifest.questions.len() != 1 {
729 bail!(
730 "ambiguous recovery context: multiple recommended decisions; clarify which question and action the owner intends"
731 );
732 }
733 let binding = manifest.recovery.get(&q.id)
734 .context("missing context: no structured recovery binding was saved; present a current recommendation")?;
735 if binding.run != q.run || binding.action.as_ref() != q.actions.get(&binding.choice) {
736 bail!("stale persisted evidence: the recommended recovery target changed");
737 }
738 if binding.choice != reply
739 || recommended_choice(&recommendation.body, q, questions) != Some(reply)
740 {
741 bail!("alias mismatch: the selected action differs from the persisted recommendation");
742 }
743 Ok(AnswerAuthority {
744 talk: talk.id.clone(),
745 turn,
746 message,
747 instruction: latest.into(),
748 instruction_fingerprint: crate::github_text::fingerprint("", latest),
749 recommendation_turn: Some(index),
750 recommendation_fingerprint: Some(manifest.body.clone()),
751 question: q.id.clone(),
752 choice: reply.into(),
753 question_fingerprint: question_fingerprint(q),
754 recovery_interpretation: Some(
755 "owner approved the single current recovery recommendation".into(),
756 ),
757 })
758}
759
760pub fn authorized_answers(questions: &Questions, talk: &Talk) -> bool {
763 let quote = current_instruction(talk).map(|(_, _, body)| body);
764 questions.list().iter().any(|q| {
765 if q.free_text() {
766 return quote
767 .and_then(|s| s.split_once(':'))
768 .is_some_and(|(_, reply)| {
769 choice_authority(q, questions, talk, reply.trim(), quote).is_some()
770 });
771 }
772 q.choices.iter().any(|choice| {
773 if contextual_recovery(q, choice)
774 && quote.is_some_and(|words| {
775 recovery_authority(q, questions, talk, choice, words).is_ok()
776 })
777 {
778 return true;
779 }
780 choice_authority(q, questions, talk, choice, quote).is_some_and(|e| {
781 validate_effect(q, choice, &e.instruction, quote.unwrap_or_default(), true).is_ok()
782 })
783 })
784 })
785}
786
787pub fn answer_write_roots(
793 questions: &Questions,
794 talks: &Talks,
795 talk: &Talk,
796) -> Vec<std::path::PathBuf> {
797 if !pending_consults(questions, &talk.id) && !authorized_answers(questions, talk) {
798 return Vec::new();
799 }
800 let Some(home) = questions.root().parent() else {
801 return Vec::new();
802 };
803 let quote = current_instruction(talk).map(|(_, _, words)| words);
804 let mut roots = std::collections::BTreeSet::from([
805 questions.root().to_path_buf(),
806 talks.answer_lock_root(),
807 ]);
808 if pending_consults(questions, &talk.id) {
809 roots.insert(home.join("queue"));
810 if let Some(words) = quote
811 && let Some(json) = words.strip_prefix("task-operation: ")
812 && let Ok(op @ TaskOperation::Done { .. }) = serde_json::from_str::<TaskOperation>(json)
813 && authorize_task_operation(questions, talk, &op, quote).is_ok()
814 && let TaskOperation::Done { task } = op
815 && let Ok(task) = crate::queue::Queue::at(home.join("queue")).get(&task)
816 {
817 for run in task.runs.iter().filter(|id| crate::run::is_run_id(id)) {
818 let path = home.join("runs").join(run);
819 if path.is_dir() {
820 roots.insert(path);
821 }
822 }
823 }
824 }
825 for q in questions.list() {
826 if q.status.open()
827 && q.node == crate::land::APPROVAL_NODE
828 && q.choices
829 .iter()
830 .any(|choice| choice == crate::merge_decision::DISMISS)
831 && crate::run::is_run_id(&q.run)
832 && authorize_answer(
833 &q,
834 talks,
835 questions,
836 &talk.id,
837 CHAT_NODE,
838 crate::merge_decision::DISMISS,
839 quote,
840 )
841 .is_ok()
842 {
843 roots.insert(home.join("queue"));
844 roots.insert(home.join("runs").join(&q.run));
845 }
846 }
847 roots.into_iter().collect()
848}
849
850pub fn authorized_holds(questions: &Questions, talk: &Talk) -> bool {
852 authorized_answers(questions, talk)
853}
854
855pub(crate) fn investigation_holder_hint(q: &Question, tasks: &[Task]) -> String {
857 let task = tasks.iter().find(|task| {
858 !q.run.is_empty()
859 && (task.id == q.run || task.runs.contains(&q.run))
860 && task.status == crate::queue::TaskStatus::Running
861 });
862 match task {
863 Some(task) => format!(
864 "Lock owner unknown. Related task {} is running and may hold the repository lock; inspect it with `magi task show {}`. ",
865 task.id, task.id
866 ),
867 None => "Lock owner unknown. ".into(),
868 }
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub enum Consent {
875 Delegable,
877 Merge,
879 Consequence,
881 Posting,
883}
884
885pub fn destructive_effect(q: &Question, label: &str) -> bool {
888 if label == crate::land::HOLD && !q.actions.contains_key(label) {
889 return false;
890 }
891 if q.node == crate::conduct::NODE
895 && q.decision_context
896 .as_deref()
897 .and_then(|context| serde_json::from_str::<serde_json::Value>(context).ok())
898 .is_some_and(|context| {
899 matches!(
900 context
901 .get(3)
902 .and_then(|v| v.get(0))
903 .and_then(|v| v.as_str()),
904 Some("complete" | "configure")
905 )
906 })
907 {
908 return true;
909 }
910 if matches!(
911 q.actions.get(label),
912 Some(crate::ask::ChoiceAction::Done | crate::ask::ChoiceAction::AbandonRun { .. })
913 ) {
914 return true;
915 }
916 let at = q.choices.iter().position(|c| c == label);
917 if q.node == crate::github_text::ASK_NODE {
919 return matches!(
920 label,
921 crate::github_text::POST_ORIGINAL
922 | crate::github_text::USE_REWRITE
923 | crate::github_text::USE_MY_TEXT
924 );
925 }
926 match q.node.as_str() {
927 crate::triage::NODE => at == Some(2),
928 crate::triage::DEPS_NODE => matches!(at, Some(1 | 2)),
929 n => n == crate::reconcile::NODE && q.seat == crate::reconcile::SEAT,
930 }
931}
932
933pub fn consent(q: &Question, choice: &str) -> Consent {
935 if choice == crate::land::APPROVE {
936 Consent::Merge
937 } else if q.node == crate::github_text::ASK_NODE
938 && matches!(
939 choice,
940 crate::github_text::POST_ORIGINAL
941 | crate::github_text::USE_REWRITE
942 | crate::github_text::USE_MY_TEXT
943 )
944 {
945 Consent::Posting
946 } else if choice == crate::merge_decision::WITHDRAW
947 || crate::merge_decision::selected_agent(choice).is_some()
948 || destructive_effect(q, choice)
949 || matches!(q.actions.get(choice), Some(crate::ask::ChoiceAction::Done))
950 {
951 Consent::Consequence
952 } else {
953 Consent::Delegable
954 }
955}
956
957pub(crate) fn validate_batch_effect(
960 q: &Question,
961 choice: &str,
962 message: &str,
963 quote: &str,
964) -> Result<()> {
965 if batch_instruction(message) {
966 validate_effect(q, choice, message, quote, false)?;
967 bail!(
968 "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"
969 );
970 }
971 Ok(())
972}
973
974pub(crate) fn validate_effect(
976 q: &Question,
977 choice: &str,
978 instruction: &str,
979 quote: &str,
980 bound: bool,
981) -> Result<()> {
982 let direct = format!("{}: {}", q.id, choice);
983 match consent(q, choice) {
984 Consent::Delegable => {}
985 Consent::Merge => {
986 let target = q.merge_target.as_ref().context("merge requires the specific reviewed PR and head binding; this legacy card has no recorded target")?;
987 let token = format!("merge {}", target.head);
988 if quote.trim() != instruction.trim()
989 || !(instruction.trim() == token
990 || instruction.trim() == format!("{}: {token}", q.id))
991 {
992 bail!(
993 "merge requires separate explicit confirmation of this specific reviewed PR and head: `{}: {token}`; a batch recommendation is insufficient",
994 q.id
995 );
996 }
997 }
998 Consent::Consequence => {
999 if quote.trim() != instruction.trim()
1000 || !(instruction.trim() == direct || (!bound && instruction.trim() == choice))
1001 {
1002 bail!(
1003 "`{choice}` requires an individual owner confirmation of the exact offered consequence: `{direct}`; batch authorization is insufficient"
1004 );
1005 }
1006 }
1007 Consent::Posting => {
1008 if bound {
1009 let token = format!("{}: {} {}", q.id, choice, question_fingerprint(q));
1010 let content_matches = if choice == crate::github_text::USE_MY_TEXT {
1011 owner_title(q, instruction).is_some()
1012 } else {
1013 instruction.trim() == token
1014 };
1015 if !content_matches || quote.trim() != instruction.trim() {
1016 bail!(
1017 "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"
1018 );
1019 }
1020 } else if !crate::github_text::posting_authorized(instruction, quote, choice) {
1021 bail!(
1022 "posting confirmation requires an explicit, unconditional instruction for the chosen text; batch authorization is insufficient"
1023 );
1024 }
1025 }
1026 }
1027 Ok(())
1028}
1029
1030pub(crate) fn latest_owner_instruction(talk: &Talk) -> Option<&str> {
1032 current_instruction(talk).map(|(_, _, words)| words)
1033}
1034
1035fn current_instruction(talk: &Talk) -> Option<(usize, usize, &str)> {
1036 let queued_owner = (!talk.pending.is_empty() || !talk.pending_messages.is_empty())
1040 && (talk.pending_messages.is_empty()
1041 || talk
1042 .pending_messages
1043 .iter()
1044 .any(|m| m.sender != Some(talk::Sender::Magi)));
1045 if !talk.status.open() || queued_owner {
1046 return None;
1047 }
1048 for (index, turn) in talk.turns.iter().enumerate().rev() {
1049 if talk::sender(turn, None) == talk::Sender::Magi {
1050 continue;
1051 }
1052 if turn.who != talk::Who::Operator || turn.posted_by.is_some() {
1055 return None;
1056 }
1057 let latest = turn
1058 .messages
1059 .iter()
1060 .enumerate()
1061 .rfind(|(_, send)| talk::sender(turn, Some(send)) == talk::Sender::Operator);
1062 let Some((message, send)) = latest else {
1063 if !turn.messages.is_empty()
1064 && turn
1065 .messages
1066 .iter()
1067 .all(|m| m.sender == Some(talk::Sender::Magi))
1068 {
1069 continue;
1070 }
1071 return None;
1072 };
1073 if send.sender.is_none()
1077 && (turn.breaks.is_none()
1078 || turn
1079 .messages
1080 .iter()
1081 .map(|m| m.body.as_str())
1082 .collect::<Vec<_>>()
1083 .join("\n\n")
1084 != turn.body)
1085 {
1086 return None;
1087 }
1088 return Some((index, message, &send.body));
1089 }
1090 None
1091}
1092
1093fn choice_authority(
1094 q: &Question,
1095 questions: &Questions,
1096 talk: &Talk,
1097 reply: &str,
1098 quote: Option<&str>,
1099) -> Option<AnswerAuthority> {
1100 if !q.status.open()
1101 || !(q.free_text() || q.choices.iter().any(|c| c == reply))
1102 || (reply == crate::land::HOLD && q.actions.contains_key(reply))
1103 {
1104 return None;
1105 }
1106 let (turn, message, instruction) = current_instruction(talk)?;
1107 if reply != crate::land::HOLD
1108 && (instruction.starts_with(" ") || instruction.starts_with('\t'))
1109 {
1110 return None;
1111 }
1112 if talk.turns[turn].at < q.asked_at {
1113 return None;
1114 }
1115 let direct = direct_choice(instruction, q, questions, reply)
1116 || (reply == crate::github_text::USE_MY_TEXT
1117 && q.node == crate::github_text::ASK_NODE
1118 && owner_title(q, instruction).is_some())
1119 || (consent(q, reply) == Consent::Posting
1120 && instruction.trim() == format!("{}: {} {}", q.id, reply, question_fingerprint(q)))
1121 || (consent(q, reply) == Consent::Merge
1122 && q.merge_target
1123 .as_ref()
1124 .is_some_and(|t| instruction.trim() == format!("{}: merge {}", q.id, t.head)));
1125 let quote_matches = if contextual_recovery(q, reply) {
1126 quote == Some(instruction)
1127 } else {
1128 quote.map(str::trim) == Some(instruction.trim())
1129 };
1130 let persisted_recovery = quote.is_none() && direct && contextual_recovery(q, reply);
1131 if !quote_matches && !persisted_recovery {
1132 return None;
1133 }
1134 let recommendation_turn = if direct {
1135 None
1136 } else {
1137 if message != 0 || q.free_text() || !batch_instruction(instruction) {
1138 return None;
1139 }
1140 let index = turn.checked_sub(1)?;
1141 let recommendation = &talk.turns[index];
1142 if recommendation.who != talk::Who::Agent
1143 || talk::sender(recommendation, None) != talk::Sender::Persona
1144 || recommendation.posted_by.is_some()
1145 || recommendation.at < q.asked_at
1146 || recommended_choice(&recommendation.body, q, questions) != Some(reply)
1147 {
1148 return None;
1149 }
1150 if let Some(manifest) = talk.recommendations.get(&index) {
1151 if manifest.body != crate::github_text::fingerprint("", &recommendation.body)
1152 || manifest.questions.get(&q.id) != Some(&question_fingerprint(q))
1153 {
1154 return None;
1155 }
1156 } else if reply != crate::land::HOLD {
1157 return None;
1160 }
1161 Some(index)
1162 };
1163 Some(AnswerAuthority {
1164 talk: talk.id.clone(),
1165 turn,
1166 message,
1167 instruction: instruction.to_owned(),
1168 instruction_fingerprint: crate::github_text::fingerprint("", instruction),
1169 recommendation_turn,
1170 recommendation_fingerprint: recommendation_turn
1171 .map(|i| crate::github_text::fingerprint("", &talk.turns[i].body)),
1172 question: q.id.clone(),
1173 choice: reply.into(),
1174 question_fingerprint: question_fingerprint(q),
1175 recovery_interpretation: None,
1176 })
1177}
1178
1179fn batch_instruction(text: &str) -> bool {
1180 let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect();
1183 let mut compact = compact.trim_end_matches(['!', '!', '。', '.', 'ー', '〜', '~']);
1184 let prefixes = [
1185 "おっけー",
1186 "オッケー〜",
1187 "オッケー",
1188 "おっけ〜",
1189 "おけ",
1190 "オケ",
1191 "ok",
1192 "OK",
1193 "Ok",
1194 "ありがとうございます",
1195 "ありがとう",
1196 "ありがと",
1197 "了解です",
1198 "了解",
1199 "はい",
1200 "じゃあ",
1201 "では",
1202 ];
1203 while let Some(rest) = prefixes
1204 .iter()
1205 .find_map(|prefix| compact.strip_prefix(prefix))
1206 {
1207 compact = rest.trim_start_matches(['!', '!', '、', ',', '。', '.', 'ー', '〜', '~']);
1208 }
1209 matches!(compact, "それぞれ回答して" | "それぞれ回答してください")
1210 || matches!(
1211 text.trim()
1212 .trim_end_matches(['.', '!'])
1213 .to_ascii_lowercase()
1214 .as_str(),
1215 "answer each as recommended" | "answer all as recommended"
1216 )
1217}
1218
1219fn refers_to(reference: &str, q: &Question, questions: &Questions) -> bool {
1220 reference == q.id
1221 || (reference.len() >= 4 && questions.resolve_id(reference).is_ok_and(|id| id == q.id))
1222}
1223
1224fn direct_choice(text: &str, q: &Question, questions: &Questions, reply: &str) -> bool {
1225 let text = text.trim();
1228 let binding = text.split_once(':').or_else(|| {
1229 if contextual_recovery(q, reply) {
1230 text.split_once(char::is_whitespace)
1231 } else {
1232 None
1233 }
1234 });
1235 let Some((reference, choice)) = binding else {
1236 return false;
1237 };
1238 let reference = reference.trim().trim_matches('`');
1239 (reference == q.id
1240 || ((reply == crate::land::HOLD || contextual_recovery(q, reply))
1241 && refers_to(reference, q, questions)))
1242 && (choice.trim() == reply
1243 || (contextual_recovery(q, reply)
1244 && q.resolve_reply(choice.trim())
1245 .is_ok_and(|resolved| resolved == reply)))
1246}
1247
1248fn recommended_choice<'a>(body: &str, q: &'a Question, questions: &Questions) -> Option<&'a str> {
1249 let mut rows = Vec::new();
1254 use comrak::nodes::NodeValue;
1257 let arena = comrak::Arena::new();
1258 let mut options = comrak::Options::default();
1259 options.extension.table = true;
1260 let document = comrak::parse_document(&arena, body, &options);
1261 let lines: Vec<_> = body.lines().collect();
1262 let mut eligible = std::collections::BTreeSet::new();
1263 for node in document.descendants() {
1264 let data = node.data.borrow();
1265 if node
1266 .ancestors()
1267 .any(|n| matches!(n.data.borrow().value, NodeValue::BlockQuote))
1268 {
1269 continue;
1270 }
1271 if matches!(
1275 data.value,
1276 NodeValue::Heading(_) | NodeValue::Paragraph | NodeValue::TableRow(_)
1277 ) && lines
1278 .get(data.sourcepos.start.line - 1..data.sourcepos.end.line)
1279 .is_some_and(|block| conditional_recommendation(&block.join(" ")))
1280 {
1281 return None;
1282 }
1283 if matches!(
1284 data.value,
1285 NodeValue::Paragraph | NodeValue::TableRow(false)
1286 ) {
1287 eligible.extend(data.sourcepos.start.line..=data.sourcepos.end.line);
1288 }
1289 }
1290 for (index, line) in body.lines().enumerate() {
1291 if !eligible.contains(&(index + 1)) {
1292 continue;
1293 }
1294 let line = line.trim();
1295 let tokens: Vec<&str> = line
1296 .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
1297 .collect();
1298 if !tokens.iter().any(|r| refers_to(r, q, questions)) {
1299 continue;
1300 }
1301 if line.starts_with('|') {
1302 let cells: Vec<_> = line
1303 .trim_matches('|')
1304 .split('|')
1305 .map(|c| c.trim().trim_matches('`'))
1306 .collect();
1307 let references = cells.iter().filter(|c| refers_to(c, q, questions)).count();
1308 let other_question = cells
1309 .iter()
1310 .any(|c| c.len() >= 4 && questions.resolve_id(c).is_ok_and(|id| id != q.id));
1311 let choices: Vec<_> = cells
1312 .iter()
1313 .filter_map(|c| q.resolve_reply(c).ok())
1314 .collect();
1315 if other_question || references != 1 || choices.len() != 1 {
1316 return None;
1317 }
1318 rows.push(
1319 q.choices
1320 .iter()
1321 .find(|choice| choice.as_str() == choices[0])?
1322 .as_str(),
1323 );
1324 continue;
1325 }
1326 let row = line.trim_start_matches(['-', '*', ' ']);
1327 let row = row.trim_start_matches('`');
1328 let end = row
1329 .find(|c: char| !c.is_ascii_alphanumeric() && c != '-')
1330 .unwrap_or(row.len());
1331 let (reference, rest) = row.split_at(end);
1332 let rest = rest.trim_start_matches('`').trim_start();
1333 let choice = rest
1334 .strip_prefix(':')
1335 .or_else(|| rest.strip_prefix('→'))
1336 .or_else(|| rest.strip_prefix('|'))
1337 .map(str::trim);
1338 if !refers_to(reference, q, questions) {
1339 return None;
1340 }
1341 let choice = choice?;
1342 let choice = if choice == "hold、その後PRを閉じる" {
1344 crate::land::HOLD
1345 } else {
1346 choice.trim_matches('`')
1347 };
1348 rows.push(
1349 q.choices
1350 .iter()
1351 .find(|offered| {
1352 q.resolve_reply(choice)
1353 .is_ok_and(|resolved| offered.as_str() == resolved)
1354 })?
1355 .as_str(),
1356 );
1357 }
1358 (rows.len() == 1).then(|| rows[0])
1359}
1360
1361fn conditional_recommendation(line: &str) -> bool {
1362 let lower = line.to_lowercase();
1363 lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
1364 matches!(
1365 word,
1366 "if" | "unless"
1367 | "when"
1368 | "maybe"
1369 | "conditional"
1370 | "otherwise"
1371 | "only"
1372 | "except"
1373 | "or"
1374 | "either"
1375 | "provided"
1376 | "assuming"
1377 | "depending"
1378 )
1379 }) || [
1380 "ただし",
1381 "場合",
1382 "なら",
1383 "たら",
1384 "限り",
1385 "のみ",
1386 "かも",
1387 "次第",
1388 "条件",
1389 "または",
1390 "もしくは",
1391 "あるいは",
1392 "れば",
1393 "とき",
1394 "際",
1395 "時だけ",
1396 "必要に応じて",
1397 ]
1398 .iter()
1399 .any(|word| line.contains(word))
1400}
1401
1402impl QuestionKind {
1403 pub fn of(q: &Question) -> Self {
1405 if crate::deputy::merge_gated(q) {
1406 return Self::Merge;
1407 }
1408 match q.node.as_str() {
1409 crate::github_text::ASK_NODE => Self::Posting,
1410 _ => Self::Ordinary,
1411 }
1412 }
1413}
1414
1415fn validate_routed_answer(q: &Question, talks: &Talks, run: &str, reply: &str) -> Result<()> {
1419 anyhow::ensure!(
1420 q.routed_chat.as_deref() == Some(run),
1421 "decision belongs to a different chat"
1422 );
1423 anyhow::ensure!(q.status.open(), "decision is no longer open");
1424 anyhow::ensure!(talks.get(run)?.status.open(), "the task chat is closed");
1425 let home = talks.root().parent().context("talk store has no home")?;
1426 let queue = crate::queue::Queue::at(home.join("queue"));
1427 crate::chat_control::owned_task(
1428 &queue,
1429 run,
1430 q.routed_task.as_deref().context("decision has no task")?,
1431 )?;
1432 anyhow::ensure!(
1433 q.free_text() || q.choices.iter().any(|c| c == reply),
1434 "decision requires an offered choice"
1435 );
1436 Ok(())
1437}
1438
1439pub fn validate_answer(
1443 q: &Question,
1444 talks: &Talks,
1445 run: &str,
1446 node: &str,
1447 reply: &str,
1448 quote: Option<&str>,
1449) -> Result<()> {
1450 if node == CHAT_NODE && q.routed_task.is_some() && q.routed_chat.is_some() {
1451 return validate_routed_answer(q, talks, run, reply);
1452 }
1453 let kind = QuestionKind::of(q);
1454 if node != CHAT_NODE || kind == QuestionKind::Ordinary {
1455 return Ok(());
1456 }
1457 let posting = kind == QuestionKind::Posting;
1458 if posting && !q.choices.iter().any(|c| c == reply) {
1459 bail!("posting confirmation requires one of the offered choices");
1460 }
1461 if posting && reply == crate::github_text::USE_MY_TEXT {
1462 bail!(
1463 "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"
1464 );
1465 }
1466 if posting
1467 && !matches!(
1468 reply,
1469 crate::github_text::POST_ORIGINAL | crate::github_text::USE_REWRITE
1470 )
1471 {
1472 return Ok(());
1473 }
1474 let subject = if posting {
1475 "posting confirmation"
1476 } else {
1477 "merge approval"
1478 };
1479 let consult = q
1480 .consult
1481 .as_ref()
1482 .with_context(|| format!("{subject} was not handed to a chat"))?;
1483 if consult.talk != run {
1484 bail!("{subject} belongs to a different chat");
1485 }
1486 if !q.status.open() {
1487 bail!("{subject} is no longer open");
1488 }
1489 let talk = talks.get(run)?;
1490 if !talk.status.open() {
1491 bail!("the consulted chat is closed");
1492 }
1493 let at = talk
1497 .turns
1498 .iter()
1499 .rposition(|t| {
1500 t.who == talk::Who::Operator
1501 && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
1502 && t.body.contains(&q.id)
1503 })
1504 .context("the question was not delivered to the chat yet")?;
1505 let queued = latest_owner_message(
1507 &talk.pending,
1508 talk.pending_breaks.as_deref(),
1509 &talk.pending_messages,
1510 None,
1511 posting,
1512 );
1513 let stored = talk.turns[at..]
1514 .iter()
1515 .enumerate()
1516 .rev()
1517 .filter(|(_, t)| t.who == talk::Who::Operator)
1518 .find_map(|(i, t)| {
1519 latest_owner_message(
1520 &t.body,
1521 t.breaks.as_deref(),
1522 &t.messages,
1523 (i == 0).then_some(q.id.as_str()),
1524 posting,
1525 )
1526 });
1527 let latest = queued
1528 .or(stored)
1529 .context("no owner message after the question was handed to the chat")?;
1530 let quote = quote
1531 .map(str::trim)
1532 .filter(|s| !s.is_empty())
1533 .with_context(|| {
1534 format!("chat {subject} requires --quote from the owner's latest message")
1535 })?;
1536 if posting {
1537 if !latest.contains(quote) {
1538 bail!("posting confirmation requires a verbatim quote of the latest owner message");
1539 }
1540 if !crate::github_text::posting_authorized(&latest, quote, reply) {
1541 bail!(
1542 "posting confirmation requires an explicit, unconditional instruction for the chosen text; ask the owner to clarify"
1543 );
1544 }
1545 return Ok(());
1546 }
1547 if reply == crate::land::APPROVE && batch_instruction(&latest) {
1548 bail!(
1549 "a batch answer instruction is insufficient for merge; merging requires a separate explicit \
1550 confirmation of this specific reviewed PR and head through the existing approval driver"
1551 );
1552 }
1553 let valid = if reply == crate::land::APPROVE {
1554 match &q.merge_target {
1555 Some(target) => {
1556 latest.trim() == format!("merge {}", target.head) && quote == latest.trim()
1557 }
1558 None => latest.contains(quote),
1559 }
1560 } else {
1561 q.choices.iter().any(|c| c == reply)
1562 && crate::merge_decision::exact_owner_choice(reply, &latest, quote)
1563 };
1564 if !valid {
1565 if reply == crate::land::APPROVE {
1566 if let Some(target) = &q.merge_target {
1567 bail!(
1568 "merge requires the latest whole owner message and --quote to be exactly `merge {}` for the displayed PR/head",
1569 target.head
1570 );
1571 }
1572 bail!("merge requires a verbatim quote of the latest explicit owner merge instruction");
1573 }
1574 bail!(
1575 "`{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"
1576 );
1577 }
1578 Ok(())
1579}
1580
1581#[cfg(test)]
1591fn latest_message(
1592 body: &str,
1593 breaks: Option<&[usize]>,
1594 after_block_of: Option<&str>,
1595) -> Option<String> {
1596 latest_message_for_policy(body, breaks, after_block_of, false)
1597}
1598
1599fn latest_owner_message(
1600 body: &str,
1601 breaks: Option<&[usize]>,
1602 messages: &[talk::Message],
1603 after: Option<&str>,
1604 posting: bool,
1605) -> Option<String> {
1606 if messages.is_empty() {
1607 return latest_message_for_policy(body, breaks, after, posting);
1608 }
1609 let first = after.map_or(0, |id| {
1610 messages
1611 .iter()
1612 .rposition(|m| {
1613 m.body.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.body.contains(id)
1614 })
1615 .unwrap_or(0)
1616 });
1617 let aligned = messages
1620 .iter()
1621 .map(|m| m.body.as_str())
1622 .collect::<Vec<_>>()
1623 .join("\n\n")
1624 == body;
1625 let valid_breaks = breaks.filter(|cuts| {
1626 cuts.windows(2).all(|w| w[0] < w[1])
1627 && cuts.iter().all(|&o| {
1628 o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
1629 })
1630 });
1631 let mut offset = 0;
1632 let mut latest = None;
1633 for (i, message) in messages.iter().enumerate() {
1634 let end = offset + message.body.len();
1635 if i >= first && message.sender != Some(talk::Sender::Magi) {
1636 let local_breaks = aligned.then_some(valid_breaks).flatten().map(|cuts| {
1637 cuts.iter()
1638 .copied()
1639 .filter(|&cut| cut > offset && cut <= end)
1640 .map(|cut| cut - offset)
1641 .collect::<Vec<_>>()
1642 });
1643 let message_breaks = if message.sender.is_some() {
1644 Some(&[][..])
1645 } else {
1646 local_breaks.as_deref()
1647 };
1648 if let Some(words) = latest_message_for_policy(
1649 &message.body,
1650 message_breaks,
1651 after.filter(|_| i == first),
1652 posting,
1653 ) {
1654 latest = Some(words);
1655 }
1656 }
1657 offset = end + 2;
1658 }
1659 latest
1660}
1661
1662fn latest_message_for_policy(
1663 body: &str,
1664 breaks: Option<&[usize]>,
1665 after_block_of: Option<&str>,
1666 posting: bool,
1667) -> Option<String> {
1668 let cuts = breaks.filter(|b| {
1669 b.windows(2).all(|w| w[0] < w[1])
1670 && b.iter().all(|&o| {
1671 o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
1672 })
1673 });
1674 let Some(cuts) = cuts else {
1675 if is_completion_notice(body) {
1676 return None;
1677 }
1678 let words = owner_words_for_policy(body, after_block_of, posting);
1679 if posting && words.contains("\n\n") {
1683 return Some(String::new());
1686 }
1687 if posting {
1688 return (!words.trim().is_empty()).then_some(words);
1689 }
1690 return words
1691 .rsplit("\n\n")
1692 .map(str::trim)
1693 .find(|p| !p.is_empty())
1694 .map(str::to_owned);
1695 };
1696 let mut starts = vec![0];
1697 starts.extend_from_slice(cuts);
1698 let messages: Vec<&str> = starts
1699 .iter()
1700 .enumerate()
1701 .map(|(i, &s)| {
1702 let e = starts.get(i + 1).map_or(body.len(), |&n| n - 2);
1703 &body[s..e]
1704 })
1705 .collect();
1706 let first = after_block_of.map_or(0, |id| {
1708 messages
1709 .iter()
1710 .rposition(|m| m.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.contains(id))
1711 .unwrap_or(0)
1712 });
1713 messages
1714 .iter()
1715 .enumerate()
1716 .skip(first)
1717 .rev()
1718 .filter(|(_, m)| !is_completion_notice(m))
1719 .map(|(i, m)| owner_words_for_policy(m, after_block_of.filter(|_| i == first), posting))
1720 .find(|w| !w.trim().is_empty())
1721}
1722
1723fn is_completion_notice(message: &str) -> bool {
1726 crate::prompt::is_completion_notice(message)
1727}
1728
1729fn legacy_len(rest: &str) -> usize {
1736 use crate::prompt::CHAT_CONSULT_HEADING;
1737 const CLOSERS: [&str; 2] = [
1738 "cannot be revived. Do not edit the repository.",
1739 "make: do not edit the repository.",
1740 ];
1741 let mut events: Vec<(usize, usize)> = Vec::new(); let first = rest.find(CHAT_CONSULT_HEADING).unwrap_or(0);
1743 for (i, _) in rest.match_indices(CHAT_CONSULT_HEADING) {
1744 if i > first
1745 && rest[..i].ends_with("# ")
1746 && rest[..i - 2].ends_with('\n')
1747 && rest[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
1748 {
1749 events.push((i, 0));
1750 }
1751 }
1752 for c in CLOSERS {
1753 events.extend(rest.match_indices(c).map(|(i, _)| (i, c.len())));
1754 }
1755 events.sort_unstable();
1756 let mut depth = 1usize;
1757 for (at, len) in events {
1758 if len == 0 {
1759 depth += 1;
1760 } else {
1761 depth -= 1;
1762 if depth == 0 {
1763 return at + len;
1764 }
1765 }
1766 }
1767 rest.len()
1768}
1769
1770#[cfg(test)]
1784fn owner_words(body: &str, after_block_of: Option<&str>) -> String {
1785 owner_words_for_policy(body, after_block_of, false)
1786}
1787
1788fn owner_words_for_policy(body: &str, after_block_of: Option<&str>, posting: bool) -> String {
1789 let trim = |text: &str| -> String {
1792 if posting {
1793 text.trim_matches(['\r', '\n']).to_owned()
1794 } else {
1795 text.trim().to_owned()
1796 }
1797 };
1798 use crate::prompt::{CHAT_CONSULT_END, CHAT_CONSULT_HEADING};
1799 let heads: Vec<usize> = body
1803 .match_indices(CHAT_CONSULT_HEADING)
1804 .map(|(i, _)| i)
1805 .filter(|&i| {
1806 let line_start = body[..i]
1807 .strip_suffix("# ")
1808 .is_some_and(|b| b.is_empty() || b.ends_with('\n'));
1809 line_start
1810 && body[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
1811 })
1812 .collect();
1813 if heads.is_empty() {
1814 return trim(body);
1815 }
1816 let mut blocks: Vec<(usize, usize)> = Vec::new();
1823 for &h in &heads {
1824 let start = if body[..h].ends_with("# ") { h - 2 } else { h };
1825 if blocks.last().is_some_and(|&(_, e)| start < e) {
1826 continue; }
1828 let rest = &body[h..];
1829 let head = rest.split("\n\n## ").next().unwrap_or(rest);
1830 let end = if head.contains(crate::prompt::CHAT_CONSULT_DEFUSED) {
1831 rest.find(CHAT_CONSULT_END)
1834 .map_or(body.len(), |p| h + p + CHAT_CONSULT_END.len())
1835 } else {
1836 h + legacy_len(rest)
1837 };
1838 blocks.push((start, end));
1839 }
1840 let mut from = 0;
1841 if let Some(id) = after_block_of {
1842 if let Some(&(_, end)) = blocks.iter().rfind(|&&(s, e)| body[s..e].contains(id)) {
1843 from = end;
1844 } else if let Some(&(_, end)) = blocks.last() {
1845 from = end;
1846 }
1847 }
1848 let mut parts = Vec::new();
1849 let mut at = from;
1850 for &(s, e) in &blocks {
1851 if s >= at {
1852 parts.push(trim(&body[at..s]));
1853 }
1854 at = at.max(e);
1855 }
1856 parts.push(trim(&body[at..]));
1857 parts
1858 .into_iter()
1859 .filter(|p| !p.trim().is_empty())
1860 .collect::<Vec<_>>()
1861 .join("\n\n")
1862}
1863
1864#[derive(Debug, PartialEq, Eq)]
1866pub enum Started {
1867 Answered,
1869 Busy,
1871 Nothing,
1873}
1874
1875pub async fn start_turn(
1882 questions: &Questions,
1883 talks: &Talks,
1884 q: &Question,
1885 talk: &Talk,
1886 cfg: &Config,
1887) -> Result<Started> {
1888 if q.consult.is_some() {
1889 return Ok(Started::Nothing);
1890 }
1891 let Some(lease) = talks.claim_turn(&talk.id)? else {
1892 return Ok(Started::Busy);
1893 };
1894 if !begin(questions, talks, q, talk)? {
1895 return Ok(Started::Nothing);
1896 }
1897 drain_turns(talks, &talk.id, lease, cfg).await?;
1898 Ok(Started::Answered)
1899}
1900
1901pub async fn drain_turns(
1908 talks: &Talks,
1909 talk_id: &str,
1910 mut lease: talk::TurnLease,
1911 cfg: &Config,
1912) -> Result<()> {
1913 let mut talk = talks.get(talk_id)?;
1914 let mut failed = None;
1915 loop {
1916 while let Some(text) = talk::drain(&mut talk, talks)? {
1917 if let Err(e) = talk::respond(&lease, &mut talk, talks, cfg, &text).await {
1918 failed.get_or_insert(e);
1919 }
1920 if !lease.beat()? {
1921 bail!("the turn lease for chat {} was lost", talk.short());
1922 }
1923 }
1924 drop(lease);
1929 talk = talks.get(&talk.id)?;
1930 let owed = talk.status.open()
1931 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
1932 if !owed {
1933 break;
1934 }
1935 let Some(again) = talks.claim_turn(&talk.id)? else {
1937 break;
1938 };
1939 lease = again;
1940 }
1941 if let Some(e) = failed {
1942 return Err(e);
1943 }
1944 Ok(())
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949 use std::collections::BTreeMap;
1950 use std::path::PathBuf;
1951
1952 use super::*;
1953 use crate::config::{AgentKind, AgentSpec, Config};
1954 use crate::queue::Source;
1955 use crate::talk::TalkStatus;
1956
1957 const RUN: &str = "20260902-000000-beef";
1958
1959 fn talks() -> (tempfile::TempDir, Talks, Talk) {
1960 let tmp = tempfile::tempdir().expect("tempdir");
1961 let store = Talks::at(tmp.path().join("talks"));
1962 let cfg = Config {
1963 agents: vec![AgentSpec {
1964 id: "mock".to_owned(),
1965 kind: AgentKind::Command,
1966 model: None,
1967 command: vec!["true".to_owned()],
1968 extra_args: Vec::new(),
1969 env: BTreeMap::new(),
1970 prompt_delivery: None,
1971 max_turn_secs: None,
1972 }],
1973 ..Config::default()
1974 };
1975 let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
1976 (tmp, store, talk)
1977 }
1978
1979 fn task(source: Source) -> Task {
1980 let mut t = Task::new(
1981 "t".to_owned(),
1982 "Do it".to_owned(),
1983 PathBuf::from("/repo"),
1984 source,
1985 );
1986 t.start(RUN.to_owned());
1987 t
1988 }
1989
1990 fn from_chat(talk: &Talk) -> Task {
1991 task(Source::Agent {
1992 run: talk.id.clone(),
1993 node: CHAT_NODE.to_owned(),
1994 })
1995 }
1996
1997 #[test]
1998 fn investigation_holder_hint_is_advisory_and_requires_a_related_running_task() {
1999 let mut q = question("implement");
2000 let mut related = task(Source::Human);
2001 let check = |q: &Question, tasks: &[Task]| investigation_holder_hint(q, tasks);
2002 let hint = check(&q, std::slice::from_ref(&related));
2003 assert!(hint.contains("Lock owner unknown"));
2004 assert!(hint.contains("may hold"));
2005 assert!(hint.contains(&format!("magi task show {}", related.id)));
2006 assert_eq!(check(&q, &[]), "Lock owner unknown. ");
2007 q.run = "unrelated".into();
2008 assert_eq!(check(&q, std::slice::from_ref(&related)), check(&q, &[]));
2009 q.run = related.id.clone();
2010 assert!(check(&q, std::slice::from_ref(&related)).contains("may hold"));
2011 related.hold_manual(None);
2012 assert_eq!(check(&q, std::slice::from_ref(&related)), check(&q, &[]));
2013 related.status = crate::queue::TaskStatus::Running;
2014 q.run.clear();
2015 assert_eq!(check(&q, &[related]), check(&q, &[]));
2016 }
2017
2018 fn question(node: &str) -> Question {
2019 Question::new(
2020 RUN.to_owned(),
2021 node.to_owned(),
2022 "impl-A".to_owned(),
2023 "Which backend?".to_owned(),
2024 "SQLite is simpler.".to_owned(),
2025 vec!["SQLite".to_owned(), "Redis".to_owned()],
2026 )
2027 }
2028
2029 #[test]
2030 fn batch_instruction_normalizes_repeated_japanese_prefixes() {
2031 for instruction in [
2032 "おっけーそれぞれ回答してー",
2033 "了解です、ありがとう!では、それぞれ回答してください。",
2034 "ありがとうございます!はい、じゃあそれぞれ回答して〜",
2035 "オッケー〜!おけ、それぞれ回答して",
2036 "OK!それぞれ回答して",
2037 "answer all as recommended!",
2038 ] {
2039 assert!(batch_instruction(instruction), "{instruction}");
2040 }
2041 for instruction in [
2042 "了解です",
2043 "ありがとう!それぞれ回答してもいい?",
2044 "もし大丈夫ならそれぞれ回答して",
2045 "それぞれ回答して、いや待って",
2046 "「それぞれ回答して」",
2047 ] {
2048 assert!(!batch_instruction(instruction), "{instruction}");
2049 }
2050 }
2051
2052 #[test]
2053 fn recommendation_returns_only_one_exact_offered_choice() {
2054 let tmp = tempfile::tempdir().unwrap();
2055 let questions = Questions::at(tmp.path().join("questions"));
2056 let mut q = question("implement");
2057 questions.put(&mut q).unwrap();
2058 let table = |rows: &str| format!("| Question | Choice |\n| --- | --- |\n{rows}");
2059 let row = format!("| {} | SQLite |", q.id);
2060 assert_eq!(
2061 recommended_choice(&table(&row), &q, &questions),
2062 Some("SQLite")
2063 );
2064 assert_eq!(
2065 recommended_choice(&format!("{}: Redis", q.id), &q, &questions),
2066 Some("Redis")
2067 );
2068 for body in [
2069 table(&format!("{row}\n| {} | Redis |", q.id)),
2070 table(&format!("{row}\n{row}")),
2071 table(&format!("| {} | SQLite | Redis |", q.id)),
2072 table(&format!("| {} | SQLite maybe |", q.id)),
2073 format!("> {}: SQLite", q.id),
2074 format!("If approved:\n\n{}: SQLite", q.id),
2075 "No recommendation".into(),
2076 ] {
2077 assert_eq!(recommended_choice(&body, &q, &questions), None, "{body}");
2078 }
2079 }
2080
2081 #[test]
2082 fn chat_answer_is_refused_for_an_operator_held_task() {
2083 let mut task = crate::queue::Task::new(
2084 "t".into(),
2085 "i".into(),
2086 std::path::PathBuf::from("."),
2087 crate::queue::Source::Human,
2088 );
2089 task.runs.push("run-1".into());
2090 let mut q = question(crate::land::APPROVAL_NODE);
2091 q.run = "run-1".into();
2092 let check = |t: &crate::queue::Task, node: &str| {
2093 refuse_operator_held(&q, node, std::slice::from_ref(t))
2094 };
2095 assert!(check(&task, CHAT_NODE).is_ok());
2096 task.hold_machine(None);
2097 assert!(check(&task, CHAT_NODE).is_ok());
2098 task.hold_manual(None);
2099 assert!(check(&task, CHAT_NODE).is_err());
2100 assert!(check(&task, "implement").is_ok());
2102 assert!(refuse_operator_held(&q, CHAT_NODE, &[]).is_ok());
2103 }
2104
2105 #[tokio::test]
2106 async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
2107 let (tmp, store, talk) = talks();
2108 let questions = Questions::at(tmp.path().join("questions"));
2109 let mut q = question("implement");
2110 questions.put(&mut q).expect("put");
2111 let held = store.claim_turn(&talk.id).expect("claim").expect("first");
2112
2113 let got = start_turn(&questions, &store, &q, &talk, &Config::default())
2114 .await
2115 .expect("start");
2116 assert_eq!(got, Started::Busy);
2117 assert!(questions.get(&q.id).unwrap().consult.is_none());
2118 assert!(store.get(&talk.id).unwrap().pending.is_empty());
2119 drop(held);
2120 }
2121
2122 fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
2125 let path = tmp.join("mock-consult-agent.sh");
2126 std::fs::write(&path, script).expect("write mock");
2127 Config {
2128 agents: vec![AgentSpec {
2129 id: "mock".to_owned(),
2130 kind: AgentKind::Command,
2131 model: None,
2132 command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
2133 extra_args: Vec::new(),
2134 env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
2135 prompt_delivery: None,
2136 max_turn_secs: None,
2137 }],
2138 ..Config::default()
2139 }
2140 }
2141
2142 async fn run_turn(
2145 script: &str,
2146 ) -> (
2147 tempfile::TempDir,
2148 Result<Started>,
2149 Questions,
2150 Question,
2151 Talk,
2152 ) {
2153 crate::run::set_home(crate::run::test_home());
2154 let (tmp, store, talk) = talks();
2155 let questions = Questions::at(tmp.path().join("questions"));
2156 let mut q = question("implement");
2157 questions.put(&mut q).expect("put");
2158 let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
2159 let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
2160 (tmp, got, questions, q, talk)
2161 }
2162
2163 #[tokio::test]
2164 async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
2165 let (tmp, got, _questions, _q, talk) =
2167 run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
2168 .await;
2169 assert_eq!(got.expect("turn"), Started::Answered);
2170 let other = Talks::at(tmp.path().join("talks"));
2171 assert!(
2172 other.claim_turn(&talk.id).expect("claim").is_some(),
2173 "free once the turn ended"
2174 );
2175 }
2176
2177 #[tokio::test]
2178 async fn the_lease_is_free_again_after_a_failed_turn() {
2179 let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
2180 assert!(got.is_err(), "the agent failed");
2181 assert!(
2182 questions.get(&q.id).unwrap().consult.is_some(),
2183 "the turn got as far as running"
2184 );
2185 let other = Talks::at(tmp.path().join("talks"));
2186 assert!(
2187 other.claim_turn(&talk.id).expect("claim").is_some(),
2188 "free after the failed turn"
2189 );
2190 }
2191
2192 #[test]
2193 fn origin_talk_is_decided_in_one_table() {
2194 let (_tmp, _store, talk) = talks();
2195 let mut closed = talk.clone();
2196 closed.status = TalkStatus::Closed;
2197 let chat = from_chat(&talk);
2198 let q = question("implement");
2199
2200 let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
2201 assert_eq!(
2202 hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
2203 Some(talk.id.clone())
2204 );
2205 assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
2207 let other = task(Source::Agent {
2208 run: talk.id.clone(),
2209 node: "implement".to_owned(),
2210 });
2211 assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
2212 assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
2214 assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
2216 assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
2218 assert!(
2220 hit(
2221 std::slice::from_ref(&chat),
2222 std::slice::from_ref(&talk),
2223 &question(crate::land::APPROVAL_NODE)
2224 )
2225 .is_some()
2226 );
2227 assert!(
2228 hit(
2229 std::slice::from_ref(&chat),
2230 std::slice::from_ref(&talk),
2231 &question(crate::bump::NOTICE_NODE)
2232 )
2233 .is_none()
2234 );
2235 let mut answered = question("implement");
2237 answered
2238 .answer(crate::ask::Answer::Choice("Redis".to_owned()))
2239 .unwrap();
2240 assert!(hit(&[chat], &[talk], &answered).is_none());
2241 }
2242
2243 #[test]
2244 fn associated_chat_remains_navigable_after_consultation_and_settlement() {
2245 let (tmp, store, mut talk) = talks();
2246 let chat = from_chat(&talk);
2247 let tasks = std::slice::from_ref(&chat);
2248 let mut q = question("implement");
2249 let questions = Questions::at(tmp.path().join("questions"));
2250 questions.put(&mut q).unwrap();
2251 assert!(begin(&questions, &store, &q, &talk).unwrap());
2252 q = questions.get(&q.id).unwrap();
2253 let associated = |q: &Question, talk: &Talk| {
2254 associated_talk(tasks, std::slice::from_ref(talk), q).map(|t| t.id)
2255 };
2256 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2257 assert!(q.status.open());
2258 assert!(q.answer.is_none());
2259 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_some());
2260 q.answer(crate::ask::Answer::Choice("Redis".into()))
2261 .unwrap();
2262 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2263 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
2264 talk.status = TalkStatus::Closed;
2265 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2266 let mut q = question("implement");
2267 q.abandon("expired");
2268 assert_eq!(associated(&q, &talk), Some(talk.id.clone()));
2269 assert!(origin_talk(tasks, std::slice::from_ref(&talk), &q).is_none());
2270 assert!(associated_talk(tasks, &[], &q).is_none());
2271 assert!(associated_talk(&[], std::slice::from_ref(&talk), &q).is_none());
2272 let notice = question(crate::bump::NOTICE_NODE);
2273 assert_eq!(associated(¬ice, &talk), Some(talk.id.clone()));
2274 assert!(origin_talk(tasks, &[talk], ¬ice).is_none());
2275 }
2276
2277 #[test]
2278 fn approval_origin_requires_an_open_chat_task_and_question() {
2279 let (_tmp, _store, talk) = talks();
2280 let chat = from_chat(&talk);
2281 let mut q = question(crate::land::APPROVAL_NODE);
2282 assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
2283 assert!(
2284 origin_talk(
2285 &[task(Source::Agent {
2286 run: talk.id.clone(),
2287 node: "implement".into(),
2288 })],
2289 std::slice::from_ref(&talk),
2290 &q
2291 )
2292 .is_none()
2293 );
2294 assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
2295 let mut closed = talk.clone();
2296 closed.status = TalkStatus::Closed;
2297 assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
2298 q.abandon("expired");
2299 assert!(
2300 origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
2301 );
2302 let mut q = question(crate::land::APPROVAL_NODE);
2303 q.answer(crate::ask::Answer::Choice("Redis".into()))
2304 .unwrap();
2305 assert!(origin_talk(&[chat], &[talk], &q).is_none());
2306 }
2307
2308 #[test]
2309 fn a_conductor_question_is_found_through_the_task_id() {
2310 let (_tmp, _store, talk) = talks();
2311 let chat = from_chat(&talk);
2312 let mut q = question(crate::conduct::NODE);
2313 q.run = chat.id.clone();
2314 assert!(origin_talk(&[chat], &[talk], &q).is_some());
2315 }
2316
2317 fn followup_of(parent: &Task, n: u32) -> Task {
2318 let mut t = Task::new(
2319 "f".to_owned(),
2320 "Fix".to_owned(),
2321 PathBuf::from("/repo"),
2322 Source::Agent {
2323 run: format!("merged-{n}"),
2324 node: "followup".to_owned(),
2325 },
2326 );
2327 t.start(format!("run-f{n}"));
2328 t.followup = Some(crate::queue::FollowUp {
2329 run: format!("merged-{n}"),
2330 origin_task: Some(parent.id.clone()),
2331 pr: "https://example.invalid/pr/1".to_owned(),
2332 findings: Vec::new(),
2333 generation: n,
2334 });
2335 t
2336 }
2337
2338 fn q_for(t: &Task) -> Question {
2339 let mut q = question("implement");
2340 q.run = t.runs[0].clone();
2341 q
2342 }
2343
2344 #[test]
2345 fn a_followup_traces_back_to_the_chat() {
2346 let (_tmp, _store, talk) = talks();
2347 let mut chat = from_chat(&talk);
2348 chat.runs = vec!["chat-run".to_owned()];
2349 let f1 = followup_of(&chat, 1);
2350 let f2 = followup_of(&f1, 2);
2351 let ts = [chat, f1.clone(), f2.clone()];
2352 let id = Some(talk.id.clone());
2353 let tk = std::slice::from_ref(&talk);
2354 assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
2355 assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
2356 }
2357
2358 #[test]
2359 fn a_followup_without_origin_task_is_found_through_runs() {
2360 let (_tmp, _store, talk) = talks();
2361 let mut chat = from_chat(&talk);
2362 chat.runs = vec!["merged-1".to_owned()];
2363 let mut f1 = followup_of(&chat, 1);
2364 f1.followup.as_mut().unwrap().origin_task = None;
2365 let q = q_for(&f1);
2366 assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
2367 }
2368
2369 #[test]
2370 fn a_dangling_origin_task_has_no_chat() {
2371 let (_tmp, _store, talk) = talks();
2372 let mut chat = from_chat(&talk);
2373 chat.runs = vec!["chat-run".to_owned()];
2374 let mut f1 = followup_of(&chat, 1);
2375 f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
2376 let q = q_for(&f1);
2377 assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
2378 }
2379
2380 #[test]
2381 fn a_recorded_chat_survives_deleted_ancestors() {
2382 let (_tmp, _store, talk) = talks();
2383 let chat = from_chat(&talk);
2384 assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
2385 let mut f1 = followup_of(&chat, 1);
2386 f1.origin_chat = chat.origin_chat.clone();
2387 let mut f2 = followup_of(&f1, 2);
2388 f2.origin_chat = f1.origin_chat.clone();
2389 let q = q_for(&f2);
2391 let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
2392 assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
2393 assert!(origin_talk(&[f2], &[], &q).is_none());
2395 }
2396
2397 #[test]
2398 fn a_task_without_the_field_still_walks_the_ancestry() {
2399 let (_tmp, _store, talk) = talks();
2400 let mut chat = from_chat(&talk);
2401 chat.origin_chat = None;
2402 let mut f1 = followup_of(&chat, 1);
2403 f1.origin_chat = None;
2404 let q = q_for(&f1);
2405 assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
2406 let mut v = serde_json::to_value(&f1).unwrap();
2408 v.as_object_mut().unwrap().remove("origin_chat");
2409 let back: Task = serde_json::from_value(v).unwrap();
2410 assert!(back.origin_chat.is_none());
2411 }
2412
2413 #[test]
2414 fn a_followup_cycle_ends_without_a_chat() {
2415 let (_tmp, _store, talk) = talks();
2416 let mut a = followup_of(&from_chat(&talk), 1);
2417 let mut b = followup_of(&a, 2);
2418 a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
2419 b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
2420 let q = q_for(&a);
2421 assert!(origin_talk(&[a, b], &[talk], &q).is_none());
2422 }
2423
2424 #[test]
2425 fn pending_consults_follow_the_store_not_the_turn_text() {
2426 let (tmp, store, talk) = talks();
2427 let questions = Questions::at(tmp.path().join("questions"));
2428 assert!(!pending_consults(&questions, &talk.id), "empty store");
2429
2430 let mut plain = question("implement");
2431 questions.put(&mut plain).unwrap();
2432 assert!(!pending_consults(&questions, &talk.id), "no consult record");
2433
2434 let mut q = question("implement");
2435 questions.put(&mut q).unwrap();
2436 begin(&questions, &store, &q, &talk).unwrap();
2437 assert!(pending_consults(&questions, &talk.id));
2438 assert!(!pending_consults(&questions, "other-talk"));
2439
2440 questions
2441 .update(&q.id, |q| {
2442 q.abandon("test");
2443 Ok(())
2444 })
2445 .unwrap();
2446 assert!(!pending_consults(&questions, &talk.id), "closed question");
2447 }
2448
2449 #[test]
2450 fn begin_reopens_a_closed_chat_before_queueing() {
2451 let (tmp, store, mut talk) = talks();
2452 let questions = Questions::at(tmp.path().join("questions"));
2453 let mut q = question("implement");
2454 questions.put(&mut q).unwrap();
2455 talk::close(&mut talk, &store).unwrap();
2456 assert!(!store.get(&talk.id).unwrap().status.open());
2457
2458 assert!(begin(&questions, &store, &q, &talk).unwrap());
2459
2460 let after = store.get(&talk.id).unwrap();
2461 assert!(after.status.open(), "the chat was reopened");
2462 assert!(after.pending.contains(&q.id), "{}", after.pending);
2463 assert!(questions.get(&q.id).unwrap().consult.is_some());
2464 }
2465
2466 #[test]
2467 fn begin_queues_once_and_leaves_the_question_open() {
2468 let (tmp, store, talk) = talks();
2469 let questions = Questions::at(tmp.path().join("questions"));
2470 let mut q = question("implement");
2471 questions.put(&mut q).unwrap();
2472
2473 assert!(begin(&questions, &store, &q, &talk).unwrap());
2474 assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
2475
2476 let after = questions.get(&q.id).unwrap();
2477 assert!(after.status.open());
2478 assert!(after.thread.is_empty());
2479 assert_eq!(after.choices, q.choices, "choices are not touched");
2480 assert_eq!(
2481 after.consult.as_ref().map(|c| c.talk.as_str()),
2482 Some(talk.id.as_str())
2483 );
2484
2485 let destination = store.get(&talk.id).unwrap();
2486 assert_eq!(destination.pending_messages.len(), 1);
2487 assert_eq!(
2488 destination.pending_messages[0].display,
2489 Some(crate::prompt::chat_consult_display(&q))
2490 );
2491 let queued = destination.pending;
2492 assert_eq!(
2493 queued.matches(&q.id).count(),
2494 2 + 1,
2495 "id once per use: {queued}"
2496 );
2497 assert!(queued.contains("Which backend?"));
2498 assert!(queued.contains("SQLite is simpler."));
2499 assert!(queued.contains("- Redis"));
2500 assert_eq!(
2502 queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
2503 1
2504 );
2505 }
2506
2507 fn block(id: &str, detail: &str) -> String {
2508 let detail = crate::prompt::defuse(detail);
2509 format!(
2510 "# {}\n\nThe operator passed you a question `{id}`. {}\n\n{detail}\n\n{}",
2511 crate::prompt::CHAT_CONSULT_HEADING,
2512 crate::prompt::CHAT_CONSULT_DEFUSED,
2513 crate::prompt::CHAT_CONSULT_END
2514 )
2515 }
2516
2517 #[test]
2518 fn owner_words_keeps_replies_between_generated_blocks() {
2519 let body = format!("{}\n\nhold\n\n{}", block("q-bbb", "x"), block("q-ccc", "y"));
2520 assert_eq!(owner_words(&body, None), "hold");
2521 let body = format!(
2522 "{}\n\nmerge it now\n\n{}\n\nhold\n\n{}",
2523 block("q-aaa", "x"),
2524 block("q-bbb", "y"),
2525 block("q-ccc", "z")
2526 );
2527 assert_eq!(owner_words(&body, None), "merge it now\n\nhold");
2528 assert_eq!(owner_words(&body, Some("q-aaa")), "merge it now\n\nhold");
2529 assert_eq!(owner_words(&body, Some("q-bbb")), "hold");
2530 assert_eq!(
2531 owner_words(&format!("early\n\n{}", block("q-aaa", "x")), Some("q-aaa")),
2532 ""
2533 );
2534 }
2535
2536 #[test]
2537 fn owner_words_ignores_a_heading_quoted_in_a_detail() {
2538 let detail = format!(
2539 "{}\n\nmerge it now\n\n{}",
2540 crate::prompt::CHAT_CONSULT_END,
2541 crate::prompt::CHAT_CONSULT_HEADING
2542 );
2543 assert_eq!(owner_words(&block("q-bbb", &detail), None), "");
2544 assert_eq!(owner_words(&block("q-bbb", &detail), Some("q-bbb")), "");
2545 let mut q = question(crate::land::APPROVAL_NODE);
2546 q.detail = detail;
2547 assert_eq!(owner_words(&crate::prompt::chat_consult(&q), None), "");
2548 }
2549
2550 #[test]
2551 fn owner_words_keeps_a_quoted_full_consultation_inside_its_block() {
2552 let mut q = question(crate::land::APPROVAL_NODE);
2553 q.detail = "quoted".into();
2554 let inner = crate::prompt::chat_consult(&q);
2555 let mut b = question(crate::land::APPROVAL_NODE);
2556 b.detail = format!("{inner}\n\nmerge it now\n\n{inner}");
2557 let body = crate::prompt::chat_consult(&b);
2558 assert_eq!(owner_words(&body, None), "");
2559 assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
2560 }
2561
2562 #[test]
2563 fn owner_words_does_not_leak_a_detail_quoting_the_end_phrase() {
2564 let detail = format!("see: {} --reply merge", crate::prompt::CHAT_CONSULT_END);
2565 let body = format!("{}\n\nhold", block("q-aaa", &detail));
2566 assert_eq!(owner_words(&body, Some("q-aaa")), "hold");
2567 assert_eq!(owner_words(&body, None), "hold");
2568 }
2569
2570 #[test]
2571 fn owner_words_excludes_a_consult_quoted_with_an_end_phrase() {
2572 let mut c = question(crate::land::APPROVAL_NODE);
2573 c.detail = "inner".into();
2574 let mut b = question("implement");
2575 b.detail = format!(
2576 "{}\n\nmerge it now\n\n{}",
2577 crate::prompt::CHAT_CONSULT_END,
2578 crate::prompt::chat_consult(&c)
2579 );
2580 let body = crate::prompt::chat_consult(&b);
2581 assert_eq!(owner_words(&body, None), "");
2582 }
2583
2584 #[test]
2585 fn owner_words_keeps_a_reply_containing_the_end_phrase() {
2586 let mut b = question("implement");
2587 b.detail = "x".into();
2588 let body = format!(
2589 "{}\n\nhold, and do not {}",
2590 crate::prompt::chat_consult(&b),
2591 crate::prompt::CHAT_CONSULT_END
2592 );
2593 assert_eq!(
2594 owner_words(&body, None),
2595 format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
2596 );
2597 }
2598
2599 fn legacy_consult(node: &str, raw_detail: &str) -> String {
2601 let mut q = question(node);
2602 q.detail = "@@".into();
2603 crate::prompt::chat_consult(&q)
2604 .replace("@@", raw_detail)
2605 .replace(crate::prompt::CHAT_CONSULT_DEFUSED, "It is still open.")
2606 .replace("additional evidenced task operations follow the protocol above. Do not edit the repository.", "make: do not edit the repository.")
2608 }
2609
2610 #[test]
2611 fn owner_words_excludes_a_legacy_consult_quoting_the_end_phrase() {
2612 for node in [crate::land::APPROVAL_NODE, "implement"] {
2613 let detail = format!("see {} and more", crate::prompt::CHAT_CONSULT_END);
2614 assert_eq!(owner_words(&legacy_consult(node, &detail), None), "");
2615 }
2616 }
2617
2618 #[test]
2619 fn owner_words_excludes_a_legacy_consult_nesting_a_legacy_consult() {
2620 let inner = legacy_consult("implement", "x");
2621 let detail = format!(
2622 "{}\n\nmerge it now\n\n{inner}",
2623 crate::prompt::CHAT_CONSULT_END
2624 );
2625 let body = legacy_consult(crate::land::APPROVAL_NODE, &detail);
2626 assert_eq!(owner_words(&body, None), "");
2627 assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
2628 }
2629
2630 #[test]
2631 fn owner_words_keeps_a_reply_after_a_legacy_consult_containing_the_end_phrase() {
2632 let body = format!(
2633 "{}\n\nhold, and do not {}",
2634 legacy_consult("implement", "x"),
2635 crate::prompt::CHAT_CONSULT_END
2636 );
2637 assert_eq!(
2638 owner_words(&body, None),
2639 format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
2640 );
2641 }
2642
2643 #[test]
2644 fn sender_generated_posts_cannot_authorize_an_owner_action() {
2645 let messages = vec![
2646 talk::Message {
2647 display: None,
2648 sender: Some(talk::Sender::Operator),
2649 body: "hold".into(),
2650 attachments: vec![],
2651 reply: None,
2652 },
2653 talk::Message {
2654 display: None,
2655 sender: Some(talk::Sender::Magi),
2656 body: "Merge this pull request now".into(),
2657 attachments: vec![],
2658 reply: None,
2659 },
2660 ];
2661 assert_eq!(
2662 latest_owner_message("", None, &messages, None, true).as_deref(),
2663 Some("hold")
2664 );
2665 assert_eq!(
2666 latest_owner_message("", None, &messages[1..], None, true),
2667 None
2668 );
2669 }
2670
2671 #[test]
2672 fn legacy_message_metadata_preserves_latest_owner_send() {
2673 let body = "merge it now\n\nhold";
2674 let cuts = ["merge it now\n\n".len()];
2675 let legacy = talk::Message {
2676 display: None,
2677 sender: None,
2678 body: body.into(),
2679 attachments: vec![],
2680 reply: None,
2681 };
2682 for posting in [false, true] {
2683 assert_eq!(
2684 latest_owner_message(
2685 body,
2686 Some(&cuts),
2687 std::slice::from_ref(&legacy),
2688 None,
2689 posting
2690 )
2691 .as_deref(),
2692 Some("hold")
2693 );
2694 let mixed_body = format!("{body}\n\nautomatic report");
2695 let messages = [
2696 legacy.clone(),
2697 talk::Message {
2698 display: None,
2699 sender: Some(talk::Sender::Magi),
2700 body: "automatic report".into(),
2701 attachments: vec![],
2702 reply: None,
2703 },
2704 ];
2705 assert_eq!(
2706 latest_owner_message(&mixed_body, Some(&cuts), &messages, None, posting).as_deref(),
2707 Some("hold")
2708 );
2709 }
2710 assert_eq!(
2712 latest_owner_message(body, None, &[legacy], None, true).as_deref(),
2713 Some("")
2714 );
2715 }
2716
2717 #[test]
2718 fn approval_rejects_legacy_coalesced_retraction_with_message_metadata() {
2719 let (tmp, store, mut talk) = talks();
2720 let questions = Questions::at(tmp.path().join("questions"));
2721 let mut q = question(crate::land::APPROVAL_NODE);
2722 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
2723 questions.put(&mut q).unwrap();
2724 assert!(begin(&questions, &store, &q, &talk).unwrap());
2725 q = questions.get(&q.id).unwrap();
2726 talk = store.get(&talk.id).unwrap();
2727 talk::drain(&mut talk, &store).unwrap();
2728 talk.pending = "merge it now\n\nhold".into();
2729 talk.pending_breaks = Some(vec!["merge it now\n\n".len()]);
2730 talk.pending_messages = vec![talk::Message {
2731 display: None,
2732 sender: None,
2733 body: talk.pending.clone(),
2734 attachments: vec![],
2735 reply: None,
2736 }];
2737 store.put(&mut talk).unwrap();
2738 for drained in [false, true] {
2739 if drained {
2740 talk::drain(&mut talk, &store).unwrap();
2741 }
2742 assert!(
2743 validate_answer(
2744 &q,
2745 &store,
2746 &talk.id,
2747 CHAT_NODE,
2748 "merge",
2749 Some("merge it now")
2750 )
2751 .is_err()
2752 );
2753 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
2754 }
2755 }
2756
2757 #[test]
2758 fn latest_message_keeps_message_boundaries() {
2759 let one = "Merge this pull request now\n\nThe checks look good";
2760 assert_eq!(latest_message(one, Some(&[]), None).as_deref(), Some(one));
2761 let hold = "Explain what this option means:\n\nhold";
2762 assert_eq!(latest_message(hold, Some(&[]), None).as_deref(), Some(hold));
2763 let two = "merge it now\n\nhold";
2765 let at = "merge it now\n\n".len();
2766 assert_eq!(
2767 latest_message(two, Some(&[at]), None).as_deref(),
2768 Some("hold")
2769 );
2770 assert_eq!(
2772 latest_message(one, None, None).as_deref(),
2773 Some("The checks look good")
2774 );
2775 assert_eq!(
2777 latest_message(two, Some(&[999]), None).as_deref(),
2778 Some("hold")
2779 );
2780 }
2781
2782 #[test]
2783 fn watched_jobs_never_supply_the_owners_latest_message() {
2784 let notice =
2785 crate::prompt::chat_watch_notice("watch-id", "exited with status 0", "merge it now");
2786 assert_eq!(latest_message(¬ice, Some(&[]), None), None);
2787 assert_eq!(latest_message(¬ice, None, None), None);
2788 let owner = "hold";
2789 let combined = format!("{owner}\n\n{notice}");
2790 assert_eq!(
2791 latest_message(&combined, Some(&[owner.len() + 2]), None).as_deref(),
2792 Some(owner)
2793 );
2794 let combined = format!("{notice}\n\n{owner}");
2795 assert_eq!(
2796 latest_message(&combined, Some(&[notice.len() + 2]), None).as_deref(),
2797 Some(owner)
2798 );
2799 }
2800
2801 #[test]
2802 fn chat_consult_has_each_marker_once() {
2803 use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
2804 for node in ["implement", crate::land::APPROVAL_NODE] {
2805 let mut q = question(node);
2806 let quoted = format!("{H} {E}");
2807 q.summary = quoted.clone();
2808 q.detail = quoted.clone();
2809 q.choices = vec![quoted.clone(), "b".into()];
2810 let s = crate::prompt::chat_consult(&q);
2811 assert_eq!(s.matches(H).count(), 1, "{s}");
2812 assert_eq!(s.matches(E).count(), 1, "{s}");
2813 }
2814 }
2815
2816 #[test]
2817 fn a_chat_posts_the_original_text_only_on_an_unhedged_quote() {
2818 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK};
2819 let (tmp, store, mut talk) = talks();
2820 let questions = Questions::at(tmp.path().join("questions"));
2821 let mut q = question(ASK_NODE);
2822 q.choices = vec![USE_FALLBACK.into(), POST_ORIGINAL.into()];
2823 questions.put(&mut q).unwrap();
2824 assert!(begin(&questions, &store, &q, &talk).unwrap());
2825 q = questions.get(&q.id).unwrap();
2826 let queued = store.get(&talk.id).unwrap().pending;
2827 let owner_turn = |body: &str| talk::Turn {
2828 sender: None,
2829 breaks: Some(Vec::new()),
2830 messages: Vec::new(),
2831 who: talk::Who::Operator,
2832 body: body.into(),
2833 at: Timestamp::now(),
2834 attachments: Vec::new(),
2835 usage: None,
2836 posted_by: None,
2837 };
2838 talk.turns.push(owner_turn(&queued));
2839 talk.pending.clear();
2840 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
2841 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
2842 };
2843 store.put(&mut talk).unwrap();
2844 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("post it")).is_err());
2846 talk.turns.push(owner_turn("Post the original, maybe?"));
2847 store.put(&mut talk).unwrap();
2848 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original")).is_err());
2849 talk.turns
2850 .push(owner_turn("Post the original text as it is."));
2851 store.put(&mut talk).unwrap();
2852 assert!(check(&q, &talk.id, POST_ORIGINAL, None).is_err());
2853 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("never said")).is_err());
2854 assert!(check(&q, &talk.id, POST_ORIGINAL, Some("Post the original text")).is_ok());
2855 assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
2857 }
2858
2859 #[test]
2860 fn a_chat_posts_the_rewrite_only_on_an_unhedged_quote() {
2861 use crate::github_text::{ASK_NODE, USE_FALLBACK, USE_REWRITE};
2862 let (tmp, store, mut talk) = talks();
2863 let questions = Questions::at(tmp.path().join("questions"));
2864 let mut q = question(ASK_NODE);
2865 q.choices = vec![USE_FALLBACK.into(), USE_REWRITE.into()];
2866 questions.put(&mut q).unwrap();
2867 assert!(begin(&questions, &store, &q, &talk).unwrap());
2868 q = questions.get(&q.id).unwrap();
2869 let queued = store.get(&talk.id).unwrap().pending;
2870 let owner_turn = |body: &str| talk::Turn {
2871 sender: None,
2872 breaks: Some(Vec::new()),
2873 messages: Vec::new(),
2874 who: talk::Who::Operator,
2875 body: body.into(),
2876 at: Timestamp::now(),
2877 attachments: Vec::new(),
2878 usage: None,
2879 posted_by: None,
2880 };
2881 talk.turns.push(owner_turn(&queued));
2882 talk.pending.clear();
2883 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
2884 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
2885 };
2886 store.put(&mut talk).unwrap();
2887 assert!(check(&q, &talk.id, USE_REWRITE, Some("post it")).is_err());
2889 talk.turns.push(owner_turn("Use the rewrite, maybe?"));
2890 store.put(&mut talk).unwrap();
2891 assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite")).is_err());
2892 talk.turns
2893 .push(owner_turn("Use the rewrite text as it is."));
2894 store.put(&mut talk).unwrap();
2895 assert!(check(&q, &talk.id, USE_REWRITE, None).is_err());
2896 assert!(check(&q, &talk.id, USE_REWRITE, Some("never said")).is_err());
2897 assert!(check(&q, &talk.id, USE_REWRITE, Some("Use the rewrite text")).is_ok());
2898 assert!(check(&q, &talk.id, USE_FALLBACK, None).is_ok());
2900 }
2901
2902 #[test]
2903 fn merge_repair_consult_requires_offered_choice_and_exact_latest_owner_request() {
2904 use crate::land::{APPROVAL_NODE, APPROVE, HOLD, REPAIR};
2905 let (tmp, store, mut talk) = talks();
2906 let questions = Questions::at(tmp.path().join("questions"));
2907 let mut q = question(APPROVAL_NODE);
2908 q.choices = vec![APPROVE.into(), HOLD.into(), REPAIR.into()];
2909 questions.put(&mut q).unwrap();
2910 begin(&questions, &store, &q, &talk).unwrap();
2911 q = questions.get(&q.id).unwrap();
2912 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
2913 talk = store.get(&talk.id).unwrap();
2914 talk.turns.push(talk::Turn {
2915 sender: None,
2916 breaks: Some(Vec::new()),
2917 messages: Vec::new(),
2918 posted_by: None,
2919 who: talk::Who::Operator,
2920 body: talk.pending.clone(),
2921 at: Timestamp::now(),
2922 attachments: Vec::new(),
2923 usage: None,
2924 });
2925 talk.pending_messages.clear(); talk.pending = "maybe fix findings and re-review".into();
2927 store.put(&mut talk).unwrap();
2928 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
2929 talk.pending = REPAIR.into();
2930 talk.pending_breaks = Some(Vec::new());
2931 store.put(&mut talk).unwrap();
2932 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_ok());
2933 q.choices.retain(|c| c != REPAIR);
2934 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, REPAIR, Some(REPAIR)).is_err());
2935 }
2936
2937 #[test]
2938 fn posting_consult_saves_only_authorized_offered_choices() {
2939 use crate::github_text::{ASK_NODE, POST_ORIGINAL, USE_FALLBACK, USE_REWRITE};
2940 for choice in [POST_ORIGINAL, USE_REWRITE] {
2941 let (tmp, store, mut talk) = talks();
2942 let questions = Questions::at(tmp.path().join("questions"));
2943 let mut q = question(ASK_NODE);
2944 q.choices = vec![USE_FALLBACK.into(), choice.into()];
2945 questions.put(&mut q).unwrap();
2946 begin(&questions, &store, &q, &talk).unwrap();
2947 q = questions.get(&q.id).unwrap();
2948 let queued = store.get(&talk.id).unwrap().pending;
2949 assert!(queued.contains("public posting confirmation"));
2950 assert!(queued.contains("not a merge approval"));
2951 assert!(
2952 queued.contains("`use fallback` choice can be saved once the owner has chosen it")
2953 );
2954 assert!(queued.contains("--quote <verbatim owner words>"));
2955 assert!(!queued.contains("answer it yourself with"));
2956 talk.turns.push(talk::Turn {
2957 sender: None,
2958 breaks: Some(Vec::new()),
2959 messages: Vec::new(),
2960 who: talk::Who::Operator,
2961 body: queued,
2962 at: Timestamp::now(),
2963 attachments: Vec::new(),
2964 usage: None,
2965 posted_by: None,
2966 });
2967 talk.pending.clear();
2968 store.put(&mut talk).unwrap();
2969 let save = |reply: &str, quote: Option<&str>, run: &str| {
2970 questions.update(&q.id, |r| {
2971 validate_answer(r, &store, run, CHAT_NODE, reply, quote)?;
2972 r.answer(crate::ask::Answer::Choice(reply.into()))
2973 })
2974 };
2975 assert!(save(choice, Some(choice), &talk.id).is_err());
2977 let clear = if choice == POST_ORIGINAL {
2978 "元の説明のまま投稿して"
2979 } else {
2980 "Use the rewrite text as it is."
2981 };
2982 talk.pending = clear.into();
2983 store.put(&mut talk).unwrap();
2984 for quote in [None, Some("forged quote")] {
2985 let error = save(choice, quote, &talk.id).unwrap_err().to_string();
2986 assert!(error.contains("posting confirmation"), "{error}");
2987 assert!(
2988 !error.contains("merge") && !error.contains("hold"),
2989 "{error}"
2990 );
2991 }
2992 assert!(save(choice, Some(clear), "another-chat").is_err());
2993 let other = if choice == POST_ORIGINAL {
2994 USE_REWRITE
2995 } else {
2996 POST_ORIGINAL
2997 };
2998 assert!(save(other, Some(clear), &talk.id).is_err());
2999 let mut unconsulted = q.clone();
3000 unconsulted.consult = None;
3001 assert!(
3002 validate_answer(
3003 &unconsulted,
3004 &store,
3005 &talk.id,
3006 CHAT_NODE,
3007 choice,
3008 Some(clear)
3009 )
3010 .is_err()
3011 );
3012 let mut expired = q.clone();
3013 expired.abandon("expired");
3014 assert!(
3015 validate_answer(&expired, &store, &talk.id, CHAT_NODE, choice, Some(clear))
3016 .is_err()
3017 );
3018 talk.status = TalkStatus::Closed;
3019 store.put(&mut talk).unwrap();
3020 assert!(save(choice, Some(clear), &talk.id).is_err());
3021 talk.status = TalkStatus::Open;
3022 for rejected in [
3023 "Post original was the option I saw.",
3024 "Use rewrite was the option I saw.",
3025 "Post the original, maybe?",
3026 "Use the rewrite only if checks pass.",
3027 "Post original. Cancel that.",
3028 "Do not post original.",
3029 "Use the rewrite when checks pass.",
3030 "元の説明で投稿してください。ただし検査が通ったら。",
3031 "元の説明で投稿しないで。",
3032 "まだ元の説明で投稿してください。",
3033 "ログには「元の説明で投稿しました」と記録されています。",
3034 "ログには「修正版で投稿しました」と記録されています。",
3035 ] {
3036 talk.pending = rejected.into();
3037 store.put(&mut talk).unwrap();
3038 let error = save(choice, Some(rejected), &talk.id)
3039 .unwrap_err()
3040 .to_string();
3041 assert!(
3042 !error.contains("merge") && !error.contains("hold"),
3043 "{error}"
3044 );
3045 assert!(save(choice, Some(clear), &talk.id).is_err(), "stale quote");
3046 let after = questions.get(&q.id).unwrap();
3047 assert!(after.status.open());
3048 assert!(after.answer.is_none());
3049 }
3050 let command = if choice == POST_ORIGINAL {
3051 "Post original"
3052 } else {
3053 "Use rewrite"
3054 };
3055 let mut earlier = talk.turns[0].clone();
3057 earlier.body = clear.into();
3058 talk.turns.push(earlier);
3059 for reported in [
3060 format!("The log contains:\n```\n{command}\n```"),
3061 format!("The log contains:\n\"\n{command}\n\""),
3062 format!("The log says: “Reported. {command}.”"),
3063 format!("The log says: “\n{command}\n”"),
3064 ]
3065 .into_iter()
3066 .chain(crate::github_text::reported_posting_messages(command))
3067 {
3068 talk.pending.clear();
3069 talk.pending_messages.clear();
3070 store.put(&mut talk).unwrap();
3071 talk::queue(&mut talk, &store, &reported, Vec::new()).unwrap();
3072 assert_eq!(talk.pending, reported);
3073 for breaks in [Some(Vec::new()), None] {
3075 talk.pending_breaks = breaks;
3076 store.put(&mut talk).unwrap();
3077 for quote in [command, reported.as_str()] {
3078 assert!(save(choice, Some(quote), &talk.id).is_err());
3079 let after = questions.get(&q.id).unwrap();
3080 assert!(after.status.open());
3081 assert!(after.answer.is_none());
3082 }
3083 }
3084 talk::drain(&mut talk, &store).unwrap();
3086 for quote in [command, reported.as_str()] {
3087 assert!(save(choice, Some(quote), &talk.id).is_err());
3088 let after = questions.get(&q.id).unwrap();
3089 assert!(after.status.open());
3090 assert!(after.answer.is_none());
3091 }
3092 }
3093 let natural = if choice == POST_ORIGINAL {
3094 [
3095 "OK, post the original",
3096 "Yes, post original",
3097 "I'd like to post the original",
3098 "元の説明で投稿してください。",
3099 ]
3100 } else {
3101 [
3102 "OK, use the rewrite",
3103 "Yes, use rewrite",
3104 "I'd like to use the rewrite",
3105 "修正版で投稿してください。",
3106 ]
3107 };
3108 for words in natural {
3109 talk.pending = words.into();
3110 store.put(&mut talk).unwrap();
3111 validate_answer(&q, &store, &talk.id, CHAT_NODE, choice, Some(words)).unwrap();
3112 }
3113 talk.pending = clear.into();
3114 store.put(&mut talk).unwrap();
3115 save(choice, Some(clear), &talk.id).unwrap();
3116 assert_eq!(
3117 questions.get(&q.id).unwrap().resolution().as_deref(),
3118 Some(choice)
3119 );
3120 }
3121 }
3122
3123 #[test]
3124 fn approval_consult_waits_for_latest_owner_confirmation() {
3125 let (tmp, store, mut talk) = talks();
3126 let questions = Questions::at(tmp.path().join("questions"));
3127 let mut q = question(crate::land::APPROVAL_NODE);
3128 q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
3129 questions.put(&mut q).unwrap();
3130 assert!(begin(&questions, &store, &q, &talk).unwrap());
3131 assert!(!begin(&questions, &store, &q, &talk).unwrap());
3132 q = questions.get(&q.id).unwrap();
3133 assert!(q.status.open());
3134 assert!(q.answer.is_none());
3135 assert!(q.thread.is_empty());
3136 let queued = store.get(&talk.id).unwrap().pending;
3137 assert!(queued.contains("Never answer it yourself"));
3138 assert!(queued.contains("Silence holds"));
3139 assert!(queued.contains("--reply merge --quote"));
3140 assert!(!queued.contains("answer it yourself with"));
3141
3142 let owner_turn = |body: &str| talk::Turn {
3143 sender: None,
3144 breaks: Some(Vec::new()),
3145 messages: Vec::new(),
3146 posted_by: None,
3147 who: talk::Who::Operator,
3148 body: body.into(),
3149 at: Timestamp::now(),
3150 attachments: Vec::new(),
3151 usage: None,
3152 };
3153 let mut old = talk.clone();
3155 old.turns
3156 .insert(0, owner_turn("Please merge PR 12 after review"));
3157 store.put(&mut old).unwrap();
3158 talk.turns = old.turns.clone();
3159 talk.turns.push(owner_turn(&queued));
3162 store.put(&mut talk).unwrap();
3163 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "merge", Some("merge")).is_err());
3164 assert!(
3165 validate_answer(
3166 &q,
3167 &store,
3168 &talk.id,
3169 CHAT_NODE,
3170 "merge",
3171 Some("Please merge PR 12")
3172 )
3173 .is_err()
3174 );
3175 let n = talk.turns.len();
3177 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
3178 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
3179 store.put(&mut talk).unwrap();
3180 assert!(
3181 validate_answer(
3182 &q,
3183 &store,
3184 &talk.id,
3185 CHAT_NODE,
3186 "merge",
3187 Some("merge it now")
3188 )
3189 .is_ok()
3190 );
3191 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now\n\nhold");
3193 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2, queued.len() + 2 + 14]);
3194 store.put(&mut talk).unwrap();
3195 assert!(
3196 validate_answer(
3197 &q,
3198 &store,
3199 &talk.id,
3200 CHAT_NODE,
3201 "merge",
3202 Some("merge it now")
3203 )
3204 .is_err()
3205 );
3206 assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
3207 talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
3209 talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
3210 talk.pending = "hold".into();
3211 store.put(&mut talk).unwrap();
3212 assert!(
3213 validate_answer(
3214 &q,
3215 &store,
3216 &talk.id,
3217 CHAT_NODE,
3218 "merge",
3219 Some("merge it now")
3220 )
3221 .is_err()
3222 );
3223 talk.pending = format!("hold\n\n{queued}");
3225 store.put(&mut talk).unwrap();
3226 assert!(
3227 validate_answer(
3228 &q,
3229 &store,
3230 &talk.id,
3231 CHAT_NODE,
3232 "merge",
3233 Some("merge it now")
3234 )
3235 .is_err()
3236 );
3237 talk.pending.clear();
3238 talk.turns[n - 1].body = queued.clone();
3239 store.put(&mut talk).unwrap();
3240 talk.turns
3241 .push(owner_turn("Merge this pull request please"));
3242 store.put(&mut talk).unwrap();
3243 let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
3244 validate_answer(q, &store, run, CHAT_NODE, reply, quote)
3245 };
3246 assert!(check(&q, "other-talk", "merge", Some("Merge this")).is_err());
3247 assert!(check(&q, &talk.id, "merge", None).is_err());
3248 assert!(check(&q, &talk.id, "merge", Some("never said")).is_err());
3249 assert!(
3250 check(
3251 &q,
3252 &talk.id,
3253 "merge",
3254 Some("Merge this pull request please")
3255 )
3256 .is_ok()
3257 );
3258 assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
3259 talk.turns
3260 .push(owner_turn("Wait, explain the checks first"));
3261 store.put(&mut talk).unwrap();
3262 assert!(
3263 check(
3264 &q,
3265 &talk.id,
3266 "merge",
3267 Some("Merge this pull request please")
3268 )
3269 .is_err()
3270 );
3271 talk.turns.push(owner_turn("Please hold"));
3272 store.put(&mut talk).unwrap();
3273 assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
3274 talk.turns.push(owner_turn("hold"));
3275 store.put(&mut talk).unwrap();
3276 assert!(check(&q, &talk.id, "hold", Some("hold")).is_ok());
3277 talk.turns
3278 .push(owner_turn("Merge this pull request please"));
3279 store.put(&mut talk).unwrap();
3280 q.abandon("approval expired or head changed");
3281 assert!(
3282 check(
3283 &q,
3284 &talk.id,
3285 "merge",
3286 Some("Merge this pull request please")
3287 )
3288 .is_err()
3289 );
3290 assert!(
3291 q.answer(crate::ask::Answer::Choice("merge".into()))
3292 .is_err()
3293 );
3294 }
3295
3296 #[test]
3297 fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
3298 let (tmp, store, talk) = talks();
3299 let questions = Questions::at(tmp.path().join("questions"));
3300 let mut q = question("implement");
3301 questions.put(&mut q).unwrap();
3302 std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
3303 assert!(begin(&questions, &store, &q, &talk).is_err());
3304 assert!(questions.get(&q.id).unwrap().consult.is_none());
3305 }
3306
3307 #[test]
3308 fn an_older_question_file_reads_without_a_consult() {
3309 let mut q = question("implement");
3310 q.schema = 5;
3311 let mut v = serde_json::to_value(&q).unwrap();
3312 v.as_object_mut().unwrap().remove("consult");
3313 let back: Question = serde_json::from_value(v).unwrap();
3314 assert!(back.consult.is_none());
3315 }
3316}