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