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