Skip to main content

spar/
model.rs

1//! The shapes that cross the boundary between spar and a model, and the shapes
2//! spar keeps for itself.
3//!
4//! Everything a model produces is parsed leniently: an LLM that answers
5//! "medium" where the schema said "med" is not a reason to abandon a run that
6//! has already spent real money. Anything genuinely unrecognisable is still an
7//! error, because silently downgrading a blocking finding is worse than
8//! stopping.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::de::{self, Deserializer, Visitor};
14use serde::{Deserialize, Serialize, Serializer};
15
16fn norm_token(text: &str) -> String {
17    text.trim()
18        .to_lowercase()
19        .chars()
20        .filter(|c| c.is_ascii_alphanumeric())
21        .collect()
22}
23
24macro_rules! string_enum {
25    (
26        $(#[$meta:meta])*
27        pub enum $name:ident {
28            $( $(#[$vmeta:meta])* $variant:ident = $canonical:literal $( | $alias:literal )* ),+ $(,)?
29        }
30    ) => {
31        $(#[$meta])*
32        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33        pub enum $name { $( $(#[$vmeta])* $variant ),+ }
34
35        impl $name {
36            pub fn as_str(self) -> &'static str {
37                match self { $( $name::$variant => $canonical ),+ }
38            }
39
40            /// Accept the canonical spelling, any listed alias, and any
41            /// difference in case, spacing, hyphens, or underscores.
42            pub fn parse_lenient(text: &str) -> Option<Self> {
43                let got = norm_token(text);
44                $(
45                    if got == norm_token($canonical) $( || got == norm_token($alias) )* {
46                        return Some($name::$variant);
47                    }
48                )+
49                None
50            }
51
52            pub fn valid_values() -> String {
53                [$( $canonical ),+].join(", ")
54            }
55        }
56
57        impl fmt::Display for $name {
58            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59                f.write_str(self.as_str())
60            }
61        }
62
63        impl Serialize for $name {
64            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65                s.serialize_str(self.as_str())
66            }
67        }
68
69        impl<'de> Deserialize<'de> for $name {
70            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71                let raw = String::deserialize(d)?;
72                $name::parse_lenient(&raw).ok_or_else(|| {
73                    de::Error::custom(format!(
74                        "{} is not one of: {}",
75                        raw,
76                        $name::valid_values()
77                    ))
78                })
79            }
80        }
81    };
82}
83
84string_enum! {
85    /// How much work an issue is. Drives ordering: cheap unblocking work first.
86    pub enum Complexity {
87        S = "s" | "small" | "sm" | "xs" | "trivial",
88        M = "m" | "medium" | "med" | "moderate",
89        L = "l" | "large" | "lg" | "xl" | "big" | "huge",
90    }
91}
92
93string_enum! {
94    /// How likely a change here is to break something.
95    pub enum Risk {
96        Low = "low" | "l" | "minimal" | "none",
97        Med = "med" | "medium" | "m" | "moderate",
98        High = "high" | "h" | "severe" | "critical",
99    }
100}
101
102string_enum! {
103    /// Only `Blocking` gates a merge. This is the whole defence against the
104    /// nitpick spiral: a competent reviewer can always find something, so
105    /// "no objections remaining" is not a stopping condition but "no blocking
106    /// objections" is.
107    pub enum Severity {
108        Blocking = "blocking" | "block" | "major" | "critical",
109        NonBlocking = "non-blocking" | "nonblocking" | "non_blocking" | "minor" | "suggestion",
110        Nit = "nit" | "nitpick" | "style" | "trivial",
111    }
112}
113
114string_enum! {
115    pub enum Verdict {
116        Approve = "approve" | "approved" | "lgtm",
117        ChangesRequested = "changes_requested" | "changes-requested" | "request_changes" | "reject",
118    }
119}
120
121string_enum! {
122    pub enum NextAction {
123        Merge = "merge" | "approve" | "ship",
124        FixMyself = "fix_myself" | "fix-myself" | "fix" | "self_fix",
125        HandBack = "hand_back" | "hand-back" | "handback" | "return",
126    }
127}
128
129string_enum! {
130    /// A reviewer's point gets exactly one of these. Refutation is a first
131    /// class outcome, not friction: an agent that accepts every comment to get
132    /// approved produces worse code, not better.
133    pub enum Action {
134        Fixed = "fixed" | "fix" | "accepted" | "done",
135        Refuted = "refuted" | "refute" | "rejected" | "disagree" | "wontfix",
136        FiledIssue = "filed_issue" | "filed-issue" | "filed" | "deferred" | "out_of_scope",
137    }
138}
139
140string_enum! {
141    /// What spar should do about one comment somebody left.
142    ///
143    /// Every value maps to exactly one action, which is the point: a verdict
144    /// that needs a second field to decide what it means is one that gets
145    /// decided differently in two places.
146    pub enum Ask {
147        /// A change is asked for, it is right, and it belongs on this branch.
148        /// Make it, push it, say so, and resolve the thread.
149        Implement = "implement" | "do" | "accept" | "fix",
150        /// Right, but really its own piece of work. File it, say where it went.
151        Defer = "defer" | "file_issue" | "filed_issue" | "out_of_scope" | "followup",
152        /// Should not be made. Reply with the reason, leave the thread open.
153        Decline = "decline" | "refute" | "reject" | "disagree" | "wontfix",
154        /// A question rather than a request. Answer it in words.
155        Answer = "answer" | "question" | "reply" | "clarify",
156        /// Nothing is being asked. Praise, or a thread they settled themselves.
157        Nothing = "nothing" | "none" | "no_request" | "noop" | "skip",
158    }
159}
160
161string_enum! {
162    /// What one screening pass decided about one recorded follow-up.
163    ///
164    /// Only `StillRelevant` files anything. The other three all take the entry
165    /// out of the queue, which is why the prompt asks for a reason and the log
166    /// prints it: they are the verdicts nobody sees the working for.
167    pub enum Screened {
168        StillRelevant = "still_relevant" | "still-relevant" | "relevant" | "keep" | "file",
169        AlreadyFixed = "already_fixed" | "already-fixed" | "fixed" | "done" | "resolved",
170        NotWorthIt = "not_worth_it" | "not-worth-it" | "not_worth_doing" | "skip" | "drop" | "wontfix",
171        Duplicate = "duplicate" | "dupe" | "dup",
172    }
173}
174
175string_enum! {
176    /// Terminal state of one issue or one resumed PR.
177    pub enum Status {
178        Pending = "pending",
179        Abandoned = "abandoned",
180        Approved = "approved",
181        Merged = "merged",
182        Escalated = "escalated",
183        Error = "error",
184        /// Review only: findings were produced and posted, nothing was changed.
185        Reviewed = "reviewed",
186        /// Review only: both reviewers found nothing that blocks a merge.
187        Clean = "clean",
188        /// Check-in: comments were read and answered.
189        Answered = "answered",
190        /// Split: the item was broken into parts, which were filed or opened.
191        Split = "split",
192        /// Split: read, and left as one piece.
193        Whole = "whole",
194    }
195}
196
197impl Complexity {
198    pub fn rank(self) -> u8 {
199        match self {
200            Complexity::S => 0,
201            Complexity::M => 1,
202            Complexity::L => 2,
203        }
204    }
205}
206
207impl Severity {
208    /// How badly it matters, independent of the order the variants happen to
209    /// be declared in. Relying on derived `Ord` here would silently invert the
210    /// moment somebody reorders the enum.
211    pub fn rank(self) -> u8 {
212        match self {
213            Severity::Nit => 0,
214            Severity::NonBlocking => 1,
215            Severity::Blocking => 2,
216        }
217    }
218
219    /// The graver of two judgements.
220    ///
221    /// Two reviewers disagreeing about severity is resolved upward on purpose.
222    /// Nothing here gates a merge, it is all advice to a person, and advice
223    /// that under-reports a real defect is worse than advice that over-reports
224    /// a small one.
225    pub fn graver(self, other: Self) -> Self {
226        if self.rank() >= other.rank() {
227            self
228        } else {
229            other
230        }
231    }
232}
233
234impl Risk {
235    pub fn rank(self) -> u8 {
236        match self {
237            Risk::Low => 0,
238            Risk::Med => 1,
239            Risk::High => 2,
240        }
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Lenient scalar helpers
246// ---------------------------------------------------------------------------
247
248/// An integer that may arrive as a number, a float, or a quoted string, with or
249/// without a leading `#`.
250pub fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
251    struct V;
252    impl<'de> Visitor<'de> for V {
253        type Value = i64;
254        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255            f.write_str("an issue number")
256        }
257        fn visit_i64<E: de::Error>(self, v: i64) -> Result<i64, E> {
258            Ok(v)
259        }
260        fn visit_u64<E: de::Error>(self, v: u64) -> Result<i64, E> {
261            Ok(v as i64)
262        }
263        fn visit_f64<E: de::Error>(self, v: f64) -> Result<i64, E> {
264            Ok(v as i64)
265        }
266        fn visit_str<E: de::Error>(self, v: &str) -> Result<i64, E> {
267            v.trim()
268                .trim_start_matches('#')
269                .parse()
270                .map_err(|_| E::custom(format!("{v} is not a number")))
271        }
272    }
273    d.deserialize_any(V)
274}
275
276fn de_i64_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<i64>, D::Error> {
277    #[derive(Deserialize)]
278    struct One(#[serde(deserialize_with = "de_i64")] i64);
279    let raw = Option::<Vec<One>>::deserialize(d)?;
280    Ok(raw
281        .unwrap_or_default()
282        .into_iter()
283        .map(|One(n)| n)
284        .collect())
285}
286
287/// A boolean that may arrive as `true`, `"true"`, `"yes"`, or `1`.
288pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
289    struct V;
290    impl<'de> Visitor<'de> for V {
291        type Value = bool;
292        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293            f.write_str("a boolean")
294        }
295        fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
296            Ok(v)
297        }
298        fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
299            Ok(v != 0)
300        }
301        fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
302            Ok(v != 0)
303        }
304        fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
305            match norm_token(v).as_str() {
306                "true" | "yes" | "y" | "1" => Ok(true),
307                "false" | "no" | "n" | "0" => Ok(false),
308                other => Err(E::custom(format!("{other} is not a boolean"))),
309            }
310        }
311    }
312    d.deserialize_any(V)
313}
314
315/// An optional number that may arrive as 412, "412", "#412", null, or "none".
316///
317/// Anything unparseable yields None rather than failing. A duplicate pointer is
318/// decoration on a verdict that stands without it, and taking a whole batch down
319/// over one stray string costs a second full repo pass to learn nothing.
320pub fn de_opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
321    Ok(match Option::<serde_json::Value>::deserialize(d)? {
322        Some(serde_json::Value::Number(n)) => n.as_i64(),
323        Some(serde_json::Value::String(s)) => s.trim().trim_start_matches('#').parse().ok(),
324        _ => None,
325    })
326}
327
328/// A list of strings that may arrive as null or be missing entirely.
329fn de_string_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
330    Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
331}
332
333fn de_bool_default_true<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
334    #[derive(Deserialize)]
335    struct Wrap(#[serde(deserialize_with = "de_bool")] bool);
336    Ok(Option::<Wrap>::deserialize(d)?
337        .map(|Wrap(b)| b)
338        .unwrap_or(true))
339}
340
341fn de_string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
342    Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
343}
344
345// ---------------------------------------------------------------------------
346// What the models return
347// ---------------------------------------------------------------------------
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct TriageVerdict {
351    #[serde(deserialize_with = "de_i64")]
352    pub issue: i64,
353    #[serde(deserialize_with = "de_bool")]
354    pub worth_doing: bool,
355    /// The issue holds context for work filed elsewhere. Never opened, and
356    /// never closed either.
357    #[serde(default, deserialize_with = "de_bool")]
358    pub tracker: bool,
359    #[serde(default, deserialize_with = "de_string")]
360    pub reason: String,
361    pub complexity: Complexity,
362    #[serde(default, deserialize_with = "de_i64_vec")]
363    pub depends_on: Vec<i64>,
364    pub risk: Risk,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct TriageResponse {
369    #[serde(default)]
370    pub issues: Vec<TriageVerdict>,
371}
372
373/// One agent's ruling on one entry in the local follow-up queue.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ScreenVerdict {
376    /// The entry's position in the list it was given, from 1.
377    ///
378    /// Matched back by index rather than by title, because the entries are
379    /// spar's own data and so can carry a handle a model cannot paraphrase.
380    #[serde(deserialize_with = "de_i64")]
381    pub entry: i64,
382    pub verdict: Screened,
383    #[serde(default, deserialize_with = "de_string")]
384    pub title: String,
385    #[serde(default, deserialize_with = "de_string")]
386    pub reason: String,
387    /// The issue or sibling entry a duplicate points at.
388    #[serde(default, deserialize_with = "de_opt_i64")]
389    pub duplicate_of: Option<i64>,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ScreenResponse {
394    #[serde(default)]
395    pub entries: Vec<ScreenVerdict>,
396}
397
398/// One agent's ruling on whether one open item is worth splitting at all.
399///
400/// The cheap screen the bare `spar split` runs over a whole queue, so that two
401/// agent calls per item are only spent on the ones where the answer might be
402/// yes.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct SplitScreen {
405    /// The issue or pull request number, which is spar's own data and so cannot
406    /// be paraphrased back wrong.
407    #[serde(deserialize_with = "de_i64")]
408    pub item: i64,
409    #[serde(deserialize_with = "de_bool")]
410    pub split: bool,
411    #[serde(default, deserialize_with = "de_string")]
412    pub reason: String,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct SplitScreenDoc {
417    #[serde(default)]
418    pub items: Vec<SplitScreen>,
419}
420
421/// One piece of a proposed decomposition.
422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct SplitPart {
424    #[serde(default, deserialize_with = "de_string")]
425    pub title: String,
426    /// The issue body, or the rationale for a pull request slice.
427    #[serde(default, deserialize_with = "de_string")]
428    pub body: String,
429    /// The paths this slice carries. Empty for an issue split, where there is
430    /// no diff to partition.
431    #[serde(default, deserialize_with = "de_string_vec")]
432    pub files: Vec<String>,
433}
434
435/// One agent's decomposition of an issue or a pull request.
436///
437/// Deliberately one proposal rather than two. Two agents asked to decompose the
438/// same thing return two decompositions that cannot be reconciled mechanically,
439/// and reconciling them is a third judgement nobody asked for.
440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
441pub struct SplitProposal {
442    #[serde(default, deserialize_with = "de_bool")]
443    pub should_split: bool,
444    #[serde(default, deserialize_with = "de_string")]
445    pub reason: String,
446    /// Whether the parts are sequential, so each is based on its predecessor
447    /// rather than on the base branch. A property of the change, not of the
448    /// repository, which is why it rides on the proposal.
449    #[serde(default, deserialize_with = "de_bool")]
450    pub stacked: bool,
451    #[serde(default)]
452    pub parts: Vec<SplitPart>,
453}
454
455/// The second agent's ruling on the first one's decomposition.
456#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457pub struct SplitCheck {
458    #[serde(default, deserialize_with = "de_bool")]
459    pub accept: bool,
460    #[serde(default, deserialize_with = "de_bool")]
461    pub stacked: bool,
462    /// Part numbers, from 1, that should not be split out.
463    #[serde(default, deserialize_with = "de_i64_vec")]
464    pub strike: Vec<i64>,
465    #[serde(default, deserialize_with = "de_string")]
466    pub reasoning: String,
467}
468
469/// One agent's judgement of one comment somebody left on a pull request.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct CommentVerdict {
472    /// The handle spar printed beside the comment, copied back so the answer
473    /// can be matched to it. Not a title: a finding's title is the only handle
474    /// there is because findings are model authored, but a comment is spar's
475    /// own data, so it gets one that cannot be paraphrased or collided.
476    #[serde(default, deserialize_with = "de_string")]
477    pub ref_id: String,
478    pub ask: Ask,
479    /// What is being asked for, in one sentence, in the agent's own words.
480    /// How spar checks the comment was understood before acting on it.
481    #[serde(default, deserialize_with = "de_string")]
482    pub request: String,
483    /// The whole argument. For a decline this is posted in the thread, so it is
484    /// written for the person who raised the point.
485    #[serde(default, deserialize_with = "de_string")]
486    pub reasoning: String,
487    /// False when the comment could be read more than one way. spar answers an
488    /// ambiguous comment in words and never guesses at a commit.
489    #[serde(deserialize_with = "de_bool")]
490    pub unambiguous: bool,
491    #[serde(default)]
492    pub new_issue_title: Option<String>,
493    #[serde(default)]
494    pub new_issue_body: Option<String>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct CheckinDoc {
499    #[serde(default)]
500    pub verdicts: Vec<CommentVerdict>,
501}
502
503/// The second agent's ruling on the first one's call.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct CommentCheck {
506    #[serde(default, deserialize_with = "de_string")]
507    pub ref_id: String,
508    #[serde(deserialize_with = "de_bool")]
509    pub agrees: bool,
510    /// What this agent would do instead. Read only when `agrees` is false.
511    pub ask: Ask,
512    #[serde(deserialize_with = "de_bool")]
513    pub unambiguous: bool,
514    #[serde(default, deserialize_with = "de_string")]
515    pub reasoning: String,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct CheckDoc {
520    #[serde(default)]
521    pub checks: Vec<CommentCheck>,
522}
523
524/// What the fix pass did about one comment.
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct FixOutcome {
527    #[serde(default, deserialize_with = "de_string")]
528    pub ref_id: String,
529    /// False when the change turned out to be wrong once the code was open.
530    /// Declining here is a better answer than making a change you now believe
531    /// is a mistake.
532    #[serde(deserialize_with = "de_bool")]
533    pub changed: bool,
534    /// One sentence naming what changed, or why it was left alone. Posted in
535    /// the thread, so it is written for the person who asked.
536    #[serde(default, deserialize_with = "de_string")]
537    pub summary: String,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct FixReport {
542    #[serde(default)]
543    pub done: Vec<FixOutcome>,
544}
545
546/// What spar has already answered on one pull request or issue.
547///
548/// Keyed by thread or comment, valued by the newest message in it that spar did
549/// not write. A thread that has moved since spar answered carries a different
550/// value and is read again, which is what makes "they replied to my reply"
551/// work. GitHub's own resolved flag covers the threads spar fixed; this covers
552/// the ones it argued with, which stay open on purpose and would otherwise be
553/// re-argued once per run, forever.
554///
555/// Written only after a reply has posted. A run that could not post is a run
556/// that has not answered, and recording it as answered would lose the comment.
557#[derive(Debug, Clone, Default, Serialize, Deserialize)]
558pub struct Answered {
559    #[serde(default)]
560    pub version: u32,
561    #[serde(default)]
562    pub seen: BTreeMap<String, String>,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct Finding {
567    pub severity: Severity,
568    #[serde(default, deserialize_with = "de_string")]
569    pub title: String,
570    #[serde(default, deserialize_with = "de_string")]
571    pub detail: String,
572    #[serde(default, deserialize_with = "de_string")]
573    pub file: String,
574    /// A real problem that this PR did not cause. Those become follow-ups
575    /// rather than review comments, so they cannot gate an unrelated merge.
576    #[serde(default = "yes", deserialize_with = "de_bool_default_true")]
577    pub in_scope: bool,
578
579    // -- the parts of a bug report ---------------------------------------
580    //
581    // Filled when a finding is going to become an issue somebody picks up
582    // cold. `detail` is the one line the pull request thread shows; these are
583    // what a person needs when the thread is not in front of them. All
584    // optional: a finding that stays in the thread has no use for them.
585    /// What is wrong, with the specifics.
586    #[serde(default)]
587    pub problem: Option<String>,
588    /// Steps to reproduce it, and what actually happens.
589    #[serde(default)]
590    pub reproduction: Option<String>,
591    /// What it costs somebody.
592    #[serde(default)]
593    pub impact: Option<String>,
594    /// What it should do instead.
595    #[serde(default)]
596    pub expected: Option<String>,
597}
598
599impl Default for Finding {
600    /// A blank finding, for building one field at a time.
601    ///
602    /// Severity is spelled out here rather than derived, because a severity
603    /// arriving by default is exactly the mistake this codebase refuses
604    /// elsewhere: it is the field that decides whether a merge is gated, and
605    /// the least severe value is the only safe thing to assume.
606    fn default() -> Self {
607        Self {
608            severity: Severity::Nit,
609            title: String::new(),
610            detail: String::new(),
611            file: String::new(),
612            in_scope: true,
613            problem: None,
614            reproduction: None,
615            impact: None,
616            expected: None,
617        }
618    }
619}
620
621impl Finding {
622    /// The parts of a bug report this finding carries, in the order they are
623    /// written, skipping the ones it does not.
624    pub fn report_sections(&self) -> Vec<(&'static str, &str)> {
625        [
626            ("Problem", self.problem.as_deref()),
627            ("Reproduction", self.reproduction.as_deref()),
628            ("Impact", self.impact.as_deref()),
629            ("Expected behavior", self.expected.as_deref()),
630        ]
631        .into_iter()
632        .filter_map(|(heading, text)| {
633            text.map(str::trim)
634                .filter(|t| !t.is_empty())
635                .map(|t| (heading, t))
636        })
637        .collect()
638    }
639}
640
641fn yes() -> bool {
642    true
643}
644
645impl Finding {
646    pub fn blocks(&self) -> bool {
647        self.severity == Severity::Blocking && self.in_scope
648    }
649
650    pub fn where_at(&self) -> &str {
651        if self.file.trim().is_empty() {
652            "general"
653        } else {
654            self.file.trim()
655        }
656    }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct Review {
661    pub verdict: Verdict,
662    pub next_action: NextAction,
663    #[serde(default, deserialize_with = "de_string")]
664    pub summary: String,
665    #[serde(default)]
666    pub findings: Vec<Finding>,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct Disposition {
671    #[serde(default, deserialize_with = "de_string")]
672    pub title: String,
673    /// Carried so a refutation lands on the same ledger key the reviewer's
674    /// finding will hash to next round. Without it the re-litigation guard
675    /// silently never fires for any finding that names a file.
676    #[serde(default, deserialize_with = "de_string")]
677    pub file: String,
678    pub action: Action,
679    #[serde(default, deserialize_with = "de_string")]
680    pub reasoning: String,
681    #[serde(default)]
682    pub new_issue_title: Option<String>,
683    #[serde(default)]
684    pub new_issue_body: Option<String>,
685}
686
687/// One reviewer's judgement of a finding the *other* reviewer raised.
688///
689/// This is the whole point of review only mode. A finding both models raise
690/// independently is worth a maintainer's attention; a finding one raised and
691/// the other examined and rejected is usually not, and saying so is more useful
692/// than forwarding both.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct Adjudication {
695    #[serde(default, deserialize_with = "de_string")]
696    pub title: String,
697    #[serde(default, deserialize_with = "de_string")]
698    pub file: String,
699    /// Whether the defect is real, judged by reading the code rather than by
700    /// deferring to the other reviewer.
701    #[serde(deserialize_with = "de_bool")]
702    pub agrees: bool,
703    /// This reviewer's own view of how badly it matters.
704    pub severity: Severity,
705    #[serde(default, deserialize_with = "de_string")]
706    pub reasoning: String,
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct AdjudicationDoc {
711    #[serde(default)]
712    pub verdicts: Vec<Adjudication>,
713}
714
715/// A finding after both reviewers have had their say.
716#[derive(Debug, Clone)]
717pub struct Judged {
718    pub finding: Finding,
719    /// Who first raised it.
720    pub raised_by: String,
721    /// How it ended up.
722    pub standing: Standing,
723    /// The other reviewer's reasoning, when they had something to say.
724    pub counterpoint: Option<String>,
725    /// What the reviewer who raised it said when the objection came back.
726    /// Kept apart from the objection: running both together behind a single
727    /// "the other says" turns the most valuable content in the comment into
728    /// one unreadable sentence.
729    pub defence: Option<String>,
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733pub enum Standing {
734    /// Both reviewers raised it independently. The strongest signal there is.
735    Corroborated,
736    /// One raised it, the other read the code and agreed.
737    Confirmed,
738    /// One raised it, the other read the code and rejected it, and it survived
739    /// a rebuttal. A person decides.
740    Disputed,
741    /// Raised, rejected, and withdrawn by the reviewer who raised it.
742    Withdrawn,
743    /// Raised with nobody left to check it, because the round budget ran out.
744    Unverified,
745}
746
747/// What the implementor did, and what the pull request body is built from.
748///
749/// Structured for the reason every other exchange here is structured: a model
750/// asked for a description writes a paragraph about having written one, while a
751/// model asked for a problem, a change list, and a way to check them answers
752/// each of those. The body's substance comes from the fields being asked for
753/// separately, and its brevity from spar composing them rather than the model
754/// narrating.
755#[derive(Debug, Clone, Default, Serialize, Deserialize)]
756pub struct Implementation {
757    /// The issue should not be implemented. No commits, and `reason` says why.
758    #[serde(default, deserialize_with = "de_bool")]
759    pub not_worth_doing: bool,
760    /// Only when declining. Posted on the issue, so it is written for whoever
761    /// opened it rather than for the harness.
762    #[serde(default, deserialize_with = "de_string")]
763    pub reason: String,
764    /// One sentence saying what changed. Leads the body.
765    #[serde(default, deserialize_with = "de_string")]
766    pub summary: String,
767    /// What was actually wrong, as understood after reading the code. Not a
768    /// restatement of the issue: the reviewer can follow the link.
769    #[serde(default, deserialize_with = "de_string")]
770    pub problem: String,
771    /// One line per change that alters behaviour.
772    #[serde(default)]
773    pub changes: Vec<String>,
774    /// How a reviewer confirms the change works.
775    #[serde(default)]
776    pub testing: Vec<String>,
777    /// Anything the reviewer would otherwise have to ask about: a deliberate
778    /// omission, a decision worth defending, a risk. Usually nothing.
779    #[serde(default)]
780    pub notes: Option<String>,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784pub struct ResponseDoc {
785    #[serde(default, deserialize_with = "de_string")]
786    pub summary: String,
787    #[serde(default)]
788    pub dispositions: Vec<Disposition>,
789}
790
791// ---------------------------------------------------------------------------
792// What spar keeps
793// ---------------------------------------------------------------------------
794
795#[derive(Debug, Clone, Serialize, Deserialize)]
796pub struct PlanItem {
797    pub issue: i64,
798    pub title: String,
799    pub complexity: Complexity,
800    pub risk: Risk,
801    pub depends_on: Vec<i64>,
802    pub reason: String,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct SkippedItem {
807    pub issue: i64,
808    pub title: String,
809    /// Keyed by agent name, so the plan file says who said what.
810    pub reasons: BTreeMap<String, String>,
811    /// An umbrella or epic, which spar comments on and leaves open.
812    ///
813    /// Set when *either* agent said so, unlike everything else here, which
814    /// needs both. Closing already requires agreement, on the principle that
815    /// one agent's opinion is not enough to close somebody's report; one agent
816    /// saying the issue is not finished is the same principle from the other
817    /// side.
818    #[serde(default)]
819    pub tracker: bool,
820}
821
822#[derive(Debug, Clone, Serialize, Deserialize)]
823pub struct ContestedItem {
824    pub issue: i64,
825    pub title: String,
826    /// Agent name to "do" or "skip".
827    pub positions: BTreeMap<String, String>,
828    pub reasons: BTreeMap<String, String>,
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub note: Option<String>,
831}
832
833#[derive(Debug, Clone, Default, Serialize, Deserialize)]
834pub struct Plan {
835    #[serde(default)]
836    pub order: Vec<PlanItem>,
837    #[serde(default)]
838    pub skipped: Vec<SkippedItem>,
839    #[serde(default)]
840    pub contested: Vec<ContestedItem>,
841}
842
843string_enum! {
844    /// How a point stands, one round to the next.
845    ///
846    /// Three of these are endings: the code will not change for the point here,
847    /// so raising it again only spends a round. They do not mean the same thing
848    /// to a person, which is why `Dropped` is not folded into `Filed`: only
849    /// `Filed` promises that somewhere holds the point.
850    ///
851    /// `Fixed` is not an ending. The code changed, the change is the author's
852    /// claim about the point, and nothing has checked it. It is here because the
853    /// ledger is the only thing a later round reads, and recording nothing for a
854    /// fix left it holding only the points the reviewer lost. Six fix rounds
855    /// across two pull requests produced no entry at all, and the guard that
856    /// ends an argument had nothing to match.
857    pub enum Settled {
858        Refuted = "refuted" | "refute" | "rejected",
859        Filed = "filed" | "filed_issue" | "filed-issue" | "out_of_scope",
860        Dropped = "dropped" | "not_filed" | "not-filed" | "unfiled",
861        Fixed = "fixed" | "fix" | "changed",
862    }
863}
864
865impl Default for Settled {
866    /// State written before the ledger held anything but refutations.
867    fn default() -> Self {
868        Settled::Refuted
869    }
870}
871
872/// What one attempt to record a follow-up actually did.
873///
874/// The distinction the ledger needs. A point written down somewhere is settled
875/// and tracked; a point deliberately not written down is settled and untracked;
876/// a point that failed to be written down is not settled at all, and saying it
877/// was loses it. A single `Option<String>` collapsed all three into "no URL".
878#[derive(Debug, Clone, PartialEq, Eq)]
879pub enum Followup {
880    /// A tracker issue or a local note now carries it. The string is what a
881    /// reader is shown: an issue URL, or a note line.
882    Recorded(String),
883    /// Something already covers it and nothing was written: a closed issue, or
884    /// a note the local queue still holds. There is a reference to point at,
885    /// but no new work to hand anyone.
886    Covered(String),
887    /// Deliberately not recorded, with the reason. Follow-ups are off, or the
888    /// run has spent its cap.
889    Dropped(&'static str),
890    /// Nothing was written and nothing covers it. The point is still open, so
891    /// the next round is free to try again.
892    Failed,
893}
894
895impl Followup {
896    /// The reference to add to the run's filed list, when there is a live one.
897    ///
898    /// `Covered` is deliberately excluded: a closed issue reported as filed
899    /// goes back into a wave to be implemented again.
900    pub fn url(&self) -> Option<&str> {
901        match self {
902            Followup::Recorded(url) => Some(url),
903            _ => None,
904        }
905    }
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct LedgerEntry {
910    pub title: String,
911    pub file: String,
912    pub reasoning: String,
913    pub round: u32,
914    #[serde(default)]
915    pub reraised: u32,
916    #[serde(default)]
917    pub outcome: Settled,
918}
919
920/// Settled points, keyed by the review loop's finding identity. Ordered so the settled block in a
921/// prompt is stable between rounds, which keeps prompt caches warm and diffs
922/// readable.
923pub type Ledger = BTreeMap<String, LedgerEntry>;
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct Dispute {
927    pub title: String,
928    #[serde(default, deserialize_with = "de_string")]
929    pub file: String,
930    pub reasoning: String,
931}
932
933/// The outcome of working one issue, or resuming one PR.
934#[derive(Debug, Clone, Serialize, Deserialize)]
935pub struct IssueRun {
936    pub issue: i64,
937    pub title: String,
938    pub status: Status,
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub pr: Option<String>,
941    #[serde(default)]
942    pub rounds: u32,
943    #[serde(default)]
944    pub disputes: Vec<Dispute>,
945    #[serde(default)]
946    pub filed: Vec<String>,
947    /// Real points a reviewer judged smaller than another round.
948    ///
949    /// Nothing else carries them. The round comment is off under the default
950    /// `pr_comments = "outcome"` and a non-blocking finding is not filed under
951    /// the default `file_non_blocking = false`, so without this the severity
952    /// ladder is a way to make a finding disappear rather than a way to stop it
953    /// costing a round. Silence on a pull request should mean nothing was
954    /// found, not that nothing was gated.
955    #[serde(default)]
956    pub noted: Vec<Finding>,
957    #[serde(default)]
958    pub notes: Vec<String>,
959    /// Runtime circuit breaker after an external follow-up write could not be
960    /// verified. A later invocation rechecks GitHub before any new write.
961    #[serde(skip)]
962    pub followup_writes_uncertain: bool,
963}
964
965impl IssueRun {
966    pub fn new(issue: i64, title: impl Into<String>) -> Self {
967        Self {
968            issue,
969            title: title.into(),
970            status: Status::Pending,
971            pr: None,
972            rounds: 0,
973            disputes: Vec::new(),
974            filed: Vec::new(),
975            noted: Vec::new(),
976            notes: Vec::new(),
977            followup_writes_uncertain: false,
978        }
979    }
980
981    /// Whether this outcome counts as the run having done its job.
982    ///
983    /// A review that produced findings did its job: the findings are the
984    /// product, and a PR needing work is not a failure of the reviewer.
985    pub fn succeeded(&self) -> bool {
986        matches!(
987            self.status,
988            Status::Merged
989                | Status::Approved
990                | Status::Abandoned
991                | Status::Reviewed
992                | Status::Clean
993                | Status::Answered
994                // A split that correctly decided nothing needed splitting did
995                // its job. Without Whole here, `spar split` over a tidy queue
996                // exits non-zero, which in a script reads as a failure.
997                | Status::Split
998                | Status::Whole
999        )
1000    }
1001}
1002
1003/// Everything needed to pick a review back up, including on another machine.
1004#[derive(Debug, Clone, Serialize, Deserialize)]
1005pub struct PersistedState {
1006    pub version: u32,
1007    /// Monotonic checkpoint order for writes within this repository.
1008    #[serde(default)]
1009    pub checkpoint: u64,
1010    pub round: u32,
1011    pub next_actor: String,
1012    pub status: Status,
1013    /// Published pull request head to which this checkpoint applies.
1014    #[serde(default, skip_serializing_if = "String::is_empty")]
1015    pub pr_head: String,
1016    #[serde(default)]
1017    pub ledger: Ledger,
1018    #[serde(default)]
1019    pub filed: Vec<String>,
1020    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1021    pub open_findings: Vec<Finding>,
1022    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1023    pub disputes: Vec<Dispute>,
1024    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1025    pub noted: Vec<Finding>,
1026}
1027
1028pub const STATE_VERSION: u32 = 2;
1029
1030// ---------------------------------------------------------------------------
1031// What gh returns
1032// ---------------------------------------------------------------------------
1033
1034#[derive(Debug, Clone, Deserialize)]
1035pub struct Label {
1036    #[serde(default)]
1037    pub name: String,
1038}
1039
1040#[derive(Debug, Clone, Deserialize)]
1041pub struct Issue {
1042    pub number: i64,
1043    #[serde(default)]
1044    pub title: String,
1045    #[serde(default)]
1046    pub body: Option<String>,
1047    #[serde(default)]
1048    pub state: String,
1049    #[serde(default)]
1050    pub url: String,
1051    #[serde(default)]
1052    pub labels: Vec<Label>,
1053}
1054
1055impl Issue {
1056    pub fn body_text(&self) -> &str {
1057        self.body.as_deref().unwrap_or("")
1058    }
1059
1060    /// The body as a prompt carries it, and whether anything was left off.
1061    ///
1062    /// Shortened only past `max`, which is sized so that nothing a person
1063    /// wrote ever reaches it. When it does fire the cut is announced in the
1064    /// text itself: an agent handed a fragment with no marker has no way to
1065    /// tell it from an issue that simply ended there, so it judges the part it
1066    /// saw and reports the confidence of having seen all of it.
1067    ///
1068    /// The cut lands on a line boundary, and an unbalanced code fence is closed
1069    /// rather than left hanging. Broken markdown reads as a defect in the issue
1070    /// and costs the model attention to rule out.
1071    pub fn body_for_prompt(&self, max: usize) -> (String, bool) {
1072        let body = self.body_text().trim();
1073        if body.chars().count() <= max {
1074            return (body.to_string(), false);
1075        }
1076        let clipped: String = body.chars().take(max).collect();
1077        let mut kept = match clipped.rfind('\n') {
1078            Some(at) => clipped[..at].to_string(),
1079            None => clipped,
1080        };
1081        if kept.matches("```").count() % 2 == 1 {
1082            kept.push_str("\n```");
1083        }
1084        kept.push_str("\n\n[Shortened to fit. The rest of this issue was not included.]");
1085        (kept, true)
1086    }
1087
1088    pub fn is_closed(&self) -> bool {
1089        self.state.eq_ignore_ascii_case("closed")
1090    }
1091}
1092
1093#[derive(Debug, Clone, Deserialize)]
1094pub struct PrRef {
1095    pub number: i64,
1096    #[serde(default)]
1097    pub url: String,
1098    #[serde(default)]
1099    pub title: String,
1100}
1101
1102#[derive(Debug, Clone, Deserialize)]
1103pub struct IssueRef {
1104    pub number: i64,
1105}
1106
1107/// An open pull request's size, as one listing call reports it.
1108///
1109/// Enough for the screen to say whether a change is worth splitting without
1110/// fetching its head, which would be one network round trip per pull request
1111/// for a question whose answer is usually no.
1112#[derive(Debug, Clone, Default, Deserialize)]
1113#[serde(rename_all = "camelCase")]
1114pub struct PrRow {
1115    pub number: i64,
1116    #[serde(default)]
1117    pub title: String,
1118    #[serde(default)]
1119    pub changed_files: i64,
1120    #[serde(default)]
1121    pub additions: i64,
1122    #[serde(default)]
1123    pub deletions: i64,
1124}
1125
1126impl PrRow {
1127    /// The size, as the screen prompt carries it.
1128    pub fn size(&self) -> String {
1129        format!(
1130            "{} file(s), +{} -{}",
1131            self.changed_files, self.additions, self.deletions
1132        )
1133    }
1134}
1135
1136#[derive(Debug, Clone, Deserialize)]
1137#[serde(rename_all = "camelCase")]
1138pub struct PrView {
1139    pub number: i64,
1140    #[serde(default)]
1141    pub url: String,
1142    #[serde(default)]
1143    pub title: String,
1144    #[serde(default)]
1145    pub head_ref_name: String,
1146    #[serde(default)]
1147    pub base_ref_name: String,
1148    #[serde(default)]
1149    pub state: String,
1150    #[serde(default)]
1151    pub closing_issues_references: Vec<IssueRef>,
1152    /// True when the PR's head branch lives on a fork rather than this
1153    /// repository.
1154    #[serde(default)]
1155    pub is_cross_repository: bool,
1156}
1157
1158/// Issues and pull requests share one number sequence per repository, so a
1159/// number names exactly one of them and spar can work out which.
1160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1161pub enum ItemKind {
1162    Issue,
1163    Pr,
1164}
1165
1166impl std::fmt::Display for ItemKind {
1167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1168        f.write_str(match self {
1169            ItemKind::Issue => "issue",
1170            ItemKind::Pr => "pull request",
1171        })
1172    }
1173}
1174
1175impl PrView {
1176    pub fn is_open(&self) -> bool {
1177        self.state.eq_ignore_ascii_case("open")
1178    }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184
1185    #[test]
1186    fn severity_accepts_the_canonical_spelling() {
1187        assert_eq!(
1188            Some(Severity::NonBlocking),
1189            Severity::parse_lenient("non-blocking")
1190        );
1191    }
1192
1193    #[test]
1194    fn severity_accepts_near_misses() {
1195        for text in ["NonBlocking", "non_blocking", " NON-BLOCKING ", "minor"] {
1196            assert_eq!(
1197                Some(Severity::NonBlocking),
1198                Severity::parse_lenient(text),
1199                "{text}"
1200            );
1201        }
1202    }
1203
1204    #[test]
1205    fn severity_rejects_nonsense() {
1206        assert_eq!(None, Severity::parse_lenient("catastrophic-ish"));
1207    }
1208
1209    #[test]
1210    fn complexity_ordering_is_cheapest_first() {
1211        assert!(Complexity::S.rank() < Complexity::M.rank());
1212        assert!(Complexity::M.rank() < Complexity::L.rank());
1213    }
1214
1215    #[test]
1216    fn finding_defaults_to_in_scope() {
1217        let f: Finding = serde_json::from_value(serde_json::json!({
1218            "severity": "blocking", "title": "t", "detail": "d", "file": "a.rs"
1219        }))
1220        .unwrap();
1221        assert!(f.in_scope);
1222        assert!(f.blocks());
1223    }
1224
1225    #[test]
1226    fn out_of_scope_blocking_does_not_block() {
1227        let f: Finding = serde_json::from_value(serde_json::json!({
1228            "severity": "blocking", "title": "t", "detail": "d",
1229            "file": "a.rs", "in_scope": false
1230        }))
1231        .unwrap();
1232        assert!(!f.blocks());
1233    }
1234
1235    #[test]
1236    fn triage_tolerates_a_quoted_issue_number() {
1237        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1238            "issue": "#42", "worth_doing": "yes", "reason": "r",
1239            "complexity": "medium", "depends_on": ["39"], "risk": "low"
1240        }))
1241        .unwrap();
1242        assert_eq!(42, v.issue);
1243        assert!(v.worth_doing);
1244        assert_eq!(Complexity::M, v.complexity);
1245        assert_eq!(vec![39], v.depends_on);
1246    }
1247
1248    #[test]
1249    fn triage_tolerates_a_missing_depends_on() {
1250        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1251            "issue": 1, "worth_doing": false, "reason": "r",
1252            "complexity": "s", "risk": "high"
1253        }))
1254        .unwrap();
1255        assert!(v.depends_on.is_empty());
1256    }
1257
1258    #[test]
1259    fn review_tolerates_a_missing_findings_array() {
1260        let r: Review = serde_json::from_value(serde_json::json!({
1261            "verdict": "approve", "next_action": "merge", "summary": "fine"
1262        }))
1263        .unwrap();
1264        assert!(r.findings.is_empty());
1265    }
1266
1267    #[test]
1268    fn a_null_reason_is_an_empty_string_not_a_failure() {
1269        let v: TriageVerdict = serde_json::from_value(serde_json::json!({
1270            "issue": 1, "worth_doing": true, "reason": null,
1271            "complexity": "s", "depends_on": [], "risk": "low"
1272        }))
1273        .unwrap();
1274        assert_eq!("", v.reason);
1275    }
1276
1277    #[test]
1278    fn unknown_severity_is_an_error_not_a_silent_downgrade() {
1279        let out: Result<Finding, _> = serde_json::from_value(serde_json::json!({
1280            "severity": "showstopper-maybe", "title": "t", "detail": "d", "file": "a.rs"
1281        }));
1282        assert!(out.is_err());
1283    }
1284
1285    /// A `spar split` that correctly decided nothing needed splitting did its
1286    /// job. Without both of these in `succeeded`, it exits non-zero over a tidy
1287    /// queue, which in a script reads as a failure.
1288    #[test]
1289    fn deciding_not_to_split_is_not_a_failure() {
1290        for status in [Status::Split, Status::Whole] {
1291            let mut run = IssueRun::new(1, "t");
1292            run.status = status;
1293            assert!(run.succeeded(), "{status}");
1294        }
1295    }
1296
1297    #[test]
1298    fn status_round_trips_through_json() {
1299        let run = IssueRun::new(4, "t");
1300        let text = serde_json::to_string(&run).unwrap();
1301        let back: IssueRun = serde_json::from_str(&text).unwrap();
1302        assert_eq!(Status::Pending, back.status);
1303    }
1304}
1305
1306#[cfg(test)]
1307mod body_for_prompt_tests {
1308    use super::*;
1309
1310    fn issue(body: &str) -> Issue {
1311        let mut i: Issue = serde_json::from_value(serde_json::json!({
1312            "number": 1, "title": "t", "state": "open", "url": "u"
1313        }))
1314        .expect("an issue");
1315        i.body = Some(body.to_string());
1316        i
1317    }
1318
1319    /// The case that is every real issue: nothing is touched and nothing is
1320    /// claimed to be.
1321    #[test]
1322    fn an_issue_that_fits_is_handed_over_whole() {
1323        let (body, cut) = issue("The guard is inverted.").body_for_prompt(60_000);
1324        assert_eq!("The guard is inverted.", body);
1325        assert!(!cut);
1326    }
1327
1328    /// A fragment with no marker is indistinguishable from an issue that ended
1329    /// there, so the agent judges what it saw with the confidence of having
1330    /// seen everything. That is what the silent caps did.
1331    #[test]
1332    fn a_shortened_body_says_so_in_the_text() {
1333        let long = "line of text\n".repeat(500);
1334        let (body, cut) = issue(&long).body_for_prompt(200);
1335        assert!(cut);
1336        assert!(body.contains("Shortened to fit"), "{body}");
1337        assert!(body.len() < long.len());
1338    }
1339
1340    /// Broken markdown reads as a defect in the issue, and costs the model
1341    /// attention to rule out.
1342    #[test]
1343    fn a_cut_never_leaves_a_code_fence_open() {
1344        let body = format!("intro\n\n```rust\n{}\n```\n", "let x = 1;\n".repeat(200));
1345        let (out, cut) = issue(&body).body_for_prompt(120);
1346        assert!(cut);
1347        assert_eq!(0, out.matches("```").count() % 2, "{out}");
1348    }
1349
1350    /// Cutting mid-word turns the last thing the agent reads into nonsense.
1351    #[test]
1352    fn a_cut_lands_on_a_line_boundary() {
1353        let body = "aaaa bbbb cccc\n".repeat(100);
1354        let (out, _) = issue(&body).body_for_prompt(100);
1355        let kept = out.split("\n\n[Shortened").next().expect("the kept part");
1356        assert!(kept.ends_with("cccc"), "{kept:?}");
1357    }
1358}