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