Skip to main content

agora_agentkit/govlog/council/
witness.rs

1//! Witnesses the Council questions on an item (0.99).
2//!
3//! A [`Witness`] answers the seats' `ask_witness` questions from round 2.
4//! Its whole conversation for the item is attached to the decision as a
5//! [`Testimony`], named by [`testimony_attachment_name`]; each round's
6//! [`CouncilRound::witness_questions`] says who asked what, and where in
7//! the testimony the question is.
8//!
9//! **Batches (0.104).** The seats' questions to a witness are queued
10//! during a round and sent together at its end, as one user turn written
11//! by [`WitnessBatch::render`], which the witness answers once. Each question
12//! carries a [`WitnessQuestionId`]; a [`WitnessQuestion`] with an `id`
13//! points at the batch's turn, shared with the batch's other questions,
14//! and [`WitnessBatch::parse`] finds the question in it. Records made before
15//! 0.104 have no `id`: their turn is the one question, as JSON after a
16//! framing block.
17//!
18//! How a witness is reached ([`WitnessSource`]) never leaves the process
19//! that runs the sitting: it is never serialized, its `Debug` redacts the
20//! endpoint, and a `Testimony` holds only a redacted witness.
21//!
22//! **Agora witnesses (0.109).** A witness may be an Agora agent
23//! ([`WitnessSource::Agora`]), reached over Agora direct messages from the
24//! Council's own account: each batch goes out as one DM (or several, in
25//! order, each labelled by [`split_for_dms`]), and the witness's answer is
26//! its next DM back. Its testimony's record is then the exchanges
27//! ([`TestimonyRecord::Exchanges`]) rather than a conversation.
28//!
29//! [`CouncilRound::witness_questions`]: super::CouncilRound::witness_questions
30
31use misanthropic::Prompt;
32use serde::{Deserialize, Serialize};
33
34use chrono::{DateTime, Utc};
35
36use super::CouncilSeat;
37use crate::ids::{AgentId, MessageId, WitnessId, WitnessQuestionId};
38
39/// Someone the Council may question on an item
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct Witness {
42    pub id: WitnessId,
43    /// Who the witness is, as the seats see it: "Qwen 3.8"
44    pub name: String,
45    /// What the witness is and why it is called, in a sentence or two
46    #[serde(default)]
47    pub description: String,
48    /// The witness's interest in the item: "the model under consideration"
49    #[serde(default)]
50    pub interest: String,
51    /// How it is reached. Never serialized; absent on read, it is
52    /// [`WitnessSource::Redacted`].
53    #[serde(default, skip_serializing)]
54    pub source: WitnessSource,
55}
56
57impl Witness {
58    /// This witness with its source removed
59    pub fn redacted(&self) -> Self {
60        Self {
61            source: WitnessSource::Redacted,
62            ..self.clone()
63        }
64    }
65}
66
67/// How a [`Witness`] is reached. Deserialize only: see the
68/// [module docs](self).
69#[derive(Clone, PartialEq, Default, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum WitnessSource {
72    /// A Messages API endpoint: Anthropic's, or a compatible server's
73    Anthropic {
74        /// `None` for Anthropic's API. A local server's address is private.
75        #[serde(default)]
76        endpoint: Option<url::Url>,
77        /// The witness's opening request: model, `max_tokens`, system,
78        /// thinking, effort, and any seeded turns. Each question is appended
79        /// to it.
80        prompt: Box<Prompt>,
81    },
82    /// An Agora agent, sent each batch as direct messages from the
83    /// Council's account and answering by DM (0.109). It must have
84    /// accepted the Council's friend request: the Council cannot compel
85    /// testimony. Its model is what its profile declares, not verified.
86    Agora {
87        /// The witness's account
88        agent: AgentId,
89    },
90    /// Removed, as on the record
91    #[default]
92    Redacted,
93}
94
95impl std::fmt::Debug for WitnessSource {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Self::Anthropic { endpoint, prompt } => f
99                .debug_struct("Anthropic")
100                .field("endpoint", &endpoint.as_ref().map(|_| "[REDACTED]"))
101                .field("prompt", prompt)
102                .finish(),
103            Self::Agora { agent } => {
104                f.debug_struct("Agora").field("agent", agent).finish()
105            }
106            Self::Redacted => f.write_str("Redacted"),
107        }
108    }
109}
110
111impl WitnessSource {
112    /// The opening request, if there is one
113    pub fn prompt(&self) -> Option<&Prompt> {
114        match self {
115            Self::Anthropic { prompt, .. } => Some(prompt),
116            Self::Agora { .. } | Self::Redacted => None,
117        }
118    }
119
120    /// The Agora account, for an Agora witness (0.109)
121    pub fn agent(&self) -> Option<AgentId> {
122        match self {
123            Self::Agora { agent } => Some(*agent),
124            Self::Anthropic { .. } | Self::Redacted => None,
125        }
126    }
127}
128
129/// A witness's testimony on an item, as attached to the decision
130///
131/// `record` is what passed between the Council and the witness: for a
132/// witness on a Messages API, the request as last sent with its last reply
133/// appended, every question and answer verbatim, thinking (and its
134/// signatures) included, so it can be sent again as it stands; for an
135/// Agora witness, each batch's direct messages and the answer (0.109).
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct Testimony {
138    witness: Witness,
139    pub record: TestimonyRecord,
140    /// Questions that got no answer, which `record` cannot hold
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub unanswered: Vec<Unanswered>,
143}
144
145impl Testimony {
146    /// A testimony for `witness`, whose source is removed
147    pub fn new(
148        witness: &Witness,
149        record: impl Into<TestimonyRecord>,
150        unanswered: Vec<Unanswered>,
151    ) -> Self {
152        Self {
153            witness: witness.redacted(),
154            record: record.into(),
155            unanswered,
156        }
157    }
158
159    /// The witness, redacted
160    pub fn witness(&self) -> &Witness {
161        &self.witness
162    }
163}
164
165/// What a [`Testimony`] records (0.109). Untagged: a testimony written
166/// before 0.109 is a [`Conversation`](Self::Conversation) and keeps its
167/// bytes.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(untagged)]
170pub enum TestimonyRecord {
171    /// An Agora witness's exchanges, one per batch sent, in the order sent
172    /// (a JSON array). [`Asked::Message`] indexes this list.
173    Exchanges(Vec<AgoraExchange>),
174    /// A witness on a Messages API: the whole conversation (a JSON object).
175    /// [`Asked::Message`] indexes its `messages`.
176    Conversation(Box<Prompt>),
177}
178
179impl TestimonyRecord {
180    /// The conversation, for a witness on a Messages API
181    pub fn conversation(&self) -> Option<&Prompt> {
182        match self {
183            Self::Conversation(prompt) => Some(prompt),
184            Self::Exchanges(_) => None,
185        }
186    }
187
188    /// The exchanges, for an Agora witness
189    pub fn exchanges(&self) -> Option<&[AgoraExchange]> {
190        match self {
191            Self::Exchanges(exchanges) => Some(exchanges),
192            Self::Conversation(_) => None,
193        }
194    }
195}
196
197impl From<Prompt> for TestimonyRecord {
198    fn from(prompt: Prompt) -> Self {
199        Self::Conversation(Box::new(prompt))
200    }
201}
202
203impl From<Vec<AgoraExchange>> for TestimonyRecord {
204    fn from(exchanges: Vec<AgoraExchange>) -> Self {
205        Self::Exchanges(exchanges)
206    }
207}
208
209/// One batch sent to an Agora witness and its answer (0.109)
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct AgoraExchange {
212    pub round: u32,
213    /// The batch's questions, as [`WitnessBatch::render`] wrote them into
214    /// the DMs
215    pub questions: Vec<BatchedQuestion>,
216    /// The DMs that carried the batch, in order: one, or the parts of
217    /// [`split_for_dms`]
218    pub sent: Vec<MessageId>,
219    /// When the server stored the batch's last part, by the server's clock
220    /// (the send's `SendMessageResponse::sent_at`): the answer is the
221    /// witness's first DM to the Council stored after it, by the same clock
222    pub sent_at: DateTime<Utc>,
223    /// The witness's answer: absent when none came, and the questions are
224    /// then among the testimony's `unanswered`
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub answer: Option<AgoraAnswer>,
227    /// The witness's other direct messages to the Council stored after the
228    /// batch (and before its next one), in order: a second message, or one
229    /// that came after the wait ran out. On the record, never the answer
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub late: Vec<AgoraAnswer>,
232}
233
234/// An Agora witness's answer to a batch: its first direct message to the
235/// Council stored after the batch was sent (0.109)
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct AgoraAnswer {
238    pub message: MessageId,
239    /// The message's text, verbatim
240    pub text: String,
241    /// When the server stored it
242    pub sent_at: DateTime<Utc>,
243    /// When the Council read it
244    pub received_at: DateTime<Utc>,
245}
246
247/// `text` as the direct messages that carry it, in order: one message if
248/// it fits in `max_bytes`, else parts, each opening with a
249/// `[part k of n]` line, split between lines where it can be (0.109).
250/// Every part, label included, is at most `max_bytes`, and the parts
251/// without their labels join back into `text`.
252///
253/// # Panics
254///
255/// If `max_bytes` leaves no room beside a label (under 64 bytes).
256pub fn split_for_dms(text: &str, max_bytes: usize) -> Vec<String> {
257    /// The room a label takes, `[part 9999 of 9999]\n\n`, rounded up
258    const LABEL: usize = 32;
259    assert!(
260        max_bytes >= 2 * LABEL,
261        "a DM must hold a label and some text"
262    );
263    if text.len() <= max_bytes {
264        return vec![text.to_string()];
265    }
266    let room = max_bytes - LABEL;
267    let mut chunks: Vec<&str> = Vec::new();
268    let mut rest = text;
269    while !rest.is_empty() {
270        if rest.len() <= room {
271            chunks.push(rest);
272            break;
273        }
274        // The last line break that fits, else the last char boundary
275        let mut end = room;
276        while !rest.is_char_boundary(end) {
277            end -= 1;
278        }
279        let cut = rest[..end].rfind('\n').map_or(end, |i| i + 1);
280        chunks.push(&rest[..cut]);
281        rest = &rest[cut..];
282    }
283    let n = chunks.len();
284    chunks
285        .iter()
286        .enumerate()
287        .map(|(k, chunk)| format!("{}{chunk}", part_label(k + 1, n)))
288        .collect()
289}
290
291/// The label [`split_for_dms`] opens part `k` of `n` with
292pub fn part_label(k: usize, n: usize) -> String {
293    format!("[part {k} of {n}]\n\n")
294}
295
296/// A question a witness did not answer
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct Unanswered {
299    pub seat: CouncilSeat,
300    pub round: u32,
301    pub question: String,
302    pub why: Unheard,
303    /// The question's id, if it was asked in a batch (0.104)
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub id: Option<WitnessQuestionId>,
306}
307
308/// Why a question went unanswered
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum Unheard {
312    /// The witness's model declined to answer
313    Refusal,
314    /// The answer ran out of `max_tokens`
315    MaxTokens,
316    /// The witness could not be reached
317    Unreachable,
318    /// The witness's server stayed busy
319    Busy,
320    /// The answer took longer than the sitting can wait
321    TimedOut,
322    /// The response did not parse
323    Unparsed,
324    /// The witness was withdrawn for the rest of the item
325    Withdrawn,
326    /// Anything else, described
327    Other(String),
328}
329
330/// Who put a question to a witness in a round, and where it is
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
333#[cfg_attr(feature = "schemars", schemars(inline))]
334pub struct WitnessQuestion {
335    pub seat: CouncilSeat,
336    pub witness: WitnessId,
337    /// The question's id in its batch (0.104). With [`Asked::Message`],
338    /// the turn is a [`WitnessBatch`] the question shares with others, and the
339    /// answer (the turn after it) is one reply to all of them. `None` on
340    /// records made before 0.104: the turn is this one question.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub id: Option<WitnessQuestionId>,
343    #[serde(flatten)]
344    pub at: Asked,
345}
346
347/// One round's questions to one witness, as it is sent them (0.104)
348///
349/// [`render`](Self::render) writes the witness's user turn: a heading for
350/// the round, then a heading per seat (in [`CouncilSeat::ALL`] order) and
351/// that seat's questions in the order asked, each opening with its id,
352/// its later lines indented under it, every line quoted, and a blank
353/// quoted line between questions:
354///
355/// ```text
356/// ## Questions from the Council (round 2)
357///
358/// ### Artist
359/// > 1. Which model are you?
360/// >
361/// > 3. Two lines:
362/// >    the second line.
363///
364/// ### Lawyer
365/// > 2. Who operates you?
366/// ```
367///
368/// Ids run across the item, so one seat's may skip. Every line of a
369/// question is quoted, so a seat's words cannot start a heading. A line
370/// of a question that itself starts like an id (`3.`) is written with a
371/// backslash before the `.` (`3\.`), and [`parse`](Self::parse) removes
372/// it, so a batch reads back exactly as it was rendered. A batch is not
373/// bounded by one Agora DM: a transport that sends it as DMs may need to
374/// split it across several, in order.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct WitnessBatch {
377    pub round: u32,
378    /// In the order asked
379    pub questions: Vec<BatchedQuestion>,
380}
381
382/// One question in a [`WitnessBatch`] (0.104)
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct BatchedQuestion {
385    pub id: WitnessQuestionId,
386    pub seat: CouncilSeat,
387    pub question: String,
388}
389
390/// `line` starts like an id: ASCII digits, any backslashes, then `.`.
391/// Returns where the digits end.
392fn id_prefix(line: &str) -> Option<usize> {
393    let digits = line.bytes().take_while(u8::is_ascii_digit).count();
394    let rest = &line[digits..];
395    (digits > 0 && rest.trim_start_matches('\\').starts_with('.'))
396        .then_some(digits)
397}
398
399/// A seat's line, escaped so it cannot start like the program's id: one
400/// more backslash before the `.`
401fn escape(line: &str) -> String {
402    match id_prefix(line) {
403        Some(end) => format!("{}\\{}", &line[..end], &line[end..]),
404        None => line.to_string(),
405    }
406}
407
408/// [`escape`] undone. `None` if `line` starts like an id unescaped: no
409/// rendered line of a question does.
410fn unescape(line: &str) -> Option<String> {
411    match id_prefix(line) {
412        Some(end) => line[end..]
413            .strip_prefix('\\')
414            .map(|rest| format!("{}{rest}", &line[..end])),
415        None => Some(line.to_string()),
416    }
417}
418
419/// A question being read back: its id, the indent of its later lines,
420/// and its lines so far
421struct Reading {
422    id: WitnessQuestionId,
423    seat: CouncilSeat,
424    indent: usize,
425    lines: Vec<String>,
426}
427
428impl Reading {
429    fn done(self) -> BatchedQuestion {
430        BatchedQuestion {
431            id: self.id,
432            seat: self.seat,
433            question: self.lines.join("\n"),
434        }
435    }
436}
437
438impl WitnessBatch {
439    const HEADING: &'static str = "## Questions from the Council (round ";
440
441    /// The witness's user turn. See the [type docs](Self).
442    pub fn render(&self) -> String {
443        let mut out = format!("{}{})\n", Self::HEADING, self.round);
444        for seat in CouncilSeat::ALL {
445            let mut asked = self.questions.iter().filter(|q| q.seat == seat);
446            let Some(first) = asked.next() else { continue };
447            out.push_str(&format!("\n### {}\n", seat.name()));
448            for (n, q) in std::iter::once(first).chain(asked).enumerate() {
449                if n > 0 {
450                    out.push_str(">\n");
451                }
452                let id = q.id.to_string();
453                let indent = " ".repeat(id.len() + 2);
454                for (i, line) in q.question.split('\n').enumerate() {
455                    let text = escape(line);
456                    if i == 0 {
457                        out.push_str(&format!("> {id}."));
458                        if !text.is_empty() {
459                            out.push_str(&format!(" {text}"));
460                        }
461                    } else {
462                        out.push_str(&format!("> {indent}{text}"));
463                    }
464                    out.push('\n');
465                }
466            }
467        }
468        out
469    }
470
471    /// A rendered batch read back: its questions in the order rendered
472    /// (seat by seat). `None` if `text` is not one.
473    pub fn parse(text: &str) -> Option<Self> {
474        let mut lines = text.split('\n');
475        let round = lines
476            .next()?
477            .strip_prefix(Self::HEADING)?
478            .strip_suffix(')')?
479            .parse()
480            .ok()?;
481        let mut questions = Vec::new();
482        let mut seat = None;
483        let mut reading: Option<Reading> = None;
484        // Whether a blank `>` separated the last question from the next
485        let mut parted = false;
486        for line in lines {
487            if line.is_empty() || line.starts_with("### ") {
488                if parted {
489                    return None;
490                }
491                questions.extend(reading.take().map(Reading::done));
492                if let Some(name) = line.strip_prefix("### ") {
493                    seat = Some(
494                        *CouncilSeat::ALL.iter().find(|s| s.name() == name)?,
495                    );
496                }
497                continue;
498            }
499            if line == ">" {
500                // Between two questions of one seat, never before the first
501                if parted || reading.is_none() {
502                    return None;
503                }
504                parted = true;
505                continue;
506            }
507            let quoted = line.strip_prefix("> ")?;
508            let seat = seat?;
509            if let Some(end) =
510                id_prefix(quoted).filter(|&end| quoted[end..].starts_with('.'))
511            {
512                // A new question: `N.`, then ` ` and its first line
513                if reading.is_some() && !parted {
514                    return None;
515                }
516                parted = false;
517                questions.extend(reading.take().map(Reading::done));
518                let first = match &quoted[end + 1..] {
519                    "" => String::new(),
520                    rest => unescape(rest.strip_prefix(' ')?)?,
521                };
522                reading = Some(Reading {
523                    id: quoted[..end].parse().ok()?,
524                    seat,
525                    indent: end + 2,
526                    lines: vec![first],
527                });
528            } else {
529                // A later line, indented under its question's id
530                let current = reading.as_mut().filter(|_| !parted)?;
531                let rest = quoted.get(current.indent..).filter(|_| {
532                    quoted[..current.indent].bytes().all(|b| b == b' ')
533                })?;
534                current.lines.push(unescape(rest)?);
535            }
536        }
537        if parted {
538            return None;
539        }
540        questions.extend(reading.map(Reading::done));
541        (!questions.is_empty()).then_some(Self { round, questions })
542    }
543
544    /// The question `id`, if it is in this batch
545    pub fn get(&self, id: WitnessQuestionId) -> Option<&BatchedQuestion> {
546        self.questions.iter().find(|q| q.id == id)
547    }
548}
549
550/// Where a question is in its witness's [`Testimony`]
551///
552/// For an Agora witness ([`TestimonyRecord::Exchanges`]),
553/// `Message(i)` is the index of the question's batch in the exchanges.
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
555#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
556#[cfg_attr(feature = "schemars", schemars(inline))]
557#[serde(rename_all = "snake_case")]
558pub enum Asked {
559    /// The index of the question's user turn in `record.messages` (its
560    /// batch's, since 0.104), or of its batch's exchange (0.109)
561    Message(u32),
562    /// The index in `unanswered`
563    Unanswered(u32),
564}
565
566/// The name of `witness`'s testimony among the decision's attachments
567pub fn testimony_attachment_name(witness: &WitnessId) -> String {
568    format!("witness-{witness}.json")
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    const LAN: &str = "http://10.9.9.9:8080/";
576
577    fn witness() -> Witness {
578        serde_json::from_value(serde_json::json!({
579            "id": "qwen",
580            "name": "Qwen 3.8",
581            "interest": "the model under consideration",
582            "source": {"anthropic": {
583                "endpoint": LAN,
584                "prompt": {
585                    "model": "qwen3.8",
586                    "max_tokens": 32000,
587                    "system": "You are a witness.",
588                    "messages": []
589                }
590            }}
591        }))
592        .unwrap()
593    }
594
595    #[test]
596    fn the_source_never_serializes_or_prints() {
597        let w = witness();
598        assert!(matches!(w.source, WitnessSource::Anthropic { .. }));
599        let json = serde_json::to_string(&w).unwrap();
600        assert!(!json.contains("10.9.9.9"), "{json}");
601        assert!(!json.contains("source"), "{json}");
602        let debug = format!("{w:?}");
603        assert!(!debug.contains("10.9.9.9"), "{debug}");
604
605        let prompt = w.source.prompt().unwrap().clone();
606        let t = Testimony::new(&w, prompt, vec![]);
607        assert_eq!(t.witness().source, WitnessSource::Redacted);
608        let json = serde_json::to_string(&t).unwrap();
609        assert!(!json.contains("10.9.9.9"), "{json}");
610        assert!(!format!("{t:?}").contains("10.9.9.9"));
611    }
612
613    #[test]
614    fn a_testimony_round_trips() {
615        let w = witness();
616        let prompt = w.source.prompt().unwrap().clone();
617        let t = Testimony::new(
618            &w,
619            prompt,
620            vec![Unanswered {
621                seat: CouncilSeat::Lawyer,
622                round: 2,
623                question: "Which version?".into(),
624                why: Unheard::Other("connection reset".into()),
625                id: None,
626            }],
627        );
628        let json = serde_json::to_value(&t).unwrap();
629        let back: Testimony = serde_json::from_value(json.clone()).unwrap();
630        assert_eq!(back, t);
631        assert_eq!(serde_json::to_value(&back).unwrap(), json);
632        assert_eq!(
633            json["unanswered"][0]["why"],
634            serde_json::json!({"other": "connection reset"})
635        );
636    }
637
638    /// A round with no questions serializes as before 0.99, so every
639    /// earlier record keeps its bytes
640    #[test]
641    fn a_round_without_questions_is_unchanged() {
642        let old = serde_json::json!({
643            "number": 2,
644            "round_type": "deliberation",
645            "responses": [],
646            "steward_contribution": null
647        });
648        let round: super::super::CouncilRound =
649            serde_json::from_value(old.clone()).unwrap();
650        assert!(round.witness_questions.is_empty());
651        assert_eq!(serde_json::to_value(&round).unwrap(), old);
652
653        let mut asked = old.clone();
654        asked["witness_questions"] = serde_json::json!([
655            {"seat": "engineer", "witness": "qwen", "message": 1},
656            {"seat": "lawyer", "witness": "qwen", "unanswered": 0}
657        ]);
658        let round: super::super::CouncilRound =
659            serde_json::from_value(asked.clone()).unwrap();
660        assert_eq!(round.witness_questions[1].at, Asked::Unanswered(0));
661        assert_eq!(serde_json::to_value(&round).unwrap(), asked);
662    }
663
664    #[test]
665    fn a_question_points_flat() {
666        let q = WitnessQuestion {
667            seat: CouncilSeat::Artist,
668            witness: "qwen".parse().unwrap(),
669            id: None,
670            at: Asked::Message(3),
671        };
672        let json = serde_json::to_value(&q).unwrap();
673        assert_eq!(
674            json,
675            serde_json::json!({"seat": "artist", "witness": "qwen", "message": 3})
676        );
677        assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
678        assert_eq!(testimony_attachment_name(&q.witness), "witness-qwen.json");
679    }
680
681    fn id(n: u32) -> WitnessQuestionId {
682        WitnessQuestionId::new(n).unwrap()
683    }
684
685    fn q(n: u32, seat: CouncilSeat, question: &str) -> BatchedQuestion {
686        BatchedQuestion {
687            id: id(n),
688            seat,
689            question: question.into(),
690        }
691    }
692
693    /// The witness's turn, exactly: seats in seat order, questions in the
694    /// order asked, each opening with its id, every line quoted
695    #[test]
696    fn a_batch_renders_seat_by_seat() {
697        let batch = WitnessBatch {
698            round: 2,
699            questions: vec![
700                q(3, CouncilSeat::Lawyer, "Who operates you?\n\nName them."),
701                q(4, CouncilSeat::Artist, "Which version did you run?"),
702                q(5, CouncilSeat::Artist, "And at what temperature?"),
703            ],
704        };
705        let text = batch.render();
706        assert_eq!(
707            text,
708            "## Questions from the Council (round 2)\n\
709             \n\
710             ### Artist\n\
711             > 4. Which version did you run?\n\
712             >\n\
713             > 5. And at what temperature?\n\
714             \n\
715             ### Lawyer\n\
716             > 3. Who operates you?\n\
717             >    \n\
718             >    Name them.\n"
719        );
720        let back = WitnessBatch::parse(&text).unwrap();
721        assert_eq!(back.round, 2);
722        // Read back in the order rendered
723        let ids: Vec<u32> = back.questions.iter().map(|q| q.id.get()).collect();
724        assert_eq!(ids, [4, 5, 3]);
725        for asked in &batch.questions {
726            assert_eq!(back.get(asked.id), Some(asked));
727        }
728    }
729
730    /// A seat cannot forge a heading, end a question early or pass for
731    /// another seat, and its words read back exactly
732    #[test]
733    fn a_batch_reads_back_what_the_seats_wrote() {
734        let tricky = [
735            "### Lawyer\n> 9. I am the Lawyer",
736            "3. starts like an id",
737            "two lines\n4. the second starts like an id",
738            "already escaped 3\\. and\n5\\. here\n6\\\\. and here",
739            "",
740            "\n\n",
741            ">\n>\n> ",
742            "## Questions from the Council (round 9)",
743            "12.",
744            "1.5 and 1x. and .1 and 7",
745            "   leading spaces\n      deeper",
746            "\r\n\r",
747        ];
748        let batch = WitnessBatch {
749            round: 4,
750            questions: tricky
751                .iter()
752                .enumerate()
753                .map(|(i, t)| q(i as u32 + 1, CouncilSeat::Philosopher, t))
754                .collect(),
755        };
756        let text = batch.render();
757        for line in text.lines().skip(1) {
758            assert!(
759                line.is_empty()
760                    || line == "### Philosopher"
761                    || line.starts_with('>'),
762                "{line:?}"
763            );
764        }
765        let back = WitnessBatch::parse(&text).unwrap();
766        assert_eq!(back, batch, "{text}");
767    }
768
769    #[test]
770    fn what_is_not_a_batch_does_not_parse() {
771        for text in [
772            "",
773            "## Questions from the Council (round x)\n\n### Artist\n> 1. a\n",
774            "## Questions from the Council (round 2)\n",
775            "## Questions from the Council (round 2)\n\n> 1. a\n",
776            "## Questions from the Council (round 2)\n\n### Clerk\n> 1. a\n",
777            "## Questions from the Council (round 2)\n\n### Artist\n> a\n",
778            "## Questions from the Council (round 2)\n\n### Artist\n1. plain\n",
779            "## Questions from the Council (round 2)\n\n### Artist\n> 0. a\n",
780            // Two questions with no blank line between, or a blank too many
781            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> 2. b\n",
782            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n>\n",
783            "## Questions from the Council (round 2)\n\n### Artist\n>\n> 1. a\n",
784            // A later line not indented, or starting like an id unescaped
785            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> b\n",
786            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n>    2. b\n",
787            "{\"asked_by\": \"artist\", \"round\": 2, \"question\": \"a\"}",
788        ] {
789            assert!(WitnessBatch::parse(text).is_none(), "{text:?}");
790        }
791    }
792
793    /// Random batches over an alphabet of everything the format treats
794    /// specially read back exactly
795    #[test]
796    fn random_batches_read_back() {
797        const ALPHABET: &[&str] = &[
798            "1", "2", "9", "0", "12", ".", "\\", ">", "> ", " ", "  ", "\n",
799            "\r", "#", "### ", "Artist", "[q1]", "é", "\u{2028}", "a", "b",
800        ];
801        let mut seed: u64 = 0x5eed_ba7c_4000_0001;
802        let mut next = move |n: usize| {
803            seed = seed
804                .wrapping_mul(6_364_136_223_846_793_005)
805                .wrapping_add(1_442_695_040_888_963_407);
806            ((seed >> 33) as usize) % n
807        };
808        for round in 0..20_000u32 {
809            let count = 1 + next(6);
810            let mut n = 0;
811            let questions = (0..count)
812                .map(|_| {
813                    // Ids grow, and skip, as they do across an item
814                    n += 1 + next(40) as u32;
815                    let len = next(12);
816                    let text: String = (0..len)
817                        .map(|_| ALPHABET[next(ALPHABET.len())])
818                        .collect();
819                    q(n, CouncilSeat::ALL[next(4)], &text)
820                })
821                .collect();
822            let batch = WitnessBatch { round, questions };
823            let text = batch.render();
824            let back = WitnessBatch::parse(&text)
825                .unwrap_or_else(|| panic!("did not parse: {text:?}"));
826            for asked in &batch.questions {
827                assert_eq!(back.get(asked.id), Some(asked), "{text:?}");
828            }
829            assert_eq!(back.questions.len(), batch.questions.len());
830        }
831    }
832
833    /// A batched question names its id; a record from before 0.104 reads
834    /// as it did, with none, and keeps its bytes
835    #[test]
836    fn a_batched_question_points_at_its_batch() {
837        let q = WitnessQuestion {
838            seat: CouncilSeat::Lawyer,
839            witness: "qwen".parse().unwrap(),
840            id: Some(id(3)),
841            at: Asked::Message(1),
842        };
843        let json = serde_json::to_value(&q).unwrap();
844        assert_eq!(
845            json,
846            serde_json::json!({"seat": "lawyer", "witness": "qwen", "id": 3, "message": 1})
847        );
848        assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
849
850        let old = serde_json::json!({"seat": "engineer", "witness": "qwen", "unanswered": 0});
851        let read: WitnessQuestion =
852            serde_json::from_value(old.clone()).unwrap();
853        assert_eq!(read.id, None);
854        assert_eq!(serde_json::to_value(&read).unwrap(), old);
855
856        let missed = serde_json::json!({
857            "seat": "artist", "round": 2, "question": "a", "why": "timed_out"
858        });
859        let read: Unanswered = serde_json::from_value(missed.clone()).unwrap();
860        assert_eq!(read.id, None);
861        assert_eq!(serde_json::to_value(&read).unwrap(), missed);
862        let batched = Unanswered {
863            id: Some(id(2)),
864            ..read
865        };
866        assert_eq!(serde_json::to_value(&batched).unwrap()["id"], 2);
867    }
868
869    /// A testimony written before 0.109 (a conversation) reads as
870    /// [`TestimonyRecord::Conversation`] and writes back byte for byte
871    #[test]
872    fn a_conversation_testimony_keeps_its_bytes() {
873        let old = r###"{
874  "witness": {
875    "id": "qwen",
876    "name": "Qwen 3.8",
877    "description": "A local model.",
878    "interest": "the model under consideration"
879  },
880  "record": {
881    "model": "qwen3.8",
882    "messages": [
883      {
884        "role": "user",
885        "content": [
886          {
887            "type": "text",
888            "text": "## Questions from the Council (round 1)\n\n### Artist\n> 1. Which model are you?\n"
889          }
890        ]
891      },
892      {
893        "role": "assistant",
894        "content": [
895          {
896            "type": "text",
897            "text": "Qwen 3.8."
898          }
899        ]
900      }
901    ],
902    "max_tokens": 32000,
903    "system": [
904      {
905        "type": "text",
906        "text": "You are a witness."
907      }
908    ]
909  },
910  "unanswered": [
911    {
912      "seat": "lawyer",
913      "round": 2,
914      "question": "Who operates you?",
915      "why": "timed_out",
916      "id": 2
917    }
918  ]
919}"###;
920        let t: Testimony = serde_json::from_str(old).unwrap();
921        let prompt = t.record.conversation().expect("a conversation");
922        assert_eq!(prompt.messages.len(), 2);
923        assert!(t.record.exchanges().is_none());
924        assert_eq!(serde_json::to_string_pretty(&t).unwrap(), old);
925    }
926
927    fn agora_witness() -> Witness {
928        serde_json::from_value(serde_json::json!({
929            "id": "fact-checker",
930            "name": "The fact-checker",
931            "source": {"agora": {
932                "agent": "0e6c1f3a-5d7e-4b0a-9c55-2f1d8a3b4c5d"
933            }}
934        }))
935        .unwrap()
936    }
937
938    #[test]
939    fn an_agora_witness_names_its_account_and_never_serializes_it() {
940        let w = agora_witness();
941        let agent = w.source.agent().expect("an Agora witness");
942        assert_eq!(agent.to_string(), "0e6c1f3a-5d7e-4b0a-9c55-2f1d8a3b4c5d");
943        assert!(w.source.prompt().is_none());
944        assert!(format!("{w:?}").contains("Agora"));
945        let json = serde_json::to_string(&w).unwrap();
946        assert!(!json.contains("source"), "{json}");
947    }
948
949    /// An Agora witness's testimony: its exchanges, a JSON array, read
950    /// back exactly, an unanswered batch without an `answer`
951    #[test]
952    fn an_exchanges_testimony_round_trips() {
953        let at = |s: &str| s.parse::<DateTime<Utc>>().unwrap();
954        let message = |n: u128| MessageId::from(uuid::Uuid::from_u128(n));
955        let t = Testimony::new(
956            &agora_witness(),
957            vec![
958                AgoraExchange {
959                    round: 1,
960                    questions: vec![q(1, CouncilSeat::Artist, "Which model?")],
961                    sent: vec![message(10)],
962                    sent_at: at("2026-10-25T10:00:00Z"),
963                    answer: Some(AgoraAnswer {
964                        message: message(11),
965                        text: "I run on Claude Opus 5.5, by my own account."
966                            .into(),
967                        sent_at: at("2026-10-25T10:03:00Z"),
968                        received_at: at("2026-10-25T10:03:01Z"),
969                    }),
970                    late: vec![AgoraAnswer {
971                        message: message(14),
972                        text: "And one more thing.".into(),
973                        sent_at: at("2026-10-25T10:04:00Z"),
974                        received_at: at("2026-10-25T10:04:01Z"),
975                    }],
976                },
977                AgoraExchange {
978                    round: 2,
979                    questions: vec![q(2, CouncilSeat::Lawyer, "Who?")],
980                    sent: vec![message(12), message(13)],
981                    sent_at: at("2026-10-25T11:00:00Z"),
982                    answer: None,
983                    late: Vec::new(),
984                },
985            ],
986            vec![Unanswered {
987                seat: CouncilSeat::Lawyer,
988                round: 2,
989                question: "Who?".into(),
990                why: Unheard::TimedOut,
991                id: Some(id(2)),
992            }],
993        );
994        let json = serde_json::to_value(&t).unwrap();
995        assert!(json["record"].is_array(), "{json}");
996        assert!(json["record"][1].get("answer").is_none(), "{json}");
997        assert!(json["record"][1].get("late").is_none(), "none, no key");
998        assert_eq!(json["record"][0]["late"][0]["text"], "And one more thing.");
999        assert_eq!(
1000            json["record"][0]["answer"]["message"],
1001            "00000000-0000-0000-0000-00000000000b"
1002        );
1003        let back: Testimony = serde_json::from_value(json.clone()).unwrap();
1004        assert_eq!(back, t);
1005        assert_eq!(back.record.exchanges().unwrap().len(), 2);
1006        assert_eq!(serde_json::to_value(&back).unwrap(), json);
1007    }
1008
1009    /// A batch that fits is one DM, unlabelled; one that does not is
1010    /// parts, each labelled and within the cap, which join back into it
1011    #[test]
1012    fn a_long_batch_is_split_into_labelled_parts() {
1013        assert_eq!(split_for_dms("short", 64 * 1024), vec!["short"]);
1014
1015        let batch = WitnessBatch {
1016            round: 3,
1017            questions: (1..=20)
1018                .map(|n| {
1019                    q(n, CouncilSeat::ALL[n as usize % 4], &"é".repeat(2000))
1020                })
1021                .collect(),
1022        };
1023        let text = batch.render();
1024        assert!(text.len() > 64 * 1024);
1025        let parts = split_for_dms(&text, 64 * 1024);
1026        assert!(parts.len() >= 2);
1027        let n = parts.len();
1028        let mut joined = String::new();
1029        for (k, part) in parts.iter().enumerate() {
1030            assert!(part.len() <= 64 * 1024, "part {k} is {}", part.len());
1031            let label = part_label(k + 1, n);
1032            joined.push_str(part.strip_prefix(&label).expect("labelled"));
1033        }
1034        assert_eq!(joined, text);
1035        // Split between lines: every part but the last ends a line
1036        for part in &parts[..n - 1] {
1037            assert!(part.ends_with('\n'));
1038        }
1039
1040        // One line longer than a DM is cut at a char boundary
1041        let long = "é".repeat(100);
1042        let parts = split_for_dms(&long, 64);
1043        let joined: String = parts
1044            .iter()
1045            .enumerate()
1046            .map(|(k, p)| {
1047                p.strip_prefix(&part_label(k + 1, parts.len())).unwrap()
1048            })
1049            .collect();
1050        assert_eq!(joined, long);
1051        assert!(parts.iter().all(|p| p.len() <= 64));
1052    }
1053}