Skip to main content

magi/
consult.rs

1//! Handing an open question to the chat conversation its task came from.
2//!
3//! A task filed by the standing chat (`Source::Agent` with
4//! [`crate::queue::CHAT_NODE`], whose `run` is the conversation's id) can raise
5//! a question the owner would rather talk through where the task started. The
6//! owner passes it on; the chat agent either answers it with `magi answer` or
7//! puts the decision points to the owner in the conversation.
8//!
9//! Three rules, each with a reason:
10//!
11//! - **One place decides.** [`origin_talk`] is the only judge of "does this
12//!   question have a chat to ask"; the web view and both entry points use it.
13//!   [`associated_talk`] resolves typed ownership links for navigation,
14//!   independently of consultation eligibility.
15//! - **Not a choice.** The hand-over never goes into `Question::choices` and
16//!   never touches `Question::answer`: the question stays `Open`, its
17//!   validation and the choice actions stay as they were.
18//! - **No new seat, no new waiter.** The text is queued as a draft of the
19//!   existing talk, so the chat's own turn machinery (its session, its turn
20//!   gate) runs it exactly as if the owner had typed it.
21
22use 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
30/// The conversation `q` may be handed to, if any.
31///
32/// Requires an open question whose task was filed from a chat. The chat may
33/// be closed: [`begin`] reopens it. Release notices are left out. Merge
34/// approvals may be discussed, but answering from chat requires the owner's latest words.
35pub 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
44/// The existing conversation associated with a question, regardless of whether
45/// the question is still eligible for consultation.
46pub fn associated_talk(tasks: &[Task], talks: &[Talk], q: &Question) -> Option<Talk> {
47    associated_talk_with_watches(tasks, talks, q, &[])
48}
49
50/// A typed ownership link, shared by association and the web's selective loader.
51#[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    // Review-start reconciliation is filed under a task id, unlike normal
62    // review questions; both the node and seat identify that producer.
63    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    // Duplicate records are ambiguous even if they currently name one owner.
74    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
83/// Explicit links may fill a missing owner, but conflicting owners fail closed.
84/// This navigation-only resolution never changes consultation eligibility.
85pub(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
107/// The talk `start` descends from: the id recorded on a task
108/// ([`Task::chat_talk`]) wins, so deleted ancestors do not matter; tasks
109/// written before it existed are resolved by walking the ancestry.
110///
111/// Walk the provenance of `start` to the task a chat filed.
112///
113/// A follow-up goes to the task its merged run served: `FollowUp::origin_task`
114/// when recorded (a missing one ends the walk - guessing another task could
115/// hand the question to an unrelated chat), else the task whose `runs` hold
116/// `FollowUp::run`. At most `MAX_FOLLOWUP_GENERATION + 1` tasks are looked at,
117/// each once, so a cycle ends in `None`.
118pub 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
137/// Is any question handed to talk `talk_id` still open?
138///
139/// Decided from the question store, not from the newest turn's wording: the
140/// owner's decision usually arrives in a later turn that carries no hand-over
141/// heading, and `magi answer` still has to be able to write then. Answered or
142/// abandoned questions and ended consultations stop counting, restoring normal
143/// permissions on the next turn.
144pub fn pending_consults(questions: &Questions, talk_id: &str) -> bool {
145    questions.list().iter().any(|q| active_consult(q, talk_id))
146}
147
148/// Consultation activity is separate from the question's waiting state.
149pub 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/// A non-answer never wakes a waiter or executes a choice action.
157#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
158pub struct ConsultOutcome {
159    /// Literal owner outcome, with no execution effect.
160    pub text: String,
161    /// Complete latest owner send used as evidence.
162    pub instruction: String,
163    /// Originating conversation.
164    pub talk: String,
165    /// Persisted conversation turn index.
166    pub turn: usize,
167    /// Persisted send index within the turn.
168    pub message: usize,
169    /// Time consultation ended.
170    pub at: Timestamp,
171}
172
173/// Closed inventory of additional consultation operations. Every execution
174/// argument is bound by the owner's complete instruction, not just a task id.
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176#[serde(rename_all = "snake_case", deny_unknown_fields)]
177pub enum TaskOperation {
178    /// File work in the conversation's repository with default execution options.
179    Add {
180        /// Complete task text.
181        instruction: String,
182        /// Whether to use one implementer.
183        solo: bool,
184    },
185    /// Release a held or finished task.
186    Release {
187        /// Full resolved task id.
188        task: String,
189    },
190    /// Hold a task for the owner.
191    Hold {
192        /// Full resolved task id.
193        task: String,
194        /// Complete hold reason.
195        reason: String,
196    },
197    /// Mark work finished without running it.
198    Done {
199        /// Full resolved task id.
200        task: String,
201    },
202    /// Adjust the queue priority.
203    Priority {
204        /// Full resolved task id.
205        task: String,
206        /// New priority.
207        priority: i32,
208    },
209}
210
211impl TaskOperation {
212    /// A literal, unambiguous individual instruction; JSON avoids shell parsing.
213    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
256/// Caller holds the conversation evidence lock until the queue mutation ends.
257pub 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
280/// Store an evidenced alternative and end only the consultation. Caller keeps
281/// the conversation evidence lock until Questions::update publishes the note.
282pub 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    // A note is the owner's literal decision, never an assistant paraphrase
288    // that could smuggle an execution authorization into retained evidence.
289    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
304/// Record the hand-over and queue the question as the chat's next message.
305///
306/// Returns `false` (and changes nothing) when the question was already handed
307/// over. The question is not otherwise touched: still `Open`, no thread turn.
308/// When queueing fails the record is withdrawn, so the owner can try again.
309pub 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    // Serialize dispatch with answers, but never wait for conversation locks
331    // under the expiring question lock. Each retry reloads the latest question.
332    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
370/// Refuse a chat approval answer whose task the operator held, as
371/// `ask_settle_cmd` does for a deputy: such a hold is lifted by the operator's
372/// button. The task is the one `q.run` names, directly or as one of its runs;
373/// none found (an unreadable task is not listed) does not refuse. The caller
374/// reads the queue before taking the question lock, so a hold placed in between
375/// is missed for that one call.
376pub 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/// Authorization policy is selected by the question's node, never its reply.
402#[derive(Clone, Copy, PartialEq, Eq)]
403pub enum QuestionKind {
404    /// A question without a guarded public decision.
405    Ordinary,
406    /// Approval to merge a change.
407    Merge,
408    /// Confirmation of the text to publish.
409    Posting,
410}
411
412/// Owner evidence retained with an answer, independent of its consultation destination.
413#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
414pub struct AnswerAuthority {
415    /// Conversation containing the instruction.
416    pub talk: String,
417    /// Zero-based persisted turn position.
418    pub turn: usize,
419    /// Zero-based send position within that turn.
420    pub message: usize,
421    /// Full instruction, not a substring selected by the assistant.
422    pub instruction: String,
423    /// Stable fingerprint of the complete instruction.
424    pub instruction_fingerprint: String,
425    /// Immediately preceding persona reply, for a batch instruction.
426    pub recommendation_turn: Option<usize>,
427    /// Fingerprint of the complete persona reply used as the batch manifest.
428    pub recommendation_fingerprint: Option<String>,
429    /// Exact decision covered by this evidence.
430    pub question: String,
431    /// Exact offered choice covered by the instruction.
432    pub choice: String,
433    /// Execution facts validated when this answer was authorized.
434    #[serde(default)]
435    pub question_fingerprint: String,
436    /// The conversation agent interpreted this complete owner send as consent
437    /// to the single persisted internal recovery recommendation.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub recovery_interpretation: Option<String>,
440}
441
442/// Immutable recommendation-time binding to question choices and targets.
443#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
444pub struct RecommendationManifest {
445    /// Fingerprint of the full persona reply.
446    pub body: String,
447    /// Full question IDs and fingerprints of their execution-relevant facts.
448    pub questions: std::collections::BTreeMap<String, String>,
449    /// Canonical choice and structured effect captured with the persona reply.
450    /// Old transcripts cannot acquire this binding retrospectively.
451    #[serde(default)]
452    pub recovery: std::collections::BTreeMap<String, RecoveryRecommendation>,
453}
454
455/// Execution binding published with a single internal recovery recommendation.
456#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
457pub struct RecoveryRecommendation {
458    /// Canonical stored label, independent of any alias used in the reply.
459    pub choice: String,
460    /// Structured effect; absent only for issuer-defined legacy keep-held.
461    pub action: Option<crate::ask::ChoiceAction>,
462    /// Run owning the question at recommendation time.
463    pub run: String,
464}
465
466/// Fingerprint of execution-relevant question facts, independent of answer state.
467pub 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
478/// Replacement title explicitly supplied with its question/content binding.
479/// The final posting gate still vets this literal text before publication.
480pub 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
495/// Capture at publication, never reconstruct new authority from old history.
496pub 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
524/// Validate an answer under the question lock. The caller must also retain
525/// [`Talks::answer_guard`] from before this read until the answer is persisted.
526/// Consultation is a delivery route, not the source of authority for an
527/// explicit, independently bound owner decision.
528pub 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    // Before delivery, preserve already-supported legacy consultation replies.
541    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        // A running original consultation can still be preparing a decision.
568        // Holds are safe to interrupt; other choices wait for that turn to stop.
569        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    // Preserve already supported consulted-chat answers. Independent evidence
591    // is required when consultation delivery cannot bind this conversation.
592    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
608/// Explain a refused binding without changing the authorization policy.
609fn 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
642/// Only structured internal recovery effects qualify. Approval and publication
643/// questions never acquire contextual consent, even if actions were attached.
644fn 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
652/// Agent-mediated interpretation, bound to a full real owner send and the
653/// immutable recommendation. Call under the same locks as `authorize_answer`.
654/// This does not execute recovery; the daemon retains all ownership and merge gates.
655pub 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
760/// Whether this turn needs the question-store write grant. Uses the same
761/// persisted evidence and effect policy as the CLI; the CLI rechecks on write.
762pub 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
787/// The complete standing-turn write grant, also used by sandbox regressions.
788/// Consultations and bound owner decisions need question/evidence locks;
789/// active consultations additionally need queue storage. Evidenced `done` and
790/// dismissal effects need their exact run history/state directories. Never
791/// grant the whole home or lift the sandbox.
792pub 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
850/// Compatibility name for callers of the earlier hold-only grant.
851pub fn authorized_holds(questions: &Questions, talk: &Talk) -> bool {
852    authorized_answers(questions, talk)
853}
854
855/// Queue state is only a diagnostic lead, never proof of lock ownership.
856pub(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/// Action consent is separate from consultation routing. This inventory uses
872/// execution effects (including structured actions), not only question nodes.
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub enum Consent {
875    /// Explicit single or batch instruction is sufficient.
876    Delegable,
877    /// Exact PR/head approval is required.
878    Merge,
879    /// Exact offered consequence must be confirmed individually.
880    Consequence,
881    /// Chosen public text needs explicit confirmation.
882    Posting,
883}
884
885/// Execution effects shared with question deputies. The issuer and seat are
886/// part of the policy: identical labels can have different consumers.
887pub fn destructive_effect(q: &Question, label: &str) -> bool {
888    if label == crate::land::HOLD && !q.actions.contains_key(label) {
889        return false;
890    }
891    // Conductor metadata describes the requested operation independently of
892    // generated choice wording. Completing a task or changing settings needs
893    // individual consent even when no structured ChoiceAction was attached.
894    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    // By label, not position: the retry question orders its choices differently.
918    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
933/// Classify every offered decision, including generic structured actions.
934pub 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
957/// Deputies use their authenticated question thread rather than a talk.
958/// A generic batch instruction cannot replace action-specific confirmation.
959pub(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
974/// Validate action-specific evidence independently of its delivery route.
975pub(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
1030/// The entire latest persisted owner send, when it is a usable instruction.
1031pub(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    // A newer queued send can retract the instruction. Wait until it is
1037    // drained rather than letting the running seat use older words. Explicitly
1038    // generated sends neither authorize nor retract an owner instruction.
1039    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        // A persona reply consumes the conversational turn. Never reuse an
1053        // older instruction just because a generated post followed that reply.
1054        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        // Older real sends can be reconstructed from recorded boundaries,
1074        // never from a guessed last paragraph. The whole-message grammar also
1075        // excludes generated legacy wrappers.
1076        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            // Old transcripts can prove a safe hold, but cannot establish what
1158            // mutable action/target was shown before manifests were persisted.
1159            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    // Whole-message grammar: substrings in conditional, reported, quoted or
1181    // retracted instructions cannot match. Keep this deliberately bounded.
1182    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    // One complete instruction, binding both the question and the offered
1226    // choice. Other actions (including closing the PR) are never inferred.
1227    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    // A row must match the entire offered label, including prefixes such as
1250    // `dismiss:` or `reimplement:`. Prefix/substring matches confer no authority.
1251    // A row must select exactly one offered choice, rather than list options.
1252    // Repeated or conflicting rows are ambiguous even if one recommends hold.
1253    let mut rows = Vec::new();
1254    // Use the same Markdown parser as the transcript renderer. Quoted lazy
1255    // continuations, nested fences and indented examples are not recommendations.
1256    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        // A heading, introduction or trailing paragraph may qualify several
1272        // rows without naming any question. Do not guess its scope: batch
1273        // authority requires the whole unquoted recommendation to be clear.
1274        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        // Preserve the legacy safe-hold wording without granting PR closure.
1343        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    /// Select the policy from the stored question node.
1404    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
1415/// Routed decisions use the chat agent's interpretation of the owner's request,
1416/// while retaining conversation ownership and open-record checks. Legacy
1417/// consultation consent below is unchanged.
1418fn 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
1439/// Validate guarded decisions answered by a running chat. Terminal answers
1440/// retain their existing behavior. Merge and posting policies both require
1441/// evidence from the correct conversation; posting also checks the selected text.
1442pub 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    // Only words the owner wrote after this question was handed over count.
1494    // The generated hand-over text is stored as an operator turn (possibly
1495    // coalesced with owner replies), so it is cut out by its own markers.
1496    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    // A reply still waiting in `pending` is newer than every stored turn.
1506    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/// The owner's newest message in a turn body (or the pending draft).
1582///
1583/// Replies queued while a turn runs are joined with a blank line, which an
1584/// owner's own blank lines cannot be told apart from, so `talk::queue` records
1585/// where each message starts (`breaks`) and the newest message is taken whole.
1586/// With no usable record (a turn stored before it existed) the only safe cut is
1587/// the last paragraph: that can reject a multi-paragraph approval, and can
1588/// accept a `hold` that was only the last paragraph of a longer message, which
1589/// is harmless because silence is a hold.
1590#[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    // A legacy message may span several sends. Only map the recorded cuts
1618    // when the message bodies still match the enclosing body exactly.
1619    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        // Legacy/invalid boundary records cannot distinguish paragraphs from
1680        // separate owner replies. Posting must not promote a reported final
1681        // paragraph into an instruction. Merge keeps its existing policy.
1682        if posting && words.contains("\n\n") {
1683            // Keep this as the newest evidence (with no usable quote), so an
1684            // ambiguous queued reply cannot fall back to an older instruction.
1685            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    // The message carrying the hand-over: everything before it is older.
1707    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
1723/// Is this message magi's own task or watch completion notice? It is never
1724/// the owner's word.
1725fn is_completion_notice(message: &str) -> bool {
1726    crate::prompt::is_completion_notice(message)
1727}
1728
1729/// Length of a consult stored before quoted markers were defused, which may
1730/// quote the end phrase or a whole earlier consult in its detail. The closer is
1731/// the generated tail's full final sentence (an owner reply does not repeat
1732/// it), and a real heading met before it opens a nested block that needs its
1733/// own closer. A block that never closes runs to the end of the text, which can
1734/// only exclude more.
1735fn 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(); // (at, 0 = open, else closer length)
1742    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/// The owner's own words in an operator turn, with magi's generated hand-over
1771/// text cut out. `drain` stores a queued consult as an operator turn, and
1772/// `talk::queue` joins an owner reply sent meanwhile onto the same draft, so
1773/// the turn's origin cannot be told from the turn as a whole. Each generated
1774/// block runs from its [`CHAT_CONSULT_HEADING`](crate::prompt::CHAT_CONSULT_HEADING)
1775/// to the first [`CHAT_CONSULT_END`](crate::prompt::CHAT_CONSULT_END) after it
1776/// (quoted markers are defused by `prompt::chat_consult`; a consult stored
1777/// before that, recognised by its opening sentence, ends at the last one before
1778/// the next heading); with no end the
1779/// block runs to the next heading. Only the blocks are cut: owner replies
1780/// between them are kept, in order, even when they contain the phrase. With `after_block_of` (the turn
1781/// carrying the hand-over itself) everything up to the end of the last block
1782/// naming that question id predates the hand-over and is dropped.
1783#[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    // Remove generated blocks without removing Markdown code indentation from
1790    // the owner's evidence. Merge retains its existing whitespace handling.
1791    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    // A real heading is the generated one: `# ` at a line start, followed by
1800    // the fixed opening sentence. A heading phrase quoted inside a question's
1801    // detail is not a boundary; it stays inside the block it is part of.
1802    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    // (start, end) of each generated block, the heading's own "# " included.
1817    // `chat_consult` defuses every marker quoted in a question's text, so the
1818    // first end phrase after a heading is the block's own. A block with no end
1819    // before the next heading runs up to that heading (or the text's end).
1820    // Turns stored before the markers were defused may still quote an end
1821    // phrase; their tail is not recognised as part of the block.
1822    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; // quoted inside the block above
1827        }
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            // Current format: quoted markers are defused, so the first end
1832            // phrase is the block's own.
1833            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/// What [`start_turn`] did.
1865#[derive(Debug, PartialEq, Eq)]
1866pub enum Started {
1867    /// The question was handed over and the chat's turn ran to its end.
1868    Answered,
1869    /// Another process holds the talk's turn lease; nothing was changed.
1870    Busy,
1871    /// The question had already been handed to the chat; nothing was changed.
1872    Nothing,
1873}
1874
1875/// [`begin`], then run the chat's turn here, under the talk's own lease.
1876///
1877/// The lease is taken *before* anything is written, so a talk somebody else is
1878/// running leaves no consult record and no draft behind (`Started::Busy`) and
1879/// the caller can simply try again later. A turn that fails after `begin`
1880/// leaves the record and the draft, so the chat can resume it.
1881pub 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
1901/// Run the talk's turns under `lease` until no draft is owed.
1902///
1903/// Other starters that found the lease held queued drafts and left them to
1904/// us, so drain until nothing is left (as the web's drain loop does). A failed
1905/// turn is kept and reported at the end, not returned at once: drafts accepted
1906/// meanwhile are still owed an answer.
1907pub 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        // A draft queued after the last drain, before the lease is gone, was
1925        // left to us by a starter that found the lease held. Release first,
1926        // then look again: whoever queues later either sees no lease (and
1927        // starts its own turn) or is seen by this re-check.
1928        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        // Somebody else took the lease meanwhile: they drain it.
1936        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        // Not a chat's answer, or another question: untouched.
2101        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    /// A `kind = "command"` agent running `script`; `LEASE` names the turn
2123    /// lease file so the script can look at it while the turn is in flight.
2124    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    /// Run `start_turn` with `script` as the agent and hand back its result,
2143    /// the store and the talk, for the caller to claim the lease afterwards.
2144    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        // exit 7 if the lease file is missing while the agent runs.
2166        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        // Not filed from a chat.
2206        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        // The conversation is gone, or no longer takes turns.
2213        assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
2214        // A closed conversation is reopened by `begin`, so it still qualifies.
2215        assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
2216        // No task owns the question.
2217        assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
2218        // Merge approvals can be consulted; release notices cannot.
2219        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        // A settled question has nothing left to ask.
2236        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(&notice, &talk), Some(talk.id.clone()));
2274        assert!(origin_talk(tasks, &[talk], &notice).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        // Neither the chat task nor f1 is in the queue any more.
2390        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        // The recorded id still has to name an open chat.
2394        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        // And the old JSON shape reads with the field absent.
2407        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        // Queued once, so the first-operator-turn title is not involved.
2501        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    /// A consult as stored before quoted markers were defused.
2600    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            // Keep the historical closer; current delivery has a different policy.
2607            .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        // Missing boundaries keep the conservative legacy posting policy.
2711        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        // Two queued replies: the second is the newest, whole.
2764        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        // No record (stored before breaks existed): last paragraph.
2771        assert_eq!(
2772            latest_message(one, None, None).as_deref(),
2773            Some("The checks look good")
2774        );
2775        // A record that does not fit the body is ignored.
2776        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(&notice, Some(&[]), None), None);
2787        assert_eq!(latest_message(&notice, 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        // No owner word after the hand-over: nothing to quote.
2845        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        // The safe default stays unguarded, as before.
2856        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        // No owner word after the hand-over: nothing to quote.
2888        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        // The safe default stays unguarded, as before.
2899        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(); // This fixture now replaces the generated draft.
2926        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            // No owner reply, nor text from the generated question, is evidence.
2976            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            // A newer report must also block fallback to an older instruction.
3056            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                // Check both current message boundaries and legacy records.
3074                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                // Drained transcript evidence must retain the same context.
3085                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        // An approval the owner gave before the hand-over is not reusable.
3154        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        // The drained consult draft is stored as an operator turn; it must not
3160        // count as the owner's words even though it contains "merge".
3161        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        // An owner reply coalesced onto the same draft still counts.
3176        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        // A later retraction in the same coalesced turn wins.
3192        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        // So does a reply still waiting in the draft.
3208        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        // Another question's hand-over queued after the retraction hides nothing.
3224        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}