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(
120        &mut talk,
121        talks,
122        &crate::prompt::chat_consult(&q),
123        Vec::new(),
124    ) {
125        let _ = questions.update(&q.id, |r| {
126            r.consult = None;
127            Ok(())
128        });
129        return Err(e).context("queue the question into the chat");
130    }
131    Ok(true)
132}
133
134/// Refuse a chat's answer to a merge approval whose task the operator held, as
135/// `ask_settle_cmd` does for a deputy: such a hold is lifted by the operator's
136/// button. The task is the one `q.run` names, directly or as one of its runs;
137/// none found (an unreadable task is not listed) does not refuse. The caller
138/// reads the queue before taking the question lock, so a hold placed in between
139/// is missed for that one call.
140pub fn refuse_operator_held(q: &Question, node: &str, tasks: &[crate::queue::Task]) -> Result<()> {
141    if q.node != crate::land::APPROVAL_NODE || node != CHAT_NODE {
142        return Ok(());
143    }
144    if let Some(task) = tasks
145        .iter()
146        .find(|t| t.id == q.run || t.runs.contains(&q.run))
147        && task.operator_held()
148    {
149        bail!(
150            "task {} is held by the operator; an answer cannot settle it",
151            task.short()
152        );
153    }
154    Ok(())
155}
156
157/// Validate a merge approval answered by a running chat. Terminal answers and
158/// other question types retain their existing behavior. Intent is judged by
159/// the chat; this gate requires evidence from the correct conversation.
160pub fn validate_answer(
161    q: &Question,
162    talks: &Talks,
163    run: &str,
164    node: &str,
165    reply: &str,
166    quote: Option<&str>,
167) -> Result<()> {
168    if q.node != crate::land::APPROVAL_NODE || node != CHAT_NODE {
169        return Ok(());
170    }
171    let consult = q
172        .consult
173        .as_ref()
174        .context("merge approval was not handed to a chat")?;
175    if consult.talk != run {
176        bail!("merge approval belongs to a different chat");
177    }
178    if !q.status.open() {
179        bail!("merge approval is no longer open");
180    }
181    let talk = talks.get(run)?;
182    if !talk.status.open() {
183        bail!("the consulted chat is closed");
184    }
185    // Only words the owner wrote after this question was handed over count.
186    // The generated hand-over text is stored as an operator turn (possibly
187    // coalesced with owner replies), so it is cut out by its own markers.
188    let at = talk
189        .turns
190        .iter()
191        .rposition(|t| {
192            t.who == talk::Who::Operator
193                && t.body.contains(crate::prompt::CHAT_CONSULT_HEADING)
194                && t.body.contains(&q.id)
195        })
196        .context("the question was not delivered to the chat yet")?;
197    // A reply still waiting in `pending` is newer than every stored turn.
198    let queued = latest_message(&talk.pending, talk.pending_breaks.as_deref(), None);
199    let stored = talk.turns[at..]
200        .iter()
201        .enumerate()
202        .rev()
203        .filter(|(_, t)| t.who == talk::Who::Operator)
204        .find_map(|(i, t)| {
205            latest_message(
206                &t.body,
207                t.breaks.as_deref(),
208                (i == 0).then_some(q.id.as_str()),
209            )
210        });
211    let latest = queued
212        .or(stored)
213        .context("no owner message after the question was handed to the chat")?;
214    let quote = quote
215        .map(str::trim)
216        .filter(|s| !s.is_empty())
217        .context("chat merge approval requires --quote from the owner's latest message")?;
218    let valid = match reply {
219        crate::land::APPROVE => latest.contains(quote),
220        crate::land::HOLD => {
221            latest.trim().eq_ignore_ascii_case(crate::land::HOLD)
222                && quote.eq_ignore_ascii_case(crate::land::HOLD)
223        }
224        _ => false,
225    };
226    if !valid {
227        bail!(
228            "merge requires a verbatim quote of the latest owner message; hold requires the whole message to be hold"
229        );
230    }
231    Ok(())
232}
233
234/// The owner's newest message in a turn body (or the pending draft).
235///
236/// Replies queued while a turn runs are joined with a blank line, which an
237/// owner's own blank lines cannot be told apart from, so `talk::queue` records
238/// where each message starts (`breaks`) and the newest message is taken whole.
239/// With no usable record (a turn stored before it existed) the only safe cut is
240/// the last paragraph: that can reject a multi-paragraph approval, and can
241/// accept a `hold` that was only the last paragraph of a longer message, which
242/// is harmless because silence is a hold.
243fn latest_message(
244    body: &str,
245    breaks: Option<&[usize]>,
246    after_block_of: Option<&str>,
247) -> Option<String> {
248    let cuts = breaks.filter(|b| {
249        b.windows(2).all(|w| w[0] < w[1])
250            && b.iter().all(|&o| {
251                o >= 2 && o <= body.len() && body.is_char_boundary(o) && body[..o].ends_with("\n\n")
252            })
253    });
254    let Some(cuts) = cuts else {
255        if is_task_notice(body) {
256            return None;
257        }
258        let words = owner_words(body, after_block_of);
259        return words
260            .rsplit("\n\n")
261            .map(str::trim)
262            .find(|p| !p.is_empty())
263            .map(str::to_owned);
264    };
265    let mut starts = vec![0];
266    starts.extend_from_slice(cuts);
267    let messages: Vec<&str> = starts
268        .iter()
269        .enumerate()
270        .map(|(i, &s)| {
271            let e = starts.get(i + 1).map_or(body.len(), |&n| n - 2);
272            &body[s..e]
273        })
274        .collect();
275    // The message carrying the hand-over: everything before it is older.
276    let first = after_block_of.map_or(0, |id| {
277        messages
278            .iter()
279            .rposition(|m| m.contains(crate::prompt::CHAT_CONSULT_HEADING) && m.contains(id))
280            .unwrap_or(0)
281    });
282    messages
283        .iter()
284        .enumerate()
285        .skip(first)
286        .rev()
287        .filter(|(_, m)| !is_task_notice(m))
288        .map(|(i, m)| owner_words(m, after_block_of.filter(|_| i == first)))
289        .find(|w| !w.is_empty())
290}
291
292/// Is this message magi's own completion notice (see
293/// [`crate::prompt::chat_task_notice`])? It is never the owner's word.
294fn is_task_notice(message: &str) -> bool {
295    message
296        .trim_start()
297        .strip_prefix("# ")
298        .is_some_and(|r| r.starts_with(crate::prompt::CHAT_TASK_NOTICE_HEADING))
299}
300
301/// Length of a consult stored before quoted markers were defused, which may
302/// quote the end phrase or a whole earlier consult in its detail. The closer is
303/// the generated tail's full final sentence (an owner reply does not repeat
304/// it), and a real heading met before it opens a nested block that needs its
305/// own closer. A block that never closes runs to the end of the text, which can
306/// only exclude more.
307fn legacy_len(rest: &str) -> usize {
308    use crate::prompt::CHAT_CONSULT_HEADING;
309    const CLOSERS: [&str; 2] = [
310        "cannot be revived. Do not edit the repository.",
311        "make: do not edit the repository.",
312    ];
313    let mut events: Vec<(usize, usize)> = Vec::new(); // (at, 0 = open, else closer length)
314    let first = rest.find(CHAT_CONSULT_HEADING).unwrap_or(0);
315    for (i, _) in rest.match_indices(CHAT_CONSULT_HEADING) {
316        if i > first
317            && rest[..i].ends_with("# ")
318            && rest[..i - 2].ends_with('\n')
319            && rest[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
320        {
321            events.push((i, 0));
322        }
323    }
324    for c in CLOSERS {
325        events.extend(rest.match_indices(c).map(|(i, _)| (i, c.len())));
326    }
327    events.sort_unstable();
328    let mut depth = 1usize;
329    for (at, len) in events {
330        if len == 0 {
331            depth += 1;
332        } else {
333            depth -= 1;
334            if depth == 0 {
335                return at + len;
336            }
337        }
338    }
339    rest.len()
340}
341
342/// The owner's own words in an operator turn, with magi's generated hand-over
343/// text cut out. `drain` stores a queued consult as an operator turn, and
344/// `talk::queue` joins an owner reply sent meanwhile onto the same draft, so
345/// the turn's origin cannot be told from the turn as a whole. Each generated
346/// block runs from its [`CHAT_CONSULT_HEADING`](crate::prompt::CHAT_CONSULT_HEADING)
347/// to the first [`CHAT_CONSULT_END`](crate::prompt::CHAT_CONSULT_END) after it
348/// (quoted markers are defused by `prompt::chat_consult`; a consult stored
349/// before that, recognised by its opening sentence, ends at the last one before
350/// the next heading); with no end the
351/// block runs to the next heading. Only the blocks are cut: owner replies
352/// between them are kept, in order, even when they contain the phrase. With `after_block_of` (the turn
353/// carrying the hand-over itself) everything up to the end of the last block
354/// naming that question id predates the hand-over and is dropped.
355fn owner_words(body: &str, after_block_of: Option<&str>) -> String {
356    use crate::prompt::{CHAT_CONSULT_END, CHAT_CONSULT_HEADING};
357    // A real heading is the generated one: `# ` at a line start, followed by
358    // the fixed opening sentence. A heading phrase quoted inside a question's
359    // detail is not a boundary; it stays inside the block it is part of.
360    let heads: Vec<usize> = body
361        .match_indices(CHAT_CONSULT_HEADING)
362        .map(|(i, _)| i)
363        .filter(|&i| {
364            let line_start = body[..i]
365                .strip_suffix("# ")
366                .is_some_and(|b| b.is_empty() || b.ends_with('\n'));
367            line_start
368                && body[i + CHAT_CONSULT_HEADING.len()..].starts_with("\n\nThe operator passed you")
369        })
370        .collect();
371    if heads.is_empty() {
372        return body.trim().to_owned();
373    }
374    // (start, end) of each generated block, the heading's own "# " included.
375    // `chat_consult` defuses every marker quoted in a question's text, so the
376    // first end phrase after a heading is the block's own. A block with no end
377    // before the next heading runs up to that heading (or the text's end).
378    // Turns stored before the markers were defused may still quote an end
379    // phrase; their tail is not recognised as part of the block.
380    let mut blocks: Vec<(usize, usize)> = Vec::new();
381    for &h in &heads {
382        let start = if body[..h].ends_with("# ") { h - 2 } else { h };
383        if blocks.last().is_some_and(|&(_, e)| start < e) {
384            continue; // quoted inside the block above
385        }
386        let rest = &body[h..];
387        let head = rest.split("\n\n## ").next().unwrap_or(rest);
388        let end = if head.contains(crate::prompt::CHAT_CONSULT_DEFUSED) {
389            // Current format: quoted markers are defused, so the first end
390            // phrase is the block's own.
391            rest.find(CHAT_CONSULT_END)
392                .map_or(body.len(), |p| h + p + CHAT_CONSULT_END.len())
393        } else {
394            h + legacy_len(rest)
395        };
396        blocks.push((start, end));
397    }
398    let mut from = 0;
399    if let Some(id) = after_block_of {
400        if let Some(&(_, end)) = blocks.iter().rfind(|&&(s, e)| body[s..e].contains(id)) {
401            from = end;
402        } else if let Some(&(_, end)) = blocks.last() {
403            from = end;
404        }
405    }
406    let mut parts = Vec::new();
407    let mut at = from;
408    for &(s, e) in &blocks {
409        if s >= at {
410            parts.push(body[at..s].trim());
411        }
412        at = at.max(e);
413    }
414    parts.push(body[at..].trim());
415    parts
416        .into_iter()
417        .filter(|p| !p.is_empty())
418        .collect::<Vec<_>>()
419        .join("\n\n")
420}
421
422/// What [`start_turn`] did.
423#[derive(Debug, PartialEq, Eq)]
424pub enum Started {
425    /// The question was handed over and the chat's turn ran to its end.
426    Answered,
427    /// Another process holds the talk's turn lease; nothing was changed.
428    Busy,
429    /// The question had already been handed to the chat; nothing was changed.
430    Nothing,
431}
432
433/// [`begin`], then run the chat's turn here, under the talk's own lease.
434///
435/// The lease is taken *before* anything is written, so a talk somebody else is
436/// running leaves no consult record and no draft behind (`Started::Busy`) and
437/// the caller can simply try again later. A turn that fails after `begin`
438/// leaves the record and the draft, so the chat can resume it.
439pub async fn start_turn(
440    questions: &Questions,
441    talks: &Talks,
442    q: &Question,
443    talk: &Talk,
444    cfg: &Config,
445) -> Result<Started> {
446    if q.consult.is_some() {
447        return Ok(Started::Nothing);
448    }
449    let Some(lease) = talks.claim_turn(&talk.id)? else {
450        return Ok(Started::Busy);
451    };
452    if !begin(questions, talks, q, talk)? {
453        return Ok(Started::Nothing);
454    }
455    drain_turns(talks, &talk.id, lease, cfg).await?;
456    Ok(Started::Answered)
457}
458
459/// Run the talk's turns under `lease` until no draft is owed.
460///
461/// Other starters that found the lease held queued drafts and left them to
462/// us, so drain until nothing is left (as the web's drain loop does). A failed
463/// turn is kept and reported at the end, not returned at once: drafts accepted
464/// meanwhile are still owed an answer.
465pub async fn drain_turns(
466    talks: &Talks,
467    talk_id: &str,
468    mut lease: talk::TurnLease,
469    cfg: &Config,
470) -> Result<()> {
471    let mut talk = talks.get(talk_id)?;
472    let mut failed = None;
473    loop {
474        while let Some(text) = talk::drain(&mut talk, talks)? {
475            if let Err(e) = talk::respond(&lease, &mut talk, talks, cfg, &text).await {
476                failed.get_or_insert(e);
477            }
478            if !lease.beat()? {
479                bail!("the turn lease for chat {} was lost", talk.short());
480            }
481        }
482        // A draft queued after the last drain, before the lease is gone, was
483        // left to us by a starter that found the lease held. Release first,
484        // then look again: whoever queues later either sees no lease (and
485        // starts its own turn) or is seen by this re-check.
486        drop(lease);
487        talk = talks.get(&talk.id)?;
488        let owed = talk.status.open()
489            && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
490        if !owed {
491            break;
492        }
493        // Somebody else took the lease meanwhile: they drain it.
494        let Some(again) = talks.claim_turn(&talk.id)? else {
495            break;
496        };
497        lease = again;
498    }
499    if let Some(e) = failed {
500        return Err(e);
501    }
502    Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507    use std::collections::BTreeMap;
508    use std::path::PathBuf;
509
510    use super::*;
511    use crate::config::{AgentKind, AgentSpec, Config};
512    use crate::queue::Source;
513    use crate::talk::TalkStatus;
514
515    const RUN: &str = "20260902-000000-beef";
516
517    fn talks() -> (tempfile::TempDir, Talks, Talk) {
518        let tmp = tempfile::tempdir().expect("tempdir");
519        let store = Talks::at(tmp.path().join("talks"));
520        let cfg = Config {
521            agents: vec![AgentSpec {
522                id: "mock".to_owned(),
523                kind: AgentKind::Command,
524                model: None,
525                command: vec!["true".to_owned()],
526                extra_args: Vec::new(),
527                env: BTreeMap::new(),
528                prompt_delivery: None,
529            }],
530            ..Config::default()
531        };
532        let talk = talk::begin(&store, &cfg, tmp.path().to_path_buf(), Some("mock")).expect("talk");
533        (tmp, store, talk)
534    }
535
536    fn task(source: Source) -> Task {
537        let mut t = Task::new(
538            "t".to_owned(),
539            "Do it".to_owned(),
540            PathBuf::from("/repo"),
541            source,
542        );
543        t.start(RUN.to_owned());
544        t
545    }
546
547    fn from_chat(talk: &Talk) -> Task {
548        task(Source::Agent {
549            run: talk.id.clone(),
550            node: CHAT_NODE.to_owned(),
551        })
552    }
553
554    fn question(node: &str) -> Question {
555        Question::new(
556            RUN.to_owned(),
557            node.to_owned(),
558            "impl-A".to_owned(),
559            "Which backend?".to_owned(),
560            "SQLite is simpler.".to_owned(),
561            vec!["SQLite".to_owned(), "Redis".to_owned()],
562        )
563    }
564
565    #[test]
566    fn chat_answer_is_refused_for_an_operator_held_task() {
567        let mut task = crate::queue::Task::new(
568            "t".into(),
569            "i".into(),
570            std::path::PathBuf::from("."),
571            crate::queue::Source::Human,
572        );
573        task.runs.push("run-1".into());
574        let mut q = question(crate::land::APPROVAL_NODE);
575        q.run = "run-1".into();
576        let check = |t: &crate::queue::Task, node: &str| {
577            refuse_operator_held(&q, node, std::slice::from_ref(t))
578        };
579        assert!(check(&task, CHAT_NODE).is_ok());
580        task.hold_machine(None);
581        assert!(check(&task, CHAT_NODE).is_ok());
582        task.hold_manual(None);
583        assert!(check(&task, CHAT_NODE).is_err());
584        // Not a chat's answer, or another question: untouched.
585        assert!(check(&task, "implement").is_ok());
586        assert!(refuse_operator_held(&q, CHAT_NODE, &[]).is_ok());
587    }
588
589    #[tokio::test]
590    async fn start_turn_is_busy_and_writes_nothing_while_the_lease_is_held() {
591        let (tmp, store, talk) = talks();
592        let questions = Questions::at(tmp.path().join("questions"));
593        let mut q = question("implement");
594        questions.put(&mut q).expect("put");
595        let held = store.claim_turn(&talk.id).expect("claim").expect("first");
596
597        let got = start_turn(&questions, &store, &q, &talk, &Config::default())
598            .await
599            .expect("start");
600        assert_eq!(got, Started::Busy);
601        assert!(questions.get(&q.id).unwrap().consult.is_none());
602        assert!(store.get(&talk.id).unwrap().pending.is_empty());
603        drop(held);
604    }
605
606    /// A `kind = "command"` agent running `script`; `LEASE` names the turn
607    /// lease file so the script can look at it while the turn is in flight.
608    fn scripted(tmp: &std::path::Path, script: &str, lease: &std::path::Path) -> Config {
609        let path = tmp.join("mock-consult-agent.sh");
610        std::fs::write(&path, script).expect("write mock");
611        Config {
612            agents: vec![AgentSpec {
613                id: "mock".to_owned(),
614                kind: AgentKind::Command,
615                model: None,
616                command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
617                extra_args: Vec::new(),
618                env: BTreeMap::from([("LEASE".to_owned(), lease.to_string_lossy().into_owned())]),
619                prompt_delivery: None,
620            }],
621            ..Config::default()
622        }
623    }
624
625    /// Run `start_turn` with `script` as the agent and hand back its result,
626    /// the store and the talk, for the caller to claim the lease afterwards.
627    async fn run_turn(
628        script: &str,
629    ) -> (
630        tempfile::TempDir,
631        Result<Started>,
632        Questions,
633        Question,
634        Talk,
635    ) {
636        crate::run::set_home(crate::run::test_home());
637        let (tmp, store, talk) = talks();
638        let questions = Questions::at(tmp.path().join("questions"));
639        let mut q = question("implement");
640        questions.put(&mut q).expect("put");
641        let cfg = scripted(tmp.path(), script, &store.turn_path(&talk.id));
642        let got = start_turn(&questions, &store, &q, &talk, &cfg).await;
643        (tmp, got, questions, q, talk)
644    }
645
646    #[tokio::test]
647    async fn the_lease_is_held_during_the_turn_and_free_once_it_ends() {
648        // exit 7 if the lease file is missing while the agent runs.
649        let (tmp, got, _questions, _q, talk) =
650            run_turn("#!/bin/sh\ncat >/dev/null\n[ -f \"$LEASE\" ] || exit 7\nprintf 'ok\\n'\n")
651                .await;
652        assert_eq!(got.expect("turn"), Started::Answered);
653        let other = Talks::at(tmp.path().join("talks"));
654        assert!(
655            other.claim_turn(&talk.id).expect("claim").is_some(),
656            "free once the turn ended"
657        );
658    }
659
660    #[tokio::test]
661    async fn the_lease_is_free_again_after_a_failed_turn() {
662        let (tmp, got, questions, q, talk) = run_turn("#!/bin/sh\ncat >/dev/null\nexit 3\n").await;
663        assert!(got.is_err(), "the agent failed");
664        assert!(
665            questions.get(&q.id).unwrap().consult.is_some(),
666            "the turn got as far as running"
667        );
668        let other = Talks::at(tmp.path().join("talks"));
669        assert!(
670            other.claim_turn(&talk.id).expect("claim").is_some(),
671            "free after the failed turn"
672        );
673    }
674
675    #[test]
676    fn origin_talk_is_decided_in_one_table() {
677        let (_tmp, _store, talk) = talks();
678        let mut closed = talk.clone();
679        closed.status = TalkStatus::Closed;
680        let chat = from_chat(&talk);
681        let q = question("implement");
682
683        let hit = |tasks: &[Task], talks: &[Talk], q: &Question| origin_talk(tasks, talks, q);
684        assert_eq!(
685            hit(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).map(|t| t.id),
686            Some(talk.id.clone())
687        );
688        // Not filed from a chat.
689        assert!(hit(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
690        let other = task(Source::Agent {
691            run: talk.id.clone(),
692            node: "implement".to_owned(),
693        });
694        assert!(hit(&[other], std::slice::from_ref(&talk), &q).is_none());
695        // The conversation is gone, or no longer takes turns.
696        assert!(hit(std::slice::from_ref(&chat), &[], &q).is_none());
697        // A closed conversation is reopened by `begin`, so it still qualifies.
698        assert!(hit(std::slice::from_ref(&chat), &[closed], &q).is_some());
699        // No task owns the question.
700        assert!(hit(&[], std::slice::from_ref(&talk), &q).is_none());
701        // Merge approvals can be consulted; release notices cannot.
702        assert!(
703            hit(
704                std::slice::from_ref(&chat),
705                std::slice::from_ref(&talk),
706                &question(crate::land::APPROVAL_NODE)
707            )
708            .is_some()
709        );
710        assert!(
711            hit(
712                std::slice::from_ref(&chat),
713                std::slice::from_ref(&talk),
714                &question(crate::bump::NOTICE_NODE)
715            )
716            .is_none()
717        );
718        // A settled question has nothing left to ask.
719        let mut answered = question("implement");
720        answered
721            .answer(crate::ask::Answer::Choice("Redis".to_owned()))
722            .unwrap();
723        assert!(hit(&[chat], &[talk], &answered).is_none());
724    }
725
726    #[test]
727    fn approval_origin_requires_an_open_chat_task_and_question() {
728        let (_tmp, _store, talk) = talks();
729        let chat = from_chat(&talk);
730        let mut q = question(crate::land::APPROVAL_NODE);
731        assert!(origin_talk(&[task(Source::Human)], std::slice::from_ref(&talk), &q).is_none());
732        assert!(
733            origin_talk(
734                &[task(Source::Agent {
735                    run: talk.id.clone(),
736                    node: "implement".into(),
737                })],
738                std::slice::from_ref(&talk),
739                &q
740            )
741            .is_none()
742        );
743        assert!(origin_talk(std::slice::from_ref(&chat), &[], &q).is_none());
744        let mut closed = talk.clone();
745        closed.status = TalkStatus::Closed;
746        assert!(origin_talk(std::slice::from_ref(&chat), &[closed], &q).is_some());
747        q.abandon("expired");
748        assert!(
749            origin_talk(std::slice::from_ref(&chat), std::slice::from_ref(&talk), &q).is_none()
750        );
751        let mut q = question(crate::land::APPROVAL_NODE);
752        q.answer(crate::ask::Answer::Choice("Redis".into()))
753            .unwrap();
754        assert!(origin_talk(&[chat], &[talk], &q).is_none());
755    }
756
757    #[test]
758    fn a_conductor_question_is_found_through_the_task_id() {
759        let (_tmp, _store, talk) = talks();
760        let chat = from_chat(&talk);
761        let mut q = question(crate::conduct::NODE);
762        q.run = chat.id.clone();
763        assert!(origin_talk(&[chat], &[talk], &q).is_some());
764    }
765
766    fn followup_of(parent: &Task, n: u32) -> Task {
767        let mut t = Task::new(
768            "f".to_owned(),
769            "Fix".to_owned(),
770            PathBuf::from("/repo"),
771            Source::Agent {
772                run: format!("merged-{n}"),
773                node: "followup".to_owned(),
774            },
775        );
776        t.start(format!("run-f{n}"));
777        t.followup = Some(crate::queue::FollowUp {
778            run: format!("merged-{n}"),
779            origin_task: Some(parent.id.clone()),
780            pr: "https://example.invalid/pr/1".to_owned(),
781            findings: Vec::new(),
782            generation: n,
783        });
784        t
785    }
786
787    fn q_for(t: &Task) -> Question {
788        let mut q = question("implement");
789        q.run = t.runs[0].clone();
790        q
791    }
792
793    #[test]
794    fn a_followup_traces_back_to_the_chat() {
795        let (_tmp, _store, talk) = talks();
796        let mut chat = from_chat(&talk);
797        chat.runs = vec!["chat-run".to_owned()];
798        let f1 = followup_of(&chat, 1);
799        let f2 = followup_of(&f1, 2);
800        let ts = [chat, f1.clone(), f2.clone()];
801        let id = Some(talk.id.clone());
802        let tk = std::slice::from_ref(&talk);
803        assert_eq!(origin_talk(&ts, tk, &q_for(&f1)).map(|t| t.id), id);
804        assert_eq!(origin_talk(&ts, tk, &q_for(&f2)).map(|t| t.id), id);
805    }
806
807    #[test]
808    fn a_followup_without_origin_task_is_found_through_runs() {
809        let (_tmp, _store, talk) = talks();
810        let mut chat = from_chat(&talk);
811        chat.runs = vec!["merged-1".to_owned()];
812        let mut f1 = followup_of(&chat, 1);
813        f1.followup.as_mut().unwrap().origin_task = None;
814        let q = q_for(&f1);
815        assert!(origin_talk(&[chat, f1], &[talk], &q).is_some());
816    }
817
818    #[test]
819    fn a_dangling_origin_task_has_no_chat() {
820        let (_tmp, _store, talk) = talks();
821        let mut chat = from_chat(&talk);
822        chat.runs = vec!["chat-run".to_owned()];
823        let mut f1 = followup_of(&chat, 1);
824        f1.followup.as_mut().unwrap().origin_task = Some("gone".to_owned());
825        let q = q_for(&f1);
826        assert!(origin_talk(&[chat, f1], &[talk], &q).is_none());
827    }
828
829    #[test]
830    fn a_recorded_chat_survives_deleted_ancestors() {
831        let (_tmp, _store, talk) = talks();
832        let chat = from_chat(&talk);
833        assert_eq!(chat.origin_chat.as_deref(), Some(talk.id.as_str()));
834        let mut f1 = followup_of(&chat, 1);
835        f1.origin_chat = chat.origin_chat.clone();
836        let mut f2 = followup_of(&f1, 2);
837        f2.origin_chat = f1.origin_chat.clone();
838        // Neither the chat task nor f1 is in the queue any more.
839        let q = q_for(&f2);
840        let got = origin_talk(std::slice::from_ref(&f2), std::slice::from_ref(&talk), &q);
841        assert_eq!(got.map(|t| t.id), Some(talk.id.clone()));
842        // The recorded id still has to name an open chat.
843        assert!(origin_talk(&[f2], &[], &q).is_none());
844    }
845
846    #[test]
847    fn a_task_without_the_field_still_walks_the_ancestry() {
848        let (_tmp, _store, talk) = talks();
849        let mut chat = from_chat(&talk);
850        chat.origin_chat = None;
851        let mut f1 = followup_of(&chat, 1);
852        f1.origin_chat = None;
853        let q = q_for(&f1);
854        assert!(origin_talk(&[chat, f1.clone()], std::slice::from_ref(&talk), &q).is_some());
855        // And the old JSON shape reads with the field absent.
856        let mut v = serde_json::to_value(&f1).unwrap();
857        v.as_object_mut().unwrap().remove("origin_chat");
858        let back: Task = serde_json::from_value(v).unwrap();
859        assert!(back.origin_chat.is_none());
860    }
861
862    #[test]
863    fn a_followup_cycle_ends_without_a_chat() {
864        let (_tmp, _store, talk) = talks();
865        let mut a = followup_of(&from_chat(&talk), 1);
866        let mut b = followup_of(&a, 2);
867        a.followup.as_mut().unwrap().origin_task = Some(b.id.clone());
868        b.followup.as_mut().unwrap().origin_task = Some(a.id.clone());
869        let q = q_for(&a);
870        assert!(origin_talk(&[a, b], &[talk], &q).is_none());
871    }
872
873    #[test]
874    fn pending_consults_follow_the_store_not_the_turn_text() {
875        let (tmp, store, talk) = talks();
876        let questions = Questions::at(tmp.path().join("questions"));
877        assert!(!pending_consults(&questions, &talk.id), "empty store");
878
879        let mut plain = question("implement");
880        questions.put(&mut plain).unwrap();
881        assert!(!pending_consults(&questions, &talk.id), "no consult record");
882
883        let mut q = question("implement");
884        questions.put(&mut q).unwrap();
885        begin(&questions, &store, &q, &talk).unwrap();
886        assert!(pending_consults(&questions, &talk.id));
887        assert!(!pending_consults(&questions, "other-talk"));
888
889        questions
890            .update(&q.id, |q| {
891                q.abandon("test");
892                Ok(())
893            })
894            .unwrap();
895        assert!(!pending_consults(&questions, &talk.id), "closed question");
896    }
897
898    #[test]
899    fn begin_reopens_a_closed_chat_before_queueing() {
900        let (tmp, store, mut talk) = talks();
901        let questions = Questions::at(tmp.path().join("questions"));
902        let mut q = question("implement");
903        questions.put(&mut q).unwrap();
904        talk::close(&mut talk, &store).unwrap();
905        assert!(!store.get(&talk.id).unwrap().status.open());
906
907        assert!(begin(&questions, &store, &q, &talk).unwrap());
908
909        let after = store.get(&talk.id).unwrap();
910        assert!(after.status.open(), "the chat was reopened");
911        assert!(after.pending.contains(&q.id), "{}", after.pending);
912        assert!(questions.get(&q.id).unwrap().consult.is_some());
913    }
914
915    #[test]
916    fn begin_queues_once_and_leaves_the_question_open() {
917        let (tmp, store, talk) = talks();
918        let questions = Questions::at(tmp.path().join("questions"));
919        let mut q = question("implement");
920        questions.put(&mut q).unwrap();
921
922        assert!(begin(&questions, &store, &q, &talk).unwrap());
923        assert!(!begin(&questions, &store, &q, &talk).unwrap(), "idempotent");
924
925        let after = questions.get(&q.id).unwrap();
926        assert!(after.status.open());
927        assert!(after.thread.is_empty());
928        assert_eq!(after.choices, q.choices, "choices are not touched");
929        assert_eq!(
930            after.consult.as_ref().map(|c| c.talk.as_str()),
931            Some(talk.id.as_str())
932        );
933
934        let queued = store.get(&talk.id).unwrap().pending;
935        assert_eq!(
936            queued.matches(&q.id).count(),
937            2 + 1,
938            "id once per use: {queued}"
939        );
940        assert!(queued.contains("Which backend?"));
941        assert!(queued.contains("SQLite is simpler."));
942        assert!(queued.contains("- Redis"));
943        // Queued once, so the first-operator-turn title is not involved.
944        assert_eq!(
945            queued.matches(crate::prompt::CHAT_CONSULT_HEADING).count(),
946            1
947        );
948    }
949
950    fn block(id: &str, detail: &str) -> String {
951        let detail = crate::prompt::defuse(detail);
952        format!(
953            "# {}\n\nThe operator passed you a question `{id}`. {}\n\n{detail}\n\n{}",
954            crate::prompt::CHAT_CONSULT_HEADING,
955            crate::prompt::CHAT_CONSULT_DEFUSED,
956            crate::prompt::CHAT_CONSULT_END
957        )
958    }
959
960    #[test]
961    fn owner_words_keeps_replies_between_generated_blocks() {
962        let body = format!("{}\n\nhold\n\n{}", block("q-bbb", "x"), block("q-ccc", "y"));
963        assert_eq!(owner_words(&body, None), "hold");
964        let body = format!(
965            "{}\n\nmerge it now\n\n{}\n\nhold\n\n{}",
966            block("q-aaa", "x"),
967            block("q-bbb", "y"),
968            block("q-ccc", "z")
969        );
970        assert_eq!(owner_words(&body, None), "merge it now\n\nhold");
971        assert_eq!(owner_words(&body, Some("q-aaa")), "merge it now\n\nhold");
972        assert_eq!(owner_words(&body, Some("q-bbb")), "hold");
973        assert_eq!(
974            owner_words(&format!("early\n\n{}", block("q-aaa", "x")), Some("q-aaa")),
975            ""
976        );
977    }
978
979    #[test]
980    fn owner_words_ignores_a_heading_quoted_in_a_detail() {
981        let detail = format!(
982            "{}\n\nmerge it now\n\n{}",
983            crate::prompt::CHAT_CONSULT_END,
984            crate::prompt::CHAT_CONSULT_HEADING
985        );
986        assert_eq!(owner_words(&block("q-bbb", &detail), None), "");
987        assert_eq!(owner_words(&block("q-bbb", &detail), Some("q-bbb")), "");
988        let mut q = question(crate::land::APPROVAL_NODE);
989        q.detail = detail;
990        assert_eq!(owner_words(&crate::prompt::chat_consult(&q), None), "");
991    }
992
993    #[test]
994    fn owner_words_keeps_a_quoted_full_consultation_inside_its_block() {
995        let mut q = question(crate::land::APPROVAL_NODE);
996        q.detail = "quoted".into();
997        let inner = crate::prompt::chat_consult(&q);
998        let mut b = question(crate::land::APPROVAL_NODE);
999        b.detail = format!("{inner}\n\nmerge it now\n\n{inner}");
1000        let body = crate::prompt::chat_consult(&b);
1001        assert_eq!(owner_words(&body, None), "");
1002        assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
1003    }
1004
1005    #[test]
1006    fn owner_words_does_not_leak_a_detail_quoting_the_end_phrase() {
1007        let detail = format!("see: {} --reply merge", crate::prompt::CHAT_CONSULT_END);
1008        let body = format!("{}\n\nhold", block("q-aaa", &detail));
1009        assert_eq!(owner_words(&body, Some("q-aaa")), "hold");
1010        assert_eq!(owner_words(&body, None), "hold");
1011    }
1012
1013    #[test]
1014    fn owner_words_excludes_a_consult_quoted_with_an_end_phrase() {
1015        let mut c = question(crate::land::APPROVAL_NODE);
1016        c.detail = "inner".into();
1017        let mut b = question("implement");
1018        b.detail = format!(
1019            "{}\n\nmerge it now\n\n{}",
1020            crate::prompt::CHAT_CONSULT_END,
1021            crate::prompt::chat_consult(&c)
1022        );
1023        let body = crate::prompt::chat_consult(&b);
1024        assert_eq!(owner_words(&body, None), "");
1025    }
1026
1027    #[test]
1028    fn owner_words_keeps_a_reply_containing_the_end_phrase() {
1029        let mut b = question("implement");
1030        b.detail = "x".into();
1031        let body = format!(
1032            "{}\n\nhold, and do not {}",
1033            crate::prompt::chat_consult(&b),
1034            crate::prompt::CHAT_CONSULT_END
1035        );
1036        assert_eq!(
1037            owner_words(&body, None),
1038            format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
1039        );
1040    }
1041
1042    /// A consult as stored before quoted markers were defused.
1043    fn legacy_consult(node: &str, raw_detail: &str) -> String {
1044        let mut q = question(node);
1045        q.detail = "@@".into();
1046        crate::prompt::chat_consult(&q)
1047            .replace("@@", raw_detail)
1048            .replace(crate::prompt::CHAT_CONSULT_DEFUSED, "It is still open.")
1049    }
1050
1051    #[test]
1052    fn owner_words_excludes_a_legacy_consult_quoting_the_end_phrase() {
1053        for node in [crate::land::APPROVAL_NODE, "implement"] {
1054            let detail = format!("see {} and more", crate::prompt::CHAT_CONSULT_END);
1055            assert_eq!(owner_words(&legacy_consult(node, &detail), None), "");
1056        }
1057    }
1058
1059    #[test]
1060    fn owner_words_excludes_a_legacy_consult_nesting_a_legacy_consult() {
1061        let inner = legacy_consult("implement", "x");
1062        let detail = format!(
1063            "{}\n\nmerge it now\n\n{inner}",
1064            crate::prompt::CHAT_CONSULT_END
1065        );
1066        let body = legacy_consult(crate::land::APPROVAL_NODE, &detail);
1067        assert_eq!(owner_words(&body, None), "");
1068        assert_eq!(owner_words(&format!("{body}\n\nhold"), None), "hold");
1069    }
1070
1071    #[test]
1072    fn owner_words_keeps_a_reply_after_a_legacy_consult_containing_the_end_phrase() {
1073        let body = format!(
1074            "{}\n\nhold, and do not {}",
1075            legacy_consult("implement", "x"),
1076            crate::prompt::CHAT_CONSULT_END
1077        );
1078        assert_eq!(
1079            owner_words(&body, None),
1080            format!("hold, and do not {}", crate::prompt::CHAT_CONSULT_END)
1081        );
1082    }
1083
1084    #[test]
1085    fn latest_message_keeps_message_boundaries() {
1086        let one = "Merge this pull request now\n\nThe checks look good";
1087        assert_eq!(latest_message(one, Some(&[]), None).as_deref(), Some(one));
1088        let hold = "Explain what this option means:\n\nhold";
1089        assert_eq!(latest_message(hold, Some(&[]), None).as_deref(), Some(hold));
1090        // Two queued replies: the second is the newest, whole.
1091        let two = "merge it now\n\nhold";
1092        let at = "merge it now\n\n".len();
1093        assert_eq!(
1094            latest_message(two, Some(&[at]), None).as_deref(),
1095            Some("hold")
1096        );
1097        // No record (stored before breaks existed): last paragraph.
1098        assert_eq!(
1099            latest_message(one, None, None).as_deref(),
1100            Some("The checks look good")
1101        );
1102        // A record that does not fit the body is ignored.
1103        assert_eq!(
1104            latest_message(two, Some(&[999]), None).as_deref(),
1105            Some("hold")
1106        );
1107    }
1108
1109    #[test]
1110    fn chat_consult_has_each_marker_once() {
1111        use crate::prompt::{CHAT_CONSULT_END as E, CHAT_CONSULT_HEADING as H};
1112        for node in ["implement", crate::land::APPROVAL_NODE] {
1113            let mut q = question(node);
1114            let quoted = format!("{H} {E}");
1115            q.summary = quoted.clone();
1116            q.detail = quoted.clone();
1117            q.choices = vec![quoted.clone(), "b".into()];
1118            let s = crate::prompt::chat_consult(&q);
1119            assert_eq!(s.matches(H).count(), 1, "{s}");
1120            assert_eq!(s.matches(E).count(), 1, "{s}");
1121        }
1122    }
1123
1124    #[test]
1125    fn approval_consult_waits_for_latest_owner_confirmation() {
1126        let (tmp, store, mut talk) = talks();
1127        let questions = Questions::at(tmp.path().join("questions"));
1128        let mut q = question(crate::land::APPROVAL_NODE);
1129        q.choices = vec![crate::land::APPROVE.into(), crate::land::HOLD.into()];
1130        questions.put(&mut q).unwrap();
1131        assert!(begin(&questions, &store, &q, &talk).unwrap());
1132        assert!(!begin(&questions, &store, &q, &talk).unwrap());
1133        q = questions.get(&q.id).unwrap();
1134        assert!(q.status.open());
1135        assert!(q.answer.is_none());
1136        assert!(q.thread.is_empty());
1137        let queued = store.get(&talk.id).unwrap().pending;
1138        assert!(queued.contains("Never answer it yourself"));
1139        assert!(queued.contains("Silence holds"));
1140        assert!(queued.contains("--reply merge --quote"));
1141        assert!(!queued.contains("answer it yourself with"));
1142
1143        let owner_turn = |body: &str| talk::Turn {
1144            breaks: Some(Vec::new()),
1145            posted_by: None,
1146            who: talk::Who::Operator,
1147            body: body.into(),
1148            at: Timestamp::now(),
1149            attachments: Vec::new(),
1150            usage: None,
1151        };
1152        // An approval the owner gave before the hand-over is not reusable.
1153        let mut old = talk.clone();
1154        old.turns
1155            .insert(0, owner_turn("Please merge PR 12 after review"));
1156        store.put(&mut old).unwrap();
1157        talk.turns = old.turns.clone();
1158        // The drained consult draft is stored as an operator turn; it must not
1159        // count as the owner's words even though it contains "merge".
1160        talk.turns.push(owner_turn(&queued));
1161        store.put(&mut talk).unwrap();
1162        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "merge", Some("merge")).is_err());
1163        assert!(
1164            validate_answer(
1165                &q,
1166                &store,
1167                &talk.id,
1168                CHAT_NODE,
1169                "merge",
1170                Some("Please merge PR 12")
1171            )
1172            .is_err()
1173        );
1174        // An owner reply coalesced onto the same draft still counts.
1175        let n = talk.turns.len();
1176        talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
1177        talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
1178        store.put(&mut talk).unwrap();
1179        assert!(
1180            validate_answer(
1181                &q,
1182                &store,
1183                &talk.id,
1184                CHAT_NODE,
1185                "merge",
1186                Some("merge it now")
1187            )
1188            .is_ok()
1189        );
1190        // A later retraction in the same coalesced turn wins.
1191        talk.turns[n - 1].body = format!("{queued}\n\nmerge it now\n\nhold");
1192        talk.turns[n - 1].breaks = Some(vec![queued.len() + 2, queued.len() + 2 + 14]);
1193        store.put(&mut talk).unwrap();
1194        assert!(
1195            validate_answer(
1196                &q,
1197                &store,
1198                &talk.id,
1199                CHAT_NODE,
1200                "merge",
1201                Some("merge it now")
1202            )
1203            .is_err()
1204        );
1205        assert!(validate_answer(&q, &store, &talk.id, CHAT_NODE, "hold", Some("hold")).is_ok());
1206        // So does a reply still waiting in the draft.
1207        talk.turns[n - 1].body = format!("{queued}\n\nmerge it now");
1208        talk.turns[n - 1].breaks = Some(vec![queued.len() + 2]);
1209        talk.pending = "hold".into();
1210        store.put(&mut talk).unwrap();
1211        assert!(
1212            validate_answer(
1213                &q,
1214                &store,
1215                &talk.id,
1216                CHAT_NODE,
1217                "merge",
1218                Some("merge it now")
1219            )
1220            .is_err()
1221        );
1222        // Another question's hand-over queued after the retraction hides nothing.
1223        talk.pending = format!("hold\n\n{queued}");
1224        store.put(&mut talk).unwrap();
1225        assert!(
1226            validate_answer(
1227                &q,
1228                &store,
1229                &talk.id,
1230                CHAT_NODE,
1231                "merge",
1232                Some("merge it now")
1233            )
1234            .is_err()
1235        );
1236        talk.pending.clear();
1237        talk.turns[n - 1].body = queued.clone();
1238        store.put(&mut talk).unwrap();
1239        talk.turns
1240            .push(owner_turn("Merge this pull request please"));
1241        store.put(&mut talk).unwrap();
1242        let check = |q: &Question, run: &str, reply: &str, quote: Option<&str>| {
1243            validate_answer(q, &store, run, CHAT_NODE, reply, quote)
1244        };
1245        assert!(check(&q, "other-talk", "merge", Some("Merge this")).is_err());
1246        assert!(check(&q, &talk.id, "merge", None).is_err());
1247        assert!(check(&q, &talk.id, "merge", Some("never said")).is_err());
1248        assert!(
1249            check(
1250                &q,
1251                &talk.id,
1252                "merge",
1253                Some("Merge this pull request please")
1254            )
1255            .is_ok()
1256        );
1257        assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
1258        talk.turns
1259            .push(owner_turn("Wait, explain the checks first"));
1260        store.put(&mut talk).unwrap();
1261        assert!(
1262            check(
1263                &q,
1264                &talk.id,
1265                "merge",
1266                Some("Merge this pull request please")
1267            )
1268            .is_err()
1269        );
1270        talk.turns.push(owner_turn("Please hold"));
1271        store.put(&mut talk).unwrap();
1272        assert!(check(&q, &talk.id, "hold", Some("hold")).is_err());
1273        talk.turns.push(owner_turn("hold"));
1274        store.put(&mut talk).unwrap();
1275        assert!(check(&q, &talk.id, "hold", Some("hold")).is_ok());
1276        talk.turns
1277            .push(owner_turn("Merge this pull request please"));
1278        store.put(&mut talk).unwrap();
1279        q.abandon("approval expired or head changed");
1280        assert!(
1281            check(
1282                &q,
1283                &talk.id,
1284                "merge",
1285                Some("Merge this pull request please")
1286            )
1287            .is_err()
1288        );
1289        assert!(
1290            q.answer(crate::ask::Answer::Choice("merge".into()))
1291                .is_err()
1292        );
1293    }
1294
1295    #[test]
1296    fn begin_withdraws_its_record_when_the_talk_cannot_take_it() {
1297        let (tmp, store, talk) = talks();
1298        let questions = Questions::at(tmp.path().join("questions"));
1299        let mut q = question("implement");
1300        questions.put(&mut q).unwrap();
1301        std::fs::remove_dir_all(tmp.path().join("talks")).unwrap();
1302        assert!(begin(&questions, &store, &q, &talk).is_err());
1303        assert!(questions.get(&q.id).unwrap().consult.is_none());
1304    }
1305
1306    #[test]
1307    fn an_older_question_file_reads_without_a_consult() {
1308        let mut q = question("implement");
1309        q.schema = 5;
1310        let mut v = serde_json::to_value(&q).unwrap();
1311        v.as_object_mut().unwrap().remove("consult");
1312        let back: Question = serde_json::from_value(v).unwrap();
1313        assert!(back.consult.is_none());
1314    }
1315}