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