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