1use misanthropic::Prompt;
32use serde::{Deserialize, Serialize};
33
34use chrono::{DateTime, Utc};
35
36use super::CouncilSeat;
37use crate::ids::{AgentId, MessageId, WitnessId, WitnessQuestionId};
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct Witness {
42 pub id: WitnessId,
43 pub name: String,
45 #[serde(default)]
47 pub description: String,
48 #[serde(default)]
50 pub interest: String,
51 #[serde(default, skip_serializing)]
54 pub source: WitnessSource,
55}
56
57impl Witness {
58 pub fn redacted(&self) -> Self {
60 Self {
61 source: WitnessSource::Redacted,
62 ..self.clone()
63 }
64 }
65}
66
67#[derive(Clone, PartialEq, Default, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum WitnessSource {
72 Anthropic {
74 #[serde(default)]
76 endpoint: Option<url::Url>,
77 prompt: Box<Prompt>,
81 },
82 Agora {
87 agent: AgentId,
89 },
90 #[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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct Testimony {
138 witness: Witness,
139 pub record: TestimonyRecord,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub unanswered: Vec<Unanswered>,
143}
144
145impl Testimony {
146 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 pub fn witness(&self) -> &Witness {
161 &self.witness
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(untagged)]
170pub enum TestimonyRecord {
171 Exchanges(Vec<AgoraExchange>),
174 Conversation(Box<Prompt>),
177}
178
179impl TestimonyRecord {
180 pub fn conversation(&self) -> Option<&Prompt> {
182 match self {
183 Self::Conversation(prompt) => Some(prompt),
184 Self::Exchanges(_) => None,
185 }
186 }
187
188 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct AgoraExchange {
212 pub round: u32,
213 pub questions: Vec<BatchedQuestion>,
216 pub sent: Vec<MessageId>,
219 pub sent_at: DateTime<Utc>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub answer: Option<AgoraAnswer>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub late: Vec<AgoraAnswer>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct AgoraAnswer {
238 pub message: MessageId,
239 pub text: String,
241 pub sent_at: DateTime<Utc>,
243 pub received_at: DateTime<Utc>,
245}
246
247pub fn split_for_dms(text: &str, max_bytes: usize) -> Vec<String> {
257 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 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
291pub fn part_label(k: usize, n: usize) -> String {
293 format!("[part {k} of {n}]\n\n")
294}
295
296#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub id: Option<WitnessQuestionId>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum Unheard {
312 Refusal,
314 MaxTokens,
316 Unreachable,
318 Busy,
320 TimedOut,
322 Unparsed,
324 Withdrawn,
326 Other(String),
328}
329
330#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub id: Option<WitnessQuestionId>,
343 #[serde(flatten)]
344 pub at: Asked,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct WitnessBatch {
377 pub round: u32,
378 pub questions: Vec<BatchedQuestion>,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct BatchedQuestion {
385 pub id: WitnessQuestionId,
386 pub seat: CouncilSeat,
387 pub question: String,
388}
389
390fn 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
399fn 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
408fn 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
419struct 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 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 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 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 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 if reading.is_some() && !parted {
514 return None;
515 }
516 parted = false;
517 questions.extend(reading.take().map(Reading::done));
518 let first = match "ed[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 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 pub fn get(&self, id: WitnessQuestionId) -> Option<&BatchedQuestion> {
546 self.questions.iter().find(|q| q.id == id)
547 }
548}
549
550#[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 Message(u32),
562 Unanswered(u32),
564}
565
566pub 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 #[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 #[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 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 #[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 "## 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 "## 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 #[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 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 #[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 #[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 #[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 #[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 for part in &parts[..n - 1] {
1037 assert!(part.ends_with('\n'));
1038 }
1039
1040 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}