1use std::collections::HashMap;
19use std::sync::{Arc, Mutex, PoisonError};
20
21use async_trait::async_trait;
22use rhema_core::{
23 Content, EventSink, Message, ModelOutput, ModelProvider, ProviderError, Role, ToolResult,
24 ToolSpec, Usage,
25};
26
27use crate::compact::{self, PendingSummary, Section, SectionOrigin, Summarizer};
28use crate::summary;
29use crate::tree::Range;
30
31const WORKER_CONCURRENCY: usize = 4;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct TranscriptSettings {
39 pub max_view_bytes: usize,
44 pub step_bytes: usize,
48 pub max_section_messages: usize,
53 pub max_block_bytes: usize,
58}
59
60impl Default for TranscriptSettings {
61 fn default() -> Self {
62 Self {
63 max_view_bytes: 256_000,
68 step_bytes: 24_000,
69 max_section_messages: 128,
70 max_block_bytes: 32_000,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83pub struct UsageBreakdown {
84 pub system_prompt_tokens: u64,
86 pub skills_tokens: u64,
89 pub tool_calls_input_tokens: u64,
92 pub tool_calls_output_tokens: u64,
94 pub user_messages_tokens: u64,
96 pub model_output_tokens: u64,
99}
100
101impl UsageBreakdown {
102 pub fn labeled(&self) -> [(&'static str, u64); 6] {
104 [
105 ("System Prompt", self.system_prompt_tokens),
106 ("Skills", self.skills_tokens),
107 ("Tool Calls Input", self.tool_calls_input_tokens),
108 ("Tool Calls Output", self.tool_calls_output_tokens),
109 ("User Messages", self.user_messages_tokens),
110 ("Model Output", self.model_output_tokens),
111 ]
112 }
113
114 pub fn total(&self) -> u64 {
116 self.system_prompt_tokens
117 + self.skills_tokens
118 + self.tool_calls_input_tokens
119 + self.tool_calls_output_tokens
120 + self.user_messages_tokens
121 + self.model_output_tokens
122 }
123}
124
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
130pub struct LogContent {
131 pub tool_calls: usize,
133 pub tool_results: usize,
135 pub user_text: usize,
137 pub assistant_text: usize,
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
148pub struct TranscriptStats {
149 pub requests: u64,
151 pub pass_through: u64,
154 pub viewed: u64,
156 pub flushes: u64,
159 pub consolidations: u64,
162 pub summarizer_calls: u64,
164 pub summarizer_failures: u64,
167 pub log_messages: usize,
169 pub sectioned_messages: usize,
171 pub sections: usize,
173 pub block_bytes: usize,
175 pub band_bytes: usize,
178 pub tail_bytes: usize,
180 #[serde(default)]
185 pub log_bytes: usize,
186 #[serde(default)]
188 pub log_content: LogContent,
189 pub input_tokens: u64,
192 pub cached_input_tokens: u64,
194 pub output_tokens: u64,
196 pub usage: UsageBreakdown,
198}
199
200impl TranscriptStats {
201 pub fn cache_hit_rate(&self) -> Option<f64> {
205 if self.input_tokens == 0 {
206 None
207 } else {
208 Some(self.cached_input_tokens as f64 / self.input_tokens as f64)
209 }
210 }
211
212 pub fn carry(&mut self, saved: &Self) {
222 self.requests += saved.requests;
223 self.pass_through += saved.pass_through;
224 self.viewed += saved.viewed;
225 self.flushes += saved.flushes;
226 self.consolidations += saved.consolidations;
227 self.summarizer_calls += saved.summarizer_calls;
228 self.summarizer_failures += saved.summarizer_failures;
229 self.input_tokens += saved.input_tokens;
230 self.cached_input_tokens += saved.cached_input_tokens;
231 self.output_tokens += saved.output_tokens;
232 let usage = &mut self.usage;
233 let saved_usage = &saved.usage;
234 usage.system_prompt_tokens += saved_usage.system_prompt_tokens;
235 usage.skills_tokens += saved_usage.skills_tokens;
236 usage.tool_calls_input_tokens += saved_usage.tool_calls_input_tokens;
237 usage.tool_calls_output_tokens += saved_usage.tool_calls_output_tokens;
238 usage.user_messages_tokens += saved_usage.user_messages_tokens;
239 usage.model_output_tokens += saved_usage.model_output_tokens;
240 if self.log_messages == 0 {
241 self.log_messages = saved.log_messages;
242 self.sectioned_messages = saved.sectioned_messages;
243 self.sections = saved.sections;
244 self.block_bytes = saved.block_bytes;
245 self.band_bytes = saved.band_bytes;
246 self.tail_bytes = saved.tail_bytes;
247 self.log_bytes = saved.log_bytes;
248 self.log_content = saved.log_content;
249 }
250 }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum WorkerOutcome {
257 Upgraded,
259 Failed,
261}
262
263#[derive(Debug)]
268pub struct Transcript {
269 settings: TranscriptSettings,
270 log: Vec<Message>,
271 sizes: Vec<usize>,
274 total_bytes: usize,
278 content: LogContent,
281 cut: usize,
284 sections: Vec<Section>,
287 skills_bytes: usize,
290 skills: Vec<SkillText>,
293 min_skill_body: usize,
296 reads: Vec<Read>,
298 pinned: HashMap<String, Range>,
303 pending_shares: Shares,
307 stats: TranscriptStats,
308 saved: Option<Vec<Section>>,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct SkillText {
320 pub name: String,
322 pub body: String,
324}
325
326#[derive(Debug, Clone, Copy)]
329struct Read {
330 call: usize,
332 result: usize,
334 skill: usize,
336}
337
338const NEEDLE_BYTES: usize = 64;
341
342fn needle(body: &str) -> &str {
345 let mut end = NEEDLE_BYTES.min(body.len());
346 while !body.is_char_boundary(end) {
347 end -= 1;
348 }
349 &body[..end]
350}
351
352#[derive(Debug, Clone, Copy, Default)]
355struct Shares {
356 system_prompt: usize,
357 skills: usize,
358 tool_defs: usize,
359 tool_calls: usize,
360 tool_results: usize,
361 user_text: usize,
362 assistant_text: usize,
363}
364
365fn add_message_content(content: &mut LogContent, message: &Message) {
370 for part in &message.content {
371 match part {
372 Content::Text(text) => match message.role {
373 Role::User => content.user_text += text.len(),
374 Role::Assistant => content.assistant_text += text.len(),
375 Role::System | Role::Tool => {}
376 },
377 Content::ToolCall(call) => {
378 content.tool_calls += call.name.len() + call.args.to_string().len();
379 }
380 Content::ToolResult(result) => {
381 content.tool_results += result.content.len();
382 content.tool_results += result
384 .images
385 .iter()
386 .map(|image| image.data.len())
387 .sum::<usize>();
388 }
389 Content::Image(_) => {}
390 }
391 }
392}
393
394impl Shares {
395 fn total(&self) -> usize {
396 self.system_prompt
397 + self.skills
398 + self.tool_defs
399 + self.tool_calls
400 + self.tool_results
401 + self.user_text
402 + self.assistant_text
403 }
404}
405
406impl Transcript {
407 pub fn new(settings: TranscriptSettings) -> Self {
409 Self {
410 settings,
411 log: Vec::new(),
412 sizes: Vec::new(),
413 total_bytes: 0,
414 content: LogContent::default(),
415 cut: 0,
416 sections: Vec::new(),
417 skills_bytes: 0,
418 skills: Vec::new(),
419 min_skill_body: usize::MAX,
420 reads: Vec::new(),
421 pinned: HashMap::new(),
422 pending_shares: Shares::default(),
423 stats: TranscriptStats::default(),
424 saved: None,
425 }
426 }
427
428 pub fn observe(&mut self, messages: &[Message]) {
434 if messages.len() >= self.log.len() && messages[..self.log.len()] == self.log[..] {
435 let first = self.log.len();
436 for message in &messages[first..] {
437 let bytes = summary::message_bytes(message);
438 self.sizes.push(bytes);
439 self.total_bytes += bytes;
440 add_message_content(&mut self.content, message);
441 }
442 self.log.extend_from_slice(&messages[first..]);
443 self.scan_reads(first);
444 } else {
445 self.log = messages.to_vec();
446 self.sizes = Vec::with_capacity(self.log.len());
447 self.total_bytes = 0;
448 self.content = LogContent::default();
449 for message in &self.log {
450 let bytes = summary::message_bytes(message);
451 self.sizes.push(bytes);
452 self.total_bytes += bytes;
453 add_message_content(&mut self.content, message);
454 }
455 self.cut = 0;
456 self.sections.clear();
457 self.reads.clear();
458 self.scan_reads(0);
459 }
460 if let Some(saved) = self.saved.take() {
468 let mut end = 0usize;
469 let tiles = !saved.is_empty()
473 && saved.iter().all(|section| {
474 match section.range.id.checked_add(section.range.n) {
475 Some(next) if section.range.id == end => {
476 end = next;
477 end <= self.log.len()
478 }
479 _ => false,
480 }
481 })
482 && end <= self.log.len();
483 if tiles {
484 self.sections = saved;
485 self.cut = end;
486 }
487 }
488 self.pinned = self
494 .sections
495 .iter()
496 .flat_map(|section| {
497 section
498 .pinned
499 .iter()
500 .map(|name| (name.clone(), section.range))
501 })
502 .collect();
503 }
504
505 pub fn len(&self) -> usize {
507 self.log.len()
508 }
509
510 pub fn is_empty(&self) -> bool {
512 self.log.is_empty()
513 }
514
515 pub fn message(&self, id: usize) -> Option<&Message> {
517 self.log.get(id)
518 }
519
520 pub fn range(&self, range: Range) -> Option<&[Message]> {
525 let end = range.id.checked_add(range.n)?;
526 self.log.get(range.id..end)
527 }
528
529 pub fn stats(&self) -> TranscriptStats {
531 self.stats
532 }
533
534 pub fn carry(&mut self, saved: &TranscriptStats) {
538 self.stats.carry(saved);
539 }
540
541 pub fn saved_sections(&self) -> Vec<Section> {
544 self.sections.clone()
545 }
546
547 pub fn adopt_sections(&mut self, sections: Vec<Section>) {
551 self.saved = Some(sections);
552 }
553
554 pub fn reset_stats(&mut self) {
558 self.stats = TranscriptStats::default();
559 }
560
561 pub fn set_skills_bytes(&mut self, bytes: usize) {
566 self.skills_bytes = bytes;
567 }
568
569 pub fn set_skills(&mut self, skills: Vec<SkillText>) {
576 self.skills = skills;
577 self.min_skill_body = self
578 .skills
579 .iter()
580 .map(|skill| skill.body.len())
581 .min()
582 .unwrap_or(usize::MAX);
583 self.reads.clear();
584 self.scan_reads(0);
585 }
586
587 fn carried_skill(&self, result: &ToolResult) -> Option<usize> {
594 if result.content.len() < self.min_skill_body {
595 return None;
596 }
597 self.skills.iter().position(|skill| {
598 result.content.len() >= skill.body.len()
599 && result.content.contains(needle(&skill.body))
600 && result.content.contains(&skill.body)
601 })
602 }
603
604 fn scan_reads(&mut self, from: usize) {
611 for index in from..self.log.len() {
612 if self.log[index].role != Role::Tool {
613 continue;
614 }
615 for part in &self.log[index].content {
616 let Content::ToolResult(result) = part else {
617 continue;
618 };
619 let Some(skill) = self.carried_skill(result) else {
620 continue;
621 };
622 let calls = || {
627 self.log[index - 1]
628 .content
629 .iter()
630 .filter_map(|part| match part {
631 Content::ToolCall(call) => Some(call),
632 _ => None,
633 })
634 };
635 let paired = index > 0
636 && self.log[index - 1].role == Role::Assistant
637 && match (calls().next(), calls().nth(1)) {
638 (Some(call), None) => call.id == result.id,
639 _ => false,
640 };
641 if paired {
642 self.reads.push(Read {
643 call: index - 1,
644 result: index,
645 skill,
646 });
647 break;
648 }
649 }
650 }
651 }
652
653 fn read_starting(&self, index: usize) -> Option<Read> {
655 let at = self.reads.partition_point(|read| read.call < index);
656 self.reads
657 .get(at)
658 .copied()
659 .filter(|read| read.call == index)
660 }
661
662 pub fn stage_usage_shares(
667 &mut self,
668 system: Option<&str>,
669 tools: &[ToolSpec],
670 messages: &[Message],
671 ) {
672 let mut shares = Shares::default();
673 if let Some(system) = system {
674 let skills = self.skills_bytes.min(system.len());
675 shares.skills = skills;
676 shares.system_prompt = system.len() - skills;
677 }
678 for spec in tools {
681 shares.tool_defs +=
682 spec.name.len() + spec.description.len() + spec.parameters.to_string().len();
683 }
684 for message in messages {
685 for content in &message.content {
686 match content {
687 Content::Text(text) => match message.role {
688 Role::User => shares.user_text += text.len(),
689 Role::Assistant => shares.assistant_text += text.len(),
690 Role::System | Role::Tool => {}
691 },
692 Content::ToolCall(call) => {
693 shares.tool_calls += call.name.len() + call.args.to_string().len();
694 }
695 Content::ToolResult(result) => {
696 shares.tool_results += result.content.len();
697 shares.tool_results += result
700 .images
701 .iter()
702 .map(|image| image.data.len())
703 .sum::<usize>();
704 }
705 Content::Image(_) => {}
708 }
709 }
710 }
711 self.pending_shares = shares;
712 }
713
714 pub fn record_usage(&mut self, usage: &Usage) {
719 self.stats.input_tokens += u64::from(usage.input_tokens);
720 self.stats.cached_input_tokens += u64::from(usage.cached_input_tokens);
721 self.stats.output_tokens += u64::from(usage.output_tokens);
722 let shares = std::mem::take(&mut self.pending_shares);
723 let total = shares.total();
724 let input = u64::from(usage.input_tokens);
725 if total > 0 && input > 0 {
726 let spread = |bytes: usize| input * bytes as u64 / total as u64;
727 let breakdown = &mut self.stats.usage;
728 breakdown.system_prompt_tokens += spread(shares.system_prompt);
729 breakdown.skills_tokens += spread(shares.skills);
730 breakdown.tool_calls_input_tokens += spread(shares.tool_defs + shares.tool_calls);
731 breakdown.tool_calls_output_tokens += spread(shares.tool_results);
732 breakdown.user_messages_tokens += spread(shares.user_text);
733 breakdown.model_output_tokens += spread(shares.assistant_text);
734 }
735 self.stats.usage.model_output_tokens += u64::from(usage.output_tokens);
736 }
737
738 pub fn record_worker_call(&mut self, outcome: WorkerOutcome) {
742 self.stats.summarizer_calls += 1;
743 if outcome == WorkerOutcome::Failed {
744 self.stats.summarizer_failures += 1;
745 }
746 }
747
748 pub fn commit_summary(&mut self, pending: &PendingSummary, text: String) -> bool {
753 let Some(section) = self.sections.get(pending.section) else {
754 return false;
755 };
756 if section.range != pending.range {
757 return false;
758 }
759 let section = &mut self.sections[pending.section];
760 let mut text = compact::cap_summary(text);
761 for name in §ion.pinned {
764 if let Some(skill) = self.skills.iter().find(|skill| &skill.name == name) {
765 text.push_str("\n\n");
766 text.push_str(&compact::skill_section_text(name, &skill.body));
767 }
768 }
769 section.text = text;
770 section.origin = SectionOrigin::Model;
771 true
772 }
773
774 pub fn plan_flush(&mut self) -> Vec<PendingSummary> {
783 self.sync_sizes();
784 let len = self.log.len();
785 if self.total_bytes <= self.settings.max_view_bytes {
786 return Vec::new();
787 }
788 let tail_start = self.tail_index();
789 let tail_bytes = self.bytes(tail_start, len);
790 let allowance = self.settings.max_view_bytes.saturating_sub(tail_bytes);
791
792 let mut c_min = tail_start;
796 let mut budget = allowance;
797 while c_min > self.cut {
798 let cost = self.sizes[c_min - 1];
799 if cost > budget {
800 break;
801 }
802 budget -= cost;
803 c_min -= 1;
804 }
805 let mut pending = Vec::new();
806 if c_min > self.cut {
807 let half = allowance / 2;
812 let mut c_new = c_min;
813 let mut band = self.bytes(c_new, tail_start);
814 while c_new < tail_start && band > half {
819 band -= self.sizes[c_new];
820 c_new += 1;
821 }
822 while c_new < tail_start && self.log[c_new].role == Role::Tool {
826 c_new += 1;
827 }
828 if c_new > self.cut {
829 pending = self.extend_sections(self.cut, c_new);
830 self.cut = c_new;
831 self.stats.flushes += 1;
832 pending.extend(self.consolidate());
833 }
834 }
835 pending
836 }
837
838 pub fn view(&mut self, messages: &[Message]) -> Option<Vec<Message>> {
847 self.observe(messages);
848 self.plan_flush();
849 self.render()
850 }
851
852 pub fn render(&mut self) -> Option<Vec<Message>> {
856 self.stats.requests += 1;
857 self.stats.log_bytes = self.total_bytes;
858 self.stats.log_content = self.content;
859 if self.cut == 0 {
860 self.stats.pass_through += 1;
861 return None;
862 }
863 let len = self.log.len();
864 let tail_start = self.tail_index();
865 self.stats.log_messages = len;
866 self.stats.sectioned_messages = self.cut;
867 self.stats.sections = self.sections.len();
868 self.stats.block_bytes = self.block_bytes();
869 self.stats.band_bytes = self.bytes(self.cut, len);
870 self.stats.tail_bytes = self.bytes(tail_start, len);
871 self.stats.viewed += 1;
872
873 let mut text = String::from(
874 "<transcript>\n\
875 The blocks below cover this conversation's older messages,\n\
876 oldest first. Each block is headed `id+n|`: the n messages\n\
877 from index id on, condensed. zoom(id, n) returns those n\n\
878 messages whole; long output is cut and the note names the\n\
879 `at` to resume at. The messages after the blocks are\n\
880 recent; they are whole.\n\n",
881 );
882 for section in &self.sections {
883 text.push_str(&format!(
884 "{}+{}|\n{}\n\n",
885 section.range.id, section.range.n, section.text
886 ));
887 }
888 text.push_str("</transcript>");
889 let mut out = Vec::with_capacity(len - self.cut + 1);
890 out.push(Message::text(Role::User, text));
891 out.extend(self.log[self.cut..].iter().cloned());
892 Some(out)
893 }
894
895 fn tail_index(&self) -> usize {
900 let user = self
901 .log
902 .iter()
903 .rposition(|message| message.role == Role::User)
904 .unwrap_or(self.log.len());
905 user.max(self.cut)
906 }
907
908 fn bytes(&self, from: usize, to: usize) -> usize {
910 self.sizes[from..to].iter().sum()
911 }
912
913 fn block_bytes(&self) -> usize {
915 self.sections.iter().map(Section::view_bytes).sum()
916 }
917
918 fn sync_sizes(&mut self) {
922 self.sizes.truncate(self.log.len());
923 let mut patched = false;
924 while self.sizes.len() < self.log.len() {
925 let id = self.sizes.len();
926 self.sizes.push(summary::message_bytes(&self.log[id]));
927 patched = true;
928 }
929 if patched {
930 self.total_bytes = self.sizes.iter().sum();
931 self.content = LogContent::default();
932 for message in &self.log {
933 add_message_content(&mut self.content, message);
934 }
935 }
936 }
937
938 fn extend_sections(&mut self, start: usize, end: usize) -> Vec<PendingSummary> {
943 let mut pending = Vec::new();
944 let mut at = start;
945 while at < end {
946 if let Some(read) = self.read_starting(at) {
952 let range = Range {
953 id: at,
954 n: read.result + 1 - at,
955 };
956 let name = self.skills[read.skill].name.clone();
957 let section = match self.pinned.get(&name) {
958 Some(carried) => Section {
959 range,
960 text: compact::skill_pointer_text(&name, *carried),
961 origin: SectionOrigin::Extractive,
962 pinned: Vec::new(),
963 },
964 None => {
965 self.pinned.insert(name.clone(), range);
966 let body = self.skills[read.skill].body.clone();
967 Section {
968 range,
969 text: compact::skill_section_text(&name, &body),
970 origin: SectionOrigin::Extractive,
971 pinned: vec![name],
972 }
973 }
974 };
975 self.sections.push(section);
976 at = read.result + 1;
977 continue;
978 }
979 let mut stop = at;
980 let mut bytes = 0usize;
981 let mut count = 0usize;
982 while stop < end {
983 let cost = self.sizes[stop];
984 if count > 0
985 && (bytes + cost > self.settings.step_bytes
986 || count >= self.settings.max_section_messages)
987 {
988 break;
989 }
990 if count > 0 && self.read_starting(stop).is_some() {
993 break;
994 }
995 bytes += cost;
996 count += 1;
997 stop += 1;
998 }
999 while stop < end && self.log[stop].role == Role::Tool {
1000 stop += 1;
1001 }
1002 let range = Range {
1003 id: at,
1004 n: stop - at,
1005 };
1006 let messages = self.log[at..stop].to_vec();
1007 let text = compact::section_digest(&messages);
1008 let context = self.sections.last().map(|s| s.text.clone());
1012 self.sections.push(Section {
1013 range,
1014 text,
1015 origin: SectionOrigin::Extractive,
1016 pinned: Vec::new(),
1017 });
1018 pending.push(PendingSummary {
1019 section: self.sections.len() - 1,
1020 range,
1021 messages,
1022 context,
1023 });
1024 at = stop;
1025 }
1026 pending
1027 }
1028
1029 fn consolidate(&mut self) -> Vec<PendingSummary> {
1036 let mut pending = Vec::new();
1037 while self.sections.len() >= 2 && self.block_bytes() > self.settings.max_block_bytes {
1038 let range = Range {
1039 id: self.sections[0].range.id,
1040 n: self.sections[0].range.n + self.sections[1].range.n,
1041 };
1042 let messages = match range
1043 .id
1044 .checked_add(range.n)
1045 .and_then(|end| self.log.get(range.id..end))
1046 {
1047 Some(messages) => messages.to_vec(),
1048 None => return pending,
1052 };
1053 let mut text = compact::section_digest(&messages);
1054 let mut pinned: Vec<String> = Vec::new();
1059 for old in [&self.sections[0], &self.sections[1]] {
1060 for name in &old.pinned {
1061 if pinned.contains(name) {
1062 continue;
1063 }
1064 if let Some(skill) = self.skills.iter().find(|skill| &skill.name == name) {
1065 text.push_str("\n\n");
1066 text.push_str(&compact::skill_section_text(name, &skill.body));
1067 }
1068 pinned.push(name.clone());
1069 }
1070 }
1071 self.sections.drain(..2);
1072 self.sections.insert(
1073 0,
1074 Section {
1075 range,
1076 text,
1077 origin: SectionOrigin::Extractive,
1078 pinned,
1079 },
1080 );
1081 pending.push(PendingSummary {
1084 section: 0,
1085 range,
1086 messages,
1087 context: None,
1088 });
1089 self.stats.consolidations += 1;
1090 }
1091 pending
1092 }
1093}
1094
1095pub struct TranscriptView {
1102 inner: Arc<dyn ModelProvider>,
1103 transcript: Arc<Mutex<Transcript>>,
1104 summarizer: Option<Arc<dyn Summarizer>>,
1105}
1106
1107impl TranscriptView {
1108 pub fn new(inner: Arc<dyn ModelProvider>, transcript: Arc<Mutex<Transcript>>) -> Self {
1113 Self {
1114 inner,
1115 transcript,
1116 summarizer: None,
1117 }
1118 }
1119
1120 pub fn with_summarizer(
1124 inner: Arc<dyn ModelProvider>,
1125 transcript: Arc<Mutex<Transcript>>,
1126 summarizer: Arc<dyn Summarizer>,
1127 ) -> Self {
1128 Self {
1129 inner,
1130 transcript,
1131 summarizer: Some(summarizer),
1132 }
1133 }
1134}
1135
1136impl std::fmt::Debug for TranscriptView {
1137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1138 f.debug_struct("TranscriptView").finish_non_exhaustive()
1140 }
1141}
1142
1143#[async_trait]
1144impl ModelProvider for TranscriptView {
1145 async fn complete(
1146 &self,
1147 mut req: rhema_core::ModelRequest,
1148 sink: &mut dyn EventSink,
1149 ) -> Result<ModelOutput, ProviderError> {
1150 let pending = {
1153 let mut transcript = self
1154 .transcript
1155 .lock()
1156 .unwrap_or_else(PoisonError::into_inner);
1157 transcript.observe(&req.messages);
1158 transcript.plan_flush()
1159 };
1160 if let Some(worker) = &self.summarizer
1166 && !pending.is_empty()
1167 {
1168 Self::spawn_worker_fill(Arc::clone(worker), Arc::clone(&self.transcript), pending);
1169 }
1170 let view = {
1171 let mut transcript = self
1172 .transcript
1173 .lock()
1174 .unwrap_or_else(PoisonError::into_inner);
1175 transcript.render()
1176 };
1177 if let Some(messages) = view {
1178 req.messages = messages;
1179 }
1180 {
1181 let mut transcript = self
1182 .transcript
1183 .lock()
1184 .unwrap_or_else(PoisonError::into_inner);
1185 transcript.stage_usage_shares(req.system.as_deref(), &req.tools, &req.messages);
1186 }
1187 let result = self.inner.complete(req, sink).await;
1188 if let Ok(output) = &result {
1189 let mut transcript = self
1190 .transcript
1191 .lock()
1192 .unwrap_or_else(PoisonError::into_inner);
1193 transcript.record_usage(&output.usage);
1194 }
1195 result
1196 }
1197}
1198
1199impl TranscriptView {
1200 fn spawn_worker_fill(
1208 worker: Arc<dyn Summarizer>,
1209 mirror: Arc<Mutex<Transcript>>,
1210 pending: Vec<PendingSummary>,
1211 ) {
1212 tokio::spawn(async move {
1213 for chunk in pending.chunks(WORKER_CONCURRENCY) {
1214 let mut fills = Vec::with_capacity(chunk.len());
1215 for item in chunk {
1216 let worker = Arc::clone(&worker);
1217 let messages = item.messages.clone();
1218 let context = item.context.clone();
1219 let section = item.section;
1220 let range = item.range;
1221 fills.push(tokio::spawn(async move {
1222 (
1223 worker.summarize(&messages, context.as_deref()).await,
1224 section,
1225 range,
1226 )
1227 }));
1228 }
1229 for fill in fills {
1230 let outcome = match fill.await {
1234 Ok((Ok(text), section, range)) => Some((text, section, range)),
1235 _ => None,
1236 };
1237 let mut transcript = mirror.lock().unwrap_or_else(PoisonError::into_inner);
1238 match outcome {
1239 Some((text, section, range)) => {
1240 let pending = PendingSummary {
1243 section,
1244 range,
1245 messages: Vec::new(),
1246 context: None,
1247 };
1248 transcript.commit_summary(&pending, text);
1249 transcript.record_worker_call(WorkerOutcome::Upgraded);
1250 }
1251 None => transcript.record_worker_call(WorkerOutcome::Failed),
1252 }
1253 }
1254 }
1255 });
1256 }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262 use rhema_core::{Content, ToolCall, ToolResult};
1263 use std::collections::HashSet;
1264 use std::sync::Mutex as StdMutex;
1265 use std::sync::atomic::{AtomicBool, Ordering};
1266
1267 async fn wait_for(done: impl Fn() -> bool) {
1270 for _ in 0..10_000 {
1271 if done() {
1272 return;
1273 }
1274 tokio::task::yield_now().await;
1275 }
1276 panic!("the background fill never finished");
1277 }
1278
1279 fn result_bytes(kb: usize) -> String {
1281 "x".repeat(kb * 1024)
1282 }
1283
1284 fn call_round(id: &str, result_kb: usize) -> Vec<Message> {
1285 vec![
1286 Message {
1287 role: Role::Assistant,
1288 content: vec![
1289 Content::Text("checking".into()),
1290 Content::ToolCall(ToolCall {
1291 id: id.into(),
1292 name: "bash".into(),
1293 args: serde_json::json!({ "command": "make test" }),
1294 provider_data: serde_json::Value::Null,
1295 }),
1296 ],
1297 },
1298 Message {
1299 role: Role::Tool,
1300 content: vec![Content::ToolResult(ToolResult::success(
1301 id,
1302 result_bytes(result_kb),
1303 ))],
1304 },
1305 ]
1306 }
1307
1308 fn long_history(rounds: usize) -> Vec<Message> {
1311 let mut messages = vec![Message::text(Role::User, "fix the build")];
1312 for i in 0..rounds {
1313 messages.extend(call_round(&format!("c{i}"), 8));
1314 }
1315 messages.push(Message::text(Role::User, "and now summarize"));
1316 messages
1317 }
1318
1319 fn settings(max_view_bytes: usize) -> TranscriptSettings {
1320 TranscriptSettings {
1321 max_view_bytes,
1322 ..TranscriptSettings::default()
1323 }
1324 }
1325
1326 #[test]
1327 fn below_the_budget_the_view_is_none() {
1328 let mut transcript = Transcript::new(settings(128_000));
1329 let messages = long_history(4); assert!(transcript.view(&messages).is_none());
1331 let stats = transcript.stats();
1332 assert_eq!(stats.requests, 1);
1333 assert_eq!(stats.pass_through, 1);
1334 assert_eq!(stats.viewed, 0);
1335 }
1336
1337 #[test]
1338 fn past_the_budget_the_aged_prefix_is_block_and_the_tail_whole() {
1339 let mut transcript = Transcript::new(settings(128_000));
1340 let messages = long_history(30); let view = transcript.view(&messages).unwrap();
1342
1343 assert_eq!(view[0].role, Role::User);
1346 let rhema_core::Content::Text(text) = &view[0].content[0] else {
1347 panic!("text view message");
1348 };
1349 assert!(text.starts_with("<transcript>"), "{text}");
1350 assert!(text.contains("zoom(id, n)"), "{text}");
1351 assert!(text.contains("0+"), "{text}");
1353
1354 let last = view.last().unwrap();
1356 assert_eq!(*last, Message::text(Role::User, "and now summarize"));
1357 assert!(view.len() < messages.len(), "the view is shorter");
1358
1359 let stats = transcript.stats();
1360 assert_eq!(stats.flushes, 1);
1361 assert_eq!(stats.sectioned_messages, transcript.cut);
1362 assert!(stats.block_bytes > 0);
1363 assert!(stats.band_bytes + stats.block_bytes < 245 * 1024);
1364 }
1365
1366 #[test]
1367 fn between_flushes_the_block_is_append_only() {
1368 let mut transcript = Transcript::new(settings(40_000));
1369 let first = long_history(5); let view = transcript.view(&first).unwrap();
1371 let first_block = match &view[0].content[..] {
1372 [rhema_core::Content::Text(text)] => text.clone(),
1373 _ => panic!("text view message"),
1374 };
1375 let sections_after_first = transcript.sections.clone();
1376
1377 let mut second = first.clone();
1380 second.extend(call_round("c5", 1));
1381 second.push(Message::text(Role::User, "next"));
1382 let view = transcript.view(&second).unwrap();
1383 let second_block = match &view[0].content[..] {
1384 [rhema_core::Content::Text(text)] => text.clone(),
1385 _ => panic!("text view message"),
1386 };
1387 assert_eq!(transcript.stats.flushes, 1, "no second flush");
1388 assert_eq!(transcript.sections, sections_after_first);
1389 assert_eq!(first_block, second_block, "the block is byte-identical");
1390 assert!(view.len() > 1);
1391 }
1392
1393 #[test]
1394 fn a_flush_overshoots_to_half_the_allowance_and_the_hysteresis_holds() {
1395 let mut transcript = Transcript::new(settings(128_000));
1396 let mut messages = vec![Message::text(Role::User, "fix the build")];
1397 for i in 0..20 {
1398 messages.extend(call_round(&format!("c{i}"), 8)); }
1400 messages.push(Message::text(Role::User, "go on"));
1401 assert!(transcript.view(&messages).is_some());
1402 let stats = transcript.stats();
1403 assert_eq!(stats.flushes, 1);
1404 let allowance = 128_000 - stats.tail_bytes;
1407 let band = stats.band_bytes - stats.tail_bytes;
1408 assert!(
1409 band <= allowance / 2,
1410 "band {band} over half the allowance {}",
1411 allowance / 2
1412 );
1413 let mut grown = messages.clone();
1416 for i in 20..26 {
1417 grown.extend(call_round(&format!("c{i}"), 8)); }
1419 grown.push(Message::text(Role::User, "still going"));
1420 transcript.view(&grown);
1421 assert_eq!(transcript.stats.flushes, 1, "flushed inside the hysteresis");
1422 }
1423
1424 #[test]
1425 fn a_flush_never_splits_a_tool_round_at_a_section_boundary() {
1426 let mut transcript = Transcript::new(settings(20_000));
1427 let messages = vec![Message::text(Role::User, "go")]
1429 .into_iter()
1430 .chain(call_round("c0", 16))
1431 .chain(call_round("c1", 16))
1432 .chain(std::iter::once(Message::text(Role::User, "done")))
1433 .collect::<Vec<_>>();
1434 let view = transcript.view(&messages).unwrap();
1435 let carried_calls: HashSet<String> = view
1438 .iter()
1439 .flat_map(|m| &m.content)
1440 .filter_map(|c| match c {
1441 rhema_core::Content::ToolCall(call) => Some(call.id.clone()),
1442 _ => None,
1443 })
1444 .collect();
1445 for message in &view {
1446 for content in &message.content {
1447 if let rhema_core::Content::ToolResult(result) = content {
1448 assert!(
1449 carried_calls.contains(&result.id),
1450 "orphan result {} in the view",
1451 result.id
1452 );
1453 }
1454 }
1455 }
1456 for section in &transcript.sections {
1460 let first = transcript
1461 .range(section.range)
1462 .expect("section in the log")
1463 .first()
1464 .unwrap();
1465 assert_ne!(first.role, Role::Tool, "section {:?}", section.range);
1466 }
1467 }
1468
1469 #[test]
1470 fn sections_respect_the_step_and_message_bounds() {
1471 let mut transcript = Transcript::new(TranscriptSettings {
1472 step_bytes: 20_000,
1473 max_section_messages: 3,
1474 ..settings(20_000)
1475 });
1476 let messages = long_history(8);
1477 transcript.observe(&messages);
1478 transcript.plan_flush();
1479 assert!(transcript.sections.len() > 1, "bounds force a split");
1480 for section in &transcript.sections {
1481 assert!(section.range.n <= 4, "{:?}", section.range);
1482 }
1483 let mut at = 0;
1485 for section in &transcript.sections {
1486 assert_eq!(section.range.id, at);
1487 at += section.range.n;
1488 }
1489 assert_eq!(at, transcript.cut);
1490 }
1491
1492 #[test]
1493 fn a_worker_upgrade_lands_and_a_wrong_one_is_rejected() {
1494 let mut transcript = Transcript::new(settings(20_000));
1495 let messages = long_history(30);
1496 transcript.observe(&messages);
1497 let pending = transcript.plan_flush();
1498 assert!(!pending.is_empty());
1499
1500 let good = pending[0].clone();
1501 assert!(transcript.commit_summary(&good, "## Goals Met\n- all".into()));
1502 assert_eq!(transcript.sections[0].text, "## Goals Met\n- all");
1503 assert_eq!(transcript.sections[0].origin, SectionOrigin::Model);
1504
1505 let stale = PendingSummary {
1508 section: pending[0].section,
1509 range: Range { id: 999, n: 1 },
1510 messages: Vec::new(),
1511 context: None,
1512 };
1513 assert!(!transcript.commit_summary(&stale, "ghost".into()));
1514 assert_eq!(transcript.sections[0].text, "## Goals Met\n- all");
1515 }
1516
1517 #[test]
1518 fn a_resume_adopts_the_saved_sections_and_skips_the_refill() {
1519 let log = long_history(30);
1520 let mut first = Transcript::new(settings(20_000));
1522 first.observe(&log);
1523 let pending = first.plan_flush();
1524 assert!(!pending.is_empty());
1525 for item in &pending {
1526 assert!(first.commit_summary(item, format!("## Goals Met\n- model {}", item.section)));
1527 }
1528 let saved = first.saved_sections();
1529 assert!(saved.iter().all(|s| s.origin == SectionOrigin::Model));
1530
1531 let mut second = Transcript::new(settings(20_000));
1534 second.adopt_sections(saved.clone());
1535 second.observe(&log);
1536 assert_eq!(second.cut, first.cut);
1537 assert_eq!(second.sections, saved);
1538 assert!(second.plan_flush().is_empty());
1540
1541 let mut extended = log.clone();
1543 extended.push(Message::text(Role::User, "one more"));
1544 second.observe(&extended);
1545 let view = second.render().unwrap();
1546 assert_eq!(view.last(), Some(&Message::text(Role::User, "one more")));
1547 }
1548
1549 fn skill_body(name: &str, rules: usize) -> String {
1553 let mut body = format!("---\nname: {name}\ndescription: the {name} trigger\n---\n\n");
1554 for i in 0..rules {
1555 body.push_str(&format!("Rule {i}: do the {name} thing.\n"));
1556 }
1557 body
1558 }
1559
1560 fn cat_result(id: &str, stdout: &str) -> Message {
1564 Message {
1565 role: Role::Tool,
1566 content: vec![Content::ToolResult(ToolResult::success(
1567 id,
1568 format!("exit code 0\n--- stdout ---\n{stdout}\n--- stderr ---\n"),
1569 ))],
1570 }
1571 }
1572
1573 fn skill_round(id: &str, name: &str, rules: usize) -> Vec<Message> {
1575 let body = skill_body(name, rules);
1576 vec![
1577 Message {
1578 role: Role::Assistant,
1579 content: vec![
1580 Content::Text("loading the skill".into()),
1581 Content::ToolCall(ToolCall {
1582 id: id.into(),
1583 name: "bash".into(),
1584 args: serde_json::json!({
1585 "command": format!("cat .rhema/skills/{name}/SKILL.md")
1586 }),
1587 provider_data: serde_json::Value::Null,
1588 }),
1589 ],
1590 },
1591 cat_result(id, &body),
1592 ]
1593 }
1594
1595 #[test]
1596 fn a_flushed_skill_read_rides_the_block_whole_and_unsummarized() {
1597 let mut transcript = Transcript::new(settings(40_000));
1598 transcript.set_skills(vec![SkillText {
1599 name: "unslop".into(),
1600 body: skill_body("unslop", 40),
1601 }]);
1602 let mut messages = vec![Message::text(Role::User, "fix the build")];
1603 messages.extend(skill_round("s1", "unslop", 40));
1604 messages.extend(long_history(6));
1605 messages.push(Message::text(Role::User, "and now summarize"));
1606
1607 transcript.observe(&messages);
1608 let pending = transcript.plan_flush();
1609 let body = skill_body("unslop", 40);
1610
1611 let pinned = transcript
1615 .sections
1616 .iter()
1617 .find(|section| !section.pinned.is_empty())
1618 .expect("one pinned section");
1619 assert_eq!(pinned.pinned, ["unslop"]);
1620 assert_eq!(pinned.range.n, 2);
1621 assert!(pinned.text.contains(&body), "{}", pinned.text);
1622 assert!(!pending.iter().any(|summary| summary.range == pinned.range));
1623
1624 let view = transcript.render().unwrap();
1627 let rhema_core::Content::Text(text) = &view[0].content[0] else {
1628 panic!("text view message");
1629 };
1630 assert!(text.contains(&body), "{text}");
1631 }
1632
1633 #[test]
1634 fn a_reread_compacts_to_a_pointer_at_the_first_copy() {
1635 let mut transcript = Transcript::new(settings(40_000));
1636 transcript.set_skills(vec![SkillText {
1637 name: "unslop".into(),
1638 body: skill_body("unslop", 40),
1639 }]);
1640 let mut messages = vec![Message::text(Role::User, "fix the build")];
1641 messages.extend(skill_round("s1", "unslop", 40));
1642 messages.extend(skill_round("s2", "unslop", 40));
1643 messages.extend(long_history(6));
1644 messages.push(Message::text(Role::User, "and now summarize"));
1645
1646 transcript.observe(&messages);
1647 transcript.plan_flush();
1648
1649 let pinned: Vec<_> = transcript
1650 .sections
1651 .iter()
1652 .filter(|section| !section.pinned.is_empty())
1653 .collect();
1654 assert_eq!(pinned.len(), 1, "one carrier");
1655 let carried = pinned[0].range;
1656 let body = skill_body("unslop", 40);
1657 let view = transcript.render().unwrap();
1658 let rhema_core::Content::Text(text) = &view[0].content[0] else {
1659 panic!("text view message");
1660 };
1661 assert_eq!(text.matches(&body).count(), 1, "one copy of the body");
1662 assert!(
1663 text.contains(&format!(
1664 "skill unslop read again; its whole text is carried at {}+{}|",
1665 carried.id, carried.n
1666 )),
1667 "{text}"
1668 );
1669 }
1670
1671 #[test]
1672 fn a_multicall_turn_digests_normally_without_a_pin() {
1673 let mut transcript = Transcript::new(settings(40_000));
1674 transcript.set_skills(vec![SkillText {
1675 name: "unslop".into(),
1676 body: skill_body("unslop", 40),
1677 }]);
1678 let mut messages = vec![Message::text(Role::User, "fix the build")];
1679 messages.push(Message {
1682 role: Role::Assistant,
1683 content: vec![
1684 Content::ToolCall(ToolCall {
1685 id: "a".into(),
1686 name: "bash".into(),
1687 args: serde_json::json!({ "command": "cat the skill" }),
1688 provider_data: serde_json::Value::Null,
1689 }),
1690 Content::ToolCall(ToolCall {
1691 id: "b".into(),
1692 name: "bash".into(),
1693 args: serde_json::json!({ "command": "make test" }),
1694 provider_data: serde_json::Value::Null,
1695 }),
1696 ],
1697 });
1698 messages.push(cat_result("a", &skill_body("unslop", 40)));
1699 messages.push(cat_result("b", "ok"));
1700 messages.extend(long_history(6));
1701 messages.push(Message::text(Role::User, "and now summarize"));
1702
1703 transcript.observe(&messages);
1704 transcript.plan_flush();
1705 assert!(
1706 transcript.sections.iter().all(|s| s.pinned.is_empty()),
1707 "{:?}",
1708 transcript.sections
1709 );
1710 }
1711
1712 #[test]
1713 fn consolidation_keeps_the_pinned_body_in_the_block() {
1714 let mut transcript = Transcript::new(TranscriptSettings {
1715 max_view_bytes: 40_000,
1716 step_bytes: 4_000,
1717 max_block_bytes: 1_000,
1718 ..TranscriptSettings::default()
1719 });
1720 transcript.set_skills(vec![SkillText {
1721 name: "unslop".into(),
1722 body: skill_body("unslop", 40),
1723 }]);
1724 let mut messages = vec![Message::text(Role::User, "fix the build")];
1725 messages.extend(skill_round("s1", "unslop", 40));
1726 messages.extend(long_history(30));
1727 messages.push(Message::text(Role::User, "and now summarize"));
1728
1729 transcript.observe(&messages);
1730 transcript.plan_flush();
1731 let body = skill_body("unslop", 40);
1732 assert!(
1733 transcript.stats.consolidations > 0,
1734 "the fixture must consolidate"
1735 );
1736 let view = transcript.render().unwrap();
1737 let rhema_core::Content::Text(text) = &view[0].content[0] else {
1738 panic!("text view message");
1739 };
1740 assert!(text.contains(&body), "{text}");
1741 }
1742
1743 #[test]
1744 fn the_model_fill_leaves_the_pinned_body_beneath_it() {
1745 let mut transcript = Transcript::new(TranscriptSettings {
1746 max_view_bytes: 40_000,
1747 step_bytes: 4_000,
1748 max_block_bytes: 1_000,
1749 ..TranscriptSettings::default()
1750 });
1751 transcript.set_skills(vec![SkillText {
1752 name: "unslop".into(),
1753 body: skill_body("unslop", 40),
1754 }]);
1755 let mut messages = vec![Message::text(Role::User, "fix the build")];
1756 messages.extend(skill_round("s1", "unslop", 40));
1757 messages.extend(long_history(30));
1758 messages.push(Message::text(Role::User, "and now summarize"));
1759
1760 transcript.observe(&messages);
1761 let pending = transcript.plan_flush();
1762 let body = skill_body("unslop", 40);
1763
1764 let summary = pending
1767 .iter()
1768 .rev()
1769 .find(|summary| {
1770 transcript
1771 .sections
1772 .get(summary.section)
1773 .is_some_and(|section| {
1774 section.range == summary.range && !section.pinned.is_empty()
1775 })
1776 })
1777 .expect("a live union pending over a pinned section")
1778 .clone();
1779 assert!(transcript.commit_summary(&summary, "## Goals Met\n- dense fill".into()));
1780 let text = &transcript.sections[summary.section].text;
1781 assert!(text.starts_with("## Goals Met\n- dense fill"), "{text}");
1782 assert!(text.contains(&body), "{text}");
1783 }
1784
1785 #[test]
1786 fn a_resume_keeps_deduplicating_rereads() {
1787 let mut first = Transcript::new(settings(40_000));
1788 first.set_skills(vec![SkillText {
1789 name: "unslop".into(),
1790 body: skill_body("unslop", 40),
1791 }]);
1792 let mut messages = vec![Message::text(Role::User, "fix the build")];
1793 messages.extend(skill_round("s1", "unslop", 40));
1794 messages.extend(long_history(8));
1795 messages.push(Message::text(Role::User, "and now summarize"));
1796 first.observe(&messages);
1797 first.plan_flush();
1798 let saved = first.saved_sections();
1799 assert!(!saved.is_empty(), "the first session must have flushed");
1800
1801 let mut second = Transcript::new(settings(40_000));
1804 second.adopt_sections(saved);
1805 let mut resumed = messages.clone();
1806 resumed.extend(skill_round("s2", "unslop", 40));
1807 resumed.extend(long_history(2));
1808 resumed.push(Message::text(Role::User, "one more"));
1809 second.observe(&resumed);
1810 second.plan_flush();
1811 let body = skill_body("unslop", 40);
1812 let view = second.render().unwrap();
1813 let rhema_core::Content::Text(text) = &view[0].content[0] else {
1814 panic!("text view message");
1815 };
1816 assert_eq!(text.matches(&body).count(), 1, "one copy after resume");
1817 }
1818
1819 #[test]
1820 fn tampered_saved_sections_fall_back_to_the_rebuild() {
1821 let mut transcript = Transcript::new(settings(20_000));
1822 transcript.adopt_sections(vec![Section {
1823 range: Range { id: 5, n: 9 },
1824 text: "## Goals Met\n- orphan".into(),
1825 origin: SectionOrigin::Model,
1826 pinned: Vec::new(),
1827 }]);
1828 transcript.observe(&long_history(30));
1829 assert_eq!(transcript.cut, 0);
1831 assert!(transcript.sections.is_empty());
1832 assert!(!transcript.plan_flush().is_empty());
1834 }
1835
1836 #[test]
1837 fn a_pending_summary_carries_the_adjacent_digest_as_context() {
1838 let mut transcript = Transcript::new(settings(20_000));
1839 transcript.observe(&long_history(30));
1840 let pending = transcript.plan_flush();
1841 assert!(pending.len() >= 2);
1842 assert_eq!(pending[0].context, None);
1845 for (prev, item) in pending.iter().zip(pending.iter().skip(1)) {
1846 let expected = transcript.sections[prev.section].text.clone();
1847 assert_eq!(item.context.as_deref(), Some(expected.as_str()));
1848 }
1849
1850 let first = pending[0].clone();
1853 assert!(transcript.commit_summary(&first, "## Goals Met\n- model".into()));
1854 let mut extended = long_history(30);
1855 for i in 0..12 {
1856 extended.push(Message::text(
1857 Role::User,
1858 format!("extra filler {i}: {}", "y".repeat(1024)),
1859 ));
1860 }
1861 transcript.observe(&extended);
1862 let expected = transcript.sections.last().unwrap().text.clone();
1865 let pending = transcript.plan_flush();
1866 assert!(!pending.is_empty());
1867 assert_eq!(pending[0].context.as_deref(), Some(expected.as_str()));
1868 }
1869
1870 #[test]
1871 fn the_block_consolidates_past_its_cap() {
1872 let mut transcript = Transcript::new(TranscriptSettings {
1873 max_block_bytes: 500,
1874 ..settings(20_000)
1875 });
1876 let messages = long_history(30);
1877 transcript.observe(&messages);
1878 transcript.plan_flush();
1879 assert!(transcript.block_bytes() <= 500);
1880 assert!(transcript.stats.consolidations > 0);
1881 let mut at = 0;
1883 for section in &transcript.sections {
1884 assert_eq!(section.range.id, at);
1885 at += section.range.n;
1886 }
1887 assert_eq!(at, transcript.cut);
1888 }
1889
1890 #[test]
1891 fn a_resumed_session_rebuilds_the_compaction_from_nothing() {
1892 let mut transcript = Transcript::new(settings(20_000));
1893 transcript.observe(&long_history(30));
1894 transcript.plan_flush();
1895 assert!(transcript.cut > 0);
1896 transcript.observe(&long_history(2));
1898 assert_eq!(transcript.cut, 0);
1899 assert!(transcript.sections.is_empty());
1900 }
1901
1902 #[test]
1903 fn the_breakdown_spreads_reported_input_by_wire_share() {
1904 let mut transcript = Transcript::new(TranscriptSettings::default());
1905 transcript.set_skills_bytes(500);
1906 let system = format!("{}{}", "b".repeat(1_500), "s".repeat(500));
1909 let call_args = serde_json::json!({ "c": "x".repeat(100) });
1910 let messages = vec![
1911 Message::text(Role::User, "u".repeat(1_000)),
1912 Message {
1913 role: Role::Assistant,
1914 content: vec![
1915 Content::Text("a".repeat(400)),
1916 Content::ToolCall(ToolCall {
1917 id: "c1".into(),
1918 name: "bash".into(),
1919 args: call_args.clone(),
1920 provider_data: serde_json::Value::Null,
1921 }),
1922 ],
1923 },
1924 Message {
1925 role: Role::Tool,
1926 content: vec![Content::ToolResult(ToolResult::success(
1927 "c1",
1928 "r".repeat(1_000),
1929 ))],
1930 },
1931 ];
1932 transcript.stage_usage_shares(Some(&system), &[], &messages);
1933 transcript.record_usage(&Usage {
1934 input_tokens: 10_000,
1935 output_tokens: 100,
1936 cached_input_tokens: 0,
1937 });
1938
1939 let total = 2_000 + 1_000 + 400 + ("bash".len() + call_args.to_string().len()) + 1_000;
1940 let spread = |bytes: usize| 10_000 * bytes as u64 / total as u64;
1941 let b = transcript.stats().usage;
1942 assert_eq!(b.system_prompt_tokens, spread(1_500));
1943 assert_eq!(b.skills_tokens, spread(500));
1944 assert_eq!(
1945 b.tool_calls_input_tokens,
1946 spread("bash".len() + call_args.to_string().len())
1947 );
1948 assert_eq!(b.tool_calls_output_tokens, spread(1_000));
1949 assert_eq!(b.user_messages_tokens, spread(1_000));
1950 assert_eq!(b.model_output_tokens, spread(400) + 100);
1953 transcript.record_usage(&Usage {
1956 input_tokens: 1_000,
1957 output_tokens: 0,
1958 cached_input_tokens: 0,
1959 });
1960 assert_eq!(transcript.stats().usage.user_messages_tokens, spread(1_000));
1961 }
1962
1963 #[test]
1964 fn a_skills_share_past_the_system_clamps() {
1965 let mut transcript = Transcript::new(TranscriptSettings::default());
1966 transcript.set_skills_bytes(9_999);
1967 transcript.stage_usage_shares(Some("short"), &[], &[]);
1968 transcript.record_usage(&Usage {
1969 input_tokens: 100,
1970 output_tokens: 0,
1971 cached_input_tokens: 0,
1972 });
1973 let b = transcript.stats().usage;
1974 assert_eq!(b.skills_tokens, 100);
1975 assert_eq!(b.system_prompt_tokens, 0);
1976 }
1977
1978 #[test]
1979 fn a_carry_continues_the_saved_run() {
1980 let saved = TranscriptStats {
1981 requests: 10,
1982 pass_through: 6,
1983 viewed: 4,
1984 flushes: 1,
1985 consolidations: 1,
1986 summarizer_calls: 2,
1987 summarizer_failures: 1,
1988 input_tokens: 90_000,
1989 cached_input_tokens: 70_000,
1990 output_tokens: 10_000,
1991 usage: UsageBreakdown {
1992 system_prompt_tokens: 20_000,
1993 skills_tokens: 2_000,
1994 tool_calls_input_tokens: 30_000,
1995 tool_calls_output_tokens: 30_000,
1996 user_messages_tokens: 8_000,
1997 model_output_tokens: 10_000,
1998 },
1999 log_messages: 12,
2001 sectioned_messages: 4,
2002 sections: 2,
2003 block_bytes: 900,
2004 band_bytes: 3_000,
2005 tail_bytes: 1_200,
2006 log_bytes: 5_400,
2007 log_content: LogContent {
2008 tool_calls: 100,
2009 tool_results: 4_000,
2010 user_text: 300,
2011 assistant_text: 1_000,
2012 },
2013 };
2014
2015 let mut fresh = TranscriptStats::default();
2019 fresh.carry(&saved);
2020 assert_eq!(fresh, saved);
2021
2022 let mut live = TranscriptStats {
2026 requests: 2,
2027 input_tokens: 5_000,
2028 output_tokens: 300,
2029 usage: UsageBreakdown {
2030 user_messages_tokens: 5_000,
2031 model_output_tokens: 300,
2032 ..UsageBreakdown::default()
2033 },
2034 log_messages: 4,
2035 block_bytes: 100,
2036 ..TranscriptStats::default()
2037 };
2038 live.carry(&saved);
2039 assert_eq!(live.requests, 12);
2040 assert_eq!(live.input_tokens, 95_000);
2041 assert_eq!(live.output_tokens, 10_300);
2042 assert_eq!(live.usage.user_messages_tokens, 13_000);
2043 assert_eq!(live.usage.model_output_tokens, 10_300);
2044 assert_eq!(live.usage.system_prompt_tokens, 20_000);
2045 assert_eq!(live.block_bytes, 100, "the live mirror's snapshot");
2046 assert_eq!(live.log_messages, 4);
2047
2048 let mut later = TranscriptStats {
2051 requests: 3,
2052 input_tokens: 8_000,
2053 ..TranscriptStats::default()
2054 };
2055 later.carry(&saved);
2056 assert_eq!(later.requests, 13);
2057 assert_eq!(later.input_tokens, 98_000);
2058 }
2059
2060 #[test]
2064 fn the_log_bytes_snapshot_counts_the_log_once() {
2065 let mut transcript = Transcript::new(settings(128_000));
2066 let messages = long_history(4); transcript.observe(&messages);
2068 transcript.render();
2069 let expected: usize = messages.iter().map(summary::message_bytes).sum();
2070 assert_eq!(transcript.stats().log_bytes, expected);
2071
2072 let grown = long_history(5);
2074 transcript.observe(&grown);
2075 transcript.render();
2076 let expected: usize = grown.iter().map(summary::message_bytes).sum();
2077 assert_eq!(transcript.stats().log_bytes, expected);
2078 }
2079
2080 #[test]
2085 fn the_content_split_counts_each_message_once() {
2086 let mut transcript = Transcript::new(settings(128_000));
2087 let messages = long_history(4);
2088 transcript.observe(&messages);
2089 transcript.render();
2090 let content = transcript.stats().log_content;
2091 assert_eq!(content.tool_results, 4 * result_bytes(8).len());
2092 assert_eq!(content.assistant_text, 4 * "checking".len());
2093 assert_eq!(
2094 content.user_text,
2095 "fix the build".len() + "and now summarize".len()
2096 );
2097 let args = serde_json::json!({ "command": "make test" }).to_string();
2098 assert_eq!(content.tool_calls, 4 * ("bash".len() + args.len()));
2099 assert_eq!(
2100 content.tool_calls + content.tool_results + content.user_text + content.assistant_text,
2101 transcript.stats().log_bytes
2102 );
2103 }
2104
2105 #[test]
2106 fn reset_stats_zeroes_the_accounting_and_keeps_the_log() {
2107 let mut transcript = Transcript::new(TranscriptSettings::default());
2108 transcript.observe(&[Message::text(Role::User, "kept")]);
2109 transcript.record_usage(&Usage {
2110 input_tokens: 1_000,
2111 output_tokens: 100,
2112 cached_input_tokens: 0,
2113 });
2114 assert_eq!(transcript.len(), 1);
2115 transcript.reset_stats();
2116 assert_eq!(transcript.stats(), TranscriptStats::default());
2117 assert_eq!(transcript.len(), 1);
2118 }
2119
2120 #[test]
2121 fn usage_records_and_the_hit_rate_reads() {
2122 let mut transcript = Transcript::new(TranscriptSettings::default());
2123 assert!(transcript.stats().cache_hit_rate().is_none());
2124 transcript.record_usage(&Usage {
2125 input_tokens: 1_000,
2126 output_tokens: 100,
2127 cached_input_tokens: 800,
2128 });
2129 let stats = transcript.stats();
2130 assert_eq!(stats.input_tokens, 1_000);
2131 assert_eq!(stats.cached_input_tokens, 800);
2132 let rate = stats.cache_hit_rate().unwrap();
2133 assert!((rate - 0.8).abs() < 1e-9);
2134 }
2135
2136 struct Recording(StdMutex<Vec<Vec<Message>>>);
2138 #[async_trait]
2139 impl ModelProvider for Recording {
2140 async fn complete(
2141 &self,
2142 req: rhema_core::ModelRequest,
2143 _sink: &mut dyn EventSink,
2144 ) -> Result<ModelOutput, ProviderError> {
2145 self.0.lock().unwrap().push(req.messages);
2146 Ok(ModelOutput {
2147 message: Message::text(Role::Assistant, "done"),
2148 stop_reason: rhema_core::StopReason::EndTurn,
2149 usage: Usage {
2150 input_tokens: 500,
2151 output_tokens: 10,
2152 cached_input_tokens: 400,
2153 },
2154 })
2155 }
2156 }
2157
2158 #[tokio::test]
2159 async fn the_decorator_rewrites_the_request_and_the_mirror_stays_whole() {
2160 let recording = Arc::new(Recording(StdMutex::new(Vec::new())));
2161 let transcript = Arc::new(Mutex::new(Transcript::new(settings(20_000))));
2162 let view = TranscriptView::new(recording.clone(), transcript.clone());
2163 let messages = long_history(30);
2164 let req = rhema_core::ModelRequest {
2165 system: None,
2166 messages: messages.clone(),
2167 tools: Vec::new(),
2168 params: rhema_core::ChatParams::new("m"),
2169 };
2170 let mut sink = NoopSink;
2171 view.complete(req, &mut sink).await.unwrap();
2172
2173 let seen = recording.0.lock().unwrap();
2174 let sent = &seen[0];
2175 assert!(sent.len() < messages.len());
2176 assert_eq!(sent.last().unwrap().role, Role::User);
2177 let mirror = transcript.lock().unwrap();
2179 assert_eq!(mirror.len(), messages.len());
2180 assert_eq!(mirror.message(0), Some(&messages[0]));
2181 assert_eq!(mirror.stats().cache_hit_rate(), Some(0.8));
2183 }
2184
2185 #[tokio::test]
2186 async fn the_decorator_upgrades_sections_with_its_summarizer() {
2187 struct Worker;
2189 #[async_trait]
2190 impl ModelProvider for Worker {
2191 async fn complete(
2192 &self,
2193 _req: rhema_core::ModelRequest,
2194 _sink: &mut dyn EventSink,
2195 ) -> Result<ModelOutput, ProviderError> {
2196 Ok(ModelOutput {
2197 message: Message::text(Role::Assistant, "## Goals Met\n- worker wrote this"),
2198 stop_reason: rhema_core::StopReason::EndTurn,
2199 usage: Usage::default(),
2200 })
2201 }
2202 }
2203
2204 let recording = Arc::new(Recording(StdMutex::new(Vec::new())));
2205 let transcript = Arc::new(Mutex::new(Transcript::new(settings(20_000))));
2206 let view = TranscriptView::with_summarizer(
2207 recording.clone(),
2208 transcript.clone(),
2209 Arc::new(compact::ModelSummarizer::new(Arc::new(Worker), "cheap")),
2210 );
2211 let req = rhema_core::ModelRequest {
2212 system: None,
2213 messages: long_history(30),
2214 tools: Vec::new(),
2215 params: rhema_core::ChatParams::new("m"),
2216 };
2217 let mut sink = NoopSink;
2218 view.complete(req, &mut sink).await.unwrap();
2219
2220 {
2223 let sent = recording.0.lock().unwrap();
2224 let rhema_core::Content::Text(text) = &sent[0][0].content[0] else {
2225 panic!("text view message");
2226 };
2227 assert!(!text.contains("worker wrote this"), "{text}");
2228 }
2229
2230 wait_for(|| {
2232 transcript
2233 .lock()
2234 .unwrap()
2235 .sections
2236 .iter()
2237 .all(|s| s.origin == SectionOrigin::Model)
2238 })
2239 .await;
2240
2241 {
2244 let mirror = transcript.lock().unwrap();
2245 let stats = mirror.stats();
2246 assert!(stats.summarizer_calls > 0);
2247 assert_eq!(stats.summarizer_failures, 0);
2248 assert!(
2249 mirror
2250 .sections
2251 .iter()
2252 .all(|s| s.text.contains("worker wrote this"))
2253 );
2254 }
2255
2256 let req = rhema_core::ModelRequest {
2258 system: None,
2259 messages: long_history(30),
2260 tools: Vec::new(),
2261 params: rhema_core::ChatParams::new("m"),
2262 };
2263 let mut sink = NoopSink;
2264 view.complete(req, &mut sink).await.unwrap();
2265 let sent = recording.0.lock().unwrap();
2266 let rhema_core::Content::Text(text) = &sent[1][0].content[0] else {
2267 panic!("text view message");
2268 };
2269 assert!(text.contains("worker wrote this"), "{text}");
2270 }
2271
2272 #[tokio::test]
2273 async fn a_failing_summarizer_never_blocks_the_request() {
2274 struct Failing;
2275 #[async_trait]
2276 impl ModelProvider for Failing {
2277 async fn complete(
2278 &self,
2279 _req: rhema_core::ModelRequest,
2280 _sink: &mut dyn EventSink,
2281 ) -> Result<ModelOutput, ProviderError> {
2282 Err(ProviderError::Timeout)
2283 }
2284 }
2285
2286 let recording = Arc::new(Recording(StdMutex::new(Vec::new())));
2287 let transcript = Arc::new(Mutex::new(Transcript::new(settings(20_000))));
2288 let view = TranscriptView::with_summarizer(
2289 recording.clone(),
2290 transcript.clone(),
2291 Arc::new(compact::ModelSummarizer::new(Arc::new(Failing), "cheap")),
2292 );
2293 let req = rhema_core::ModelRequest {
2294 system: None,
2295 messages: long_history(30),
2296 tools: Vec::new(),
2297 params: rhema_core::ChatParams::new("m"),
2298 };
2299 let mut sink = NoopSink;
2300 view.complete(req, &mut sink).await.unwrap();
2304 {
2305 let sent = recording.0.lock().unwrap();
2308 assert_eq!(sent.len(), 1);
2309 }
2310 wait_for(|| transcript.lock().unwrap().stats().summarizer_calls > 0).await;
2311 let mirror = transcript.lock().unwrap();
2312 assert_eq!(
2313 mirror.stats().summarizer_failures,
2314 mirror.stats().summarizer_calls
2315 );
2316 assert!(
2317 mirror
2318 .sections
2319 .iter()
2320 .all(|s| s.origin == SectionOrigin::Extractive)
2321 );
2322 }
2323
2324 struct Gated(Arc<AtomicBool>);
2328
2329 #[async_trait]
2330 impl ModelProvider for Gated {
2331 async fn complete(
2332 &self,
2333 _req: rhema_core::ModelRequest,
2334 _sink: &mut dyn EventSink,
2335 ) -> Result<ModelOutput, ProviderError> {
2336 while !self.0.load(Ordering::SeqCst) {
2337 tokio::task::yield_now().await;
2338 }
2339 Ok(ModelOutput {
2340 message: Message::text(Role::Assistant, "## Goals Met\n- late but dense"),
2341 stop_reason: rhema_core::StopReason::EndTurn,
2342 usage: Usage::default(),
2343 })
2344 }
2345 }
2346
2347 #[tokio::test]
2348 async fn a_slow_summarizer_never_delays_the_request() {
2349 let gate = Arc::new(AtomicBool::new(false));
2350 let recording = Arc::new(Recording(StdMutex::new(Vec::new())));
2351 let transcript = Arc::new(Mutex::new(Transcript::new(settings(20_000))));
2352 let view = TranscriptView::with_summarizer(
2353 recording.clone(),
2354 transcript.clone(),
2355 Arc::new(compact::ModelSummarizer::new(
2356 Arc::new(Gated(Arc::clone(&gate))),
2357 "cheap",
2358 )),
2359 );
2360 let req = rhema_core::ModelRequest {
2361 system: None,
2362 messages: long_history(30),
2363 tools: Vec::new(),
2364 params: rhema_core::ChatParams::new("m"),
2365 };
2366 let turn = tokio::spawn(async move {
2367 let mut sink = NoopSink;
2368 view.complete(req, &mut sink).await.unwrap();
2369 });
2370
2371 wait_for(|| !recording.0.lock().unwrap().is_empty()).await;
2374 wait_for(|| turn.is_finished()).await;
2375 assert!(
2376 !gate.load(Ordering::SeqCst),
2377 "the request waited on the gated worker"
2378 );
2379
2380 gate.store(true, Ordering::SeqCst);
2382 wait_for(|| {
2383 transcript
2384 .lock()
2385 .unwrap()
2386 .sections
2387 .iter()
2388 .all(|s| s.origin == SectionOrigin::Model)
2389 })
2390 .await;
2391 let mirror = transcript.lock().unwrap();
2392 assert!(
2393 mirror
2394 .sections
2395 .iter()
2396 .all(|s| s.text.contains("late but dense")),
2397 "{:?}",
2398 mirror.sections
2399 );
2400 }
2401
2402 struct NoopSink;
2403
2404 impl EventSink for NoopSink {
2405 fn emit(&mut self, _event: rhema_core::Event<'_>) {}
2406 }
2407}