Skip to main content

rhema_transcript/
view.rs

1//! The request view: the provider decorator that rewrites
2//! `req.messages` under a byte budget, and the mirror `zoom` reads.
3//!
4//! The decorator sits outside everything else on the request path, as
5//! paste expansion does: the engine clones the session into the request
6//! untouched, the decorator projects it, the session keeps every
7//! message (SPEC.md §6). Requests carry the full history, so the
8//! decorator's mirror is exactly the log as of the last request — a
9//! resumed session rebuilds it wholesale on the first call.
10//!
11//! Past the budget the aged prefix is covered by the stepped
12//! compaction block ([`crate::compact`]): sections tiling `[0, cut)`,
13//! appended only at flushes so the request prefix stays cache-stable
14//! between them. This module owns the cut and the sections; the
15//! summarizer seam and the section writers live in
16//! [`crate::compact`].
17
18use 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
31/// How many section fills the background worker runs at once. A flush
32/// can chunk a whole allowance into a dozen sections; the cap keeps
33/// one worker provider from seeing a stampede.
34const WORKER_CONCURRENCY: usize = 4;
35
36/// The view's settings.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct TranscriptSettings {
39    /// The byte budget a request's messages aim for. The transcript
40    /// engages only past it — below, requests pass through
41    /// byte-identical — and the current turn may exceed it: the tail is
42    /// never cut.
43    pub max_view_bytes: usize,
44    /// The bytes one section's messages aim for: the summarizer
45    /// worker's input bound, and the granularity compaction works at.
46    /// A section carries at least one message whatever this says.
47    pub step_bytes: usize,
48    /// The messages one section covers at most. Bounds the extractive
49    /// stand-in's lossiness: each section's digest is a few hundred
50    /// bytes, so this caps how much history one line may paper over
51    /// when no model fill is configured.
52    pub max_section_messages: usize,
53    /// The bytes the committed block may reach before the oldest
54    /// sections consolidate into one. Consolidation rewrites the
55    /// block — a full cache break — so it fires only past this cap,
56    /// and merges down to half of it before stopping.
57    pub max_block_bytes: usize,
58}
59
60impl Default for TranscriptSettings {
61    fn default() -> Self {
62        Self {
63            // Tool-heavy sessions (build logs, file dumps in results)
64            // run oversized tails constantly; 256 KB keeps the
65            // allowance — and with it the stretch between flushes —
66            // worth the name at that workload.
67            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/// The session's token usage, split by what it bought. The five input
76/// categories share each request's reported input tokens in proportion
77/// to their wire bytes (tool *definitions* ride with the calls that
78/// use them); the model's generated output tokens all land on
79/// `model_output_tokens`, which also carries the assistant text the
80/// history re-sends. The compaction block travels as a user message
81/// and counts there.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83pub struct UsageBreakdown {
84    /// The system prompt minus its skills section.
85    pub system_prompt_tokens: u64,
86    /// The skills section of the system prompt, when the agent could
87    /// isolate it at render time; zero otherwise.
88    pub skills_tokens: u64,
89    /// Tool definitions plus the tool-call arguments re-sent in
90    /// assistant messages.
91    pub tool_calls_input_tokens: u64,
92    /// Tool results — the tools' output, re-sent as input.
93    pub tool_calls_output_tokens: u64,
94    /// User text, the compaction block included.
95    pub user_messages_tokens: u64,
96    /// The model's words: assistant text in the history plus every
97    /// generated output token.
98    pub model_output_tokens: u64,
99}
100
101impl UsageBreakdown {
102    /// The six categories, labeled as the Usage page spells them.
103    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    /// The grand total the percentages read against.
115    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/// The log's own wire bytes by category — each message counted once,
126/// the content the billed split re-sends once per request. The system
127/// prompt, the skills section, and the tool definitions ride every
128/// request but live in no message, so they have no figure here.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
130pub struct LogContent {
131    /// Tool-call arguments in assistant messages.
132    pub tool_calls: usize,
133    /// Tool results in tool messages.
134    pub tool_results: usize,
135    /// User text, the compaction block included.
136    pub user_text: usize,
137    /// Assistant text.
138    pub assistant_text: usize,
139}
140
141/// The compaction's benchmark snapshot: what the view has been doing,
142/// and what the providers' own usage says about the prefix cache. All
143/// counters run from the transcript's construction; a resumed session
144/// keeps its run continuous by folding the saved counters in with
145/// [`TranscriptStats::carry`]. The struct is the saved shape: the
146/// frontend serializes it into the session file verbatim.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
148pub struct TranscriptStats {
149    /// Requests the decorator forwarded.
150    pub requests: u64,
151    /// Requests below the budget, sent byte-identical: the whole
152    /// prefix a cache holds.
153    pub pass_through: u64,
154    /// Requests carrying the compaction block plus the whole band.
155    pub viewed: u64,
156    /// Flushes: cut movements, the only moments the view's prefix
157    /// breaks.
158    pub flushes: u64,
159    /// Consolidations: block rewrites past the block cap, each one a
160    /// full cache break.
161    pub consolidations: u64,
162    /// Summarizer calls issued, successes and failures together.
163    pub summarizer_calls: u64,
164    /// Summarizer calls that failed; each left the extractive
165    /// stand-in in place.
166    pub summarizer_failures: u64,
167    /// The mirror's message count at the last request.
168    pub log_messages: usize,
169    /// Messages under the block at the last request: the cut.
170    pub sectioned_messages: usize,
171    /// The block's section count at the last request.
172    pub sections: usize,
173    /// The block's bytes at the last request.
174    pub block_bytes: usize,
175    /// The bytes carried whole from the cut to the log's end, at the
176    /// last request.
177    pub band_bytes: usize,
178    /// The current turn's bytes at the last request.
179    pub tail_bytes: usize,
180    /// The log's wire bytes at the last request — what the
181    /// conversation actually holds, each message counted once. The
182    /// usage split below sums the same content once per request;
183    /// this is the once.
184    #[serde(default)]
185    pub log_bytes: usize,
186    /// The log's bytes by category at the last request, counted once.
187    #[serde(default)]
188    pub log_content: LogContent,
189    /// Input tokens the providers reported, summed over requests —
190    /// cached tokens included.
191    pub input_tokens: u64,
192    /// Of those, the tokens the provider served from its prompt cache.
193    pub cached_input_tokens: u64,
194    /// Output tokens the providers reported, summed over requests.
195    pub output_tokens: u64,
196    /// The session's usage split by category.
197    pub usage: UsageBreakdown,
198}
199
200impl TranscriptStats {
201    /// The session's prompt-cache hit rate: cached input over all
202    /// input. `None` before any usage was reported. The providers'
203    /// own numbers — the ground truth the stepped design optimizes.
204    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    /// Folds a saved session's counters in, so a resumed session's
213    /// stats read as one run with the pre-resume requests included:
214    /// the run counters and the usage split sum, the cache rate keeps
215    /// covering everything. The at-last-request snapshots describe
216    /// the mirror that measured them, so they stay this side's —
217    /// except while this mirror has not looked at a log yet
218    /// (`log_messages == 0`), where the snapshot is what a Usage page
219    /// opened before the first request would otherwise render as
220    /// zeros, and the saved one is the honest figure.
221    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/// How one summarizer call ended, as [`Transcript::record_worker_call`]
254/// counts it.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum WorkerOutcome {
257    /// The worker answered; the section's stand-in was upgraded.
258    Upgraded,
259    /// The worker failed; the extractive stand-in stays in place.
260    Failed,
261}
262
263/// The mirror: the log as the last request carried it, the stepped
264/// compaction state over it, and the benchmark counters. Shared
265/// between the decorator (which refreshes it) and the zoom tool (which
266/// reads the log).
267#[derive(Debug)]
268pub struct Transcript {
269    settings: TranscriptSettings,
270    log: Vec<Message>,
271    /// Per-message wire bytes, parallel to `log`: the budget's unit,
272    /// counted once per message instead of per request.
273    sizes: Vec<usize>,
274    /// [`Vec`]`sizes` summed once, maintained by `observe` and read by
275    /// the planner and the counters. The whole-log walk this replaces
276    /// re-serialized every tool call's arguments on every request.
277    total_bytes: usize,
278    /// The log's bytes by category, maintained by `observe` the same
279    /// way; `render` snapshots it into `stats.log_content`.
280    content: LogContent,
281    /// First message not under the block: `[0, cut)` is sectioned,
282    /// `[cut, len)` travels whole. Moves only at flushes.
283    cut: usize,
284    /// The committed block: sections tiling `[0, cut)`, oldest first,
285    /// append-only between flushes.
286    sections: Vec<Section>,
287    /// The skills section's byte share of the system prompt, measured
288    /// at render time by the agent that assembled it. Zero until set.
289    skills_bytes: usize,
290    /// The skills whose bodies pin whole into the block. Empty, and
291    /// nothing pins.
292    skills: Vec<SkillText>,
293    /// The shortest pinned body: the detect gate, below which no
294    /// result can carry a body at all. `usize::MAX` with no skills.
295    min_skill_body: usize,
296    /// The skill reads found in the log, ordered by `call`.
297    reads: Vec<Read>,
298    /// The skill names a committed section carries whole, with that
299    /// section's range: the dedupe that turns re-reads into pointers.
300    /// Rebuilt from the sections on every observe, so resume adoption
301    /// keeps it true.
302    pinned: HashMap<String, Range>,
303    /// The wire bytes the pending response's input tokens spread over:
304    /// staged from the request before it goes out, consumed by
305    /// [`Transcript::record_usage`].
306    pending_shares: Shares,
307    stats: TranscriptStats,
308    /// A previous session's sections, handed over by the session save
309    /// and waiting for the resumed log. `observe` adopts them on the
310    /// resume's replace when they tile a prefix of the rebuilt log,
311    /// and drops them for anything else.
312    saved: Option<Vec<Section>>,
313}
314
315/// A skill whose body pins: a tool result carrying one stays whole in
316/// the block instead of compacting to a digest. Set on the mirror at
317/// wiring time, from the same discovery the prompt renders.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct SkillText {
320    /// The skill's folder name.
321    pub name: String,
322    /// The whole `SKILL.md`, frontmatter included.
323    pub body: String,
324}
325
326/// One skill read found in the log: the call-and-result span and the
327/// skill the result carried.
328#[derive(Debug, Clone, Copy)]
329struct Read {
330    /// The log index of the assistant message holding the call.
331    call: usize,
332    /// The log index of the tool message carrying the body.
333    result: usize,
334    /// The skill's index in the mirror's skill list.
335    skill: usize,
336}
337
338/// The bytes of a detect needle: a body's head, distinct per skill,
339/// compared before the whole body is.
340const NEEDLE_BYTES: usize = 64;
341
342/// The body's head on a char boundary: the cheap prefilter a result
343/// must contain before the whole body is compared.
344fn 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/// One request's wire bytes by category — the proportions the
353/// reported input tokens are spread over.
354#[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
365/// Adds one message's bytes by category into `content` — the
366/// per-message form of the split the counters and the usage shares
367/// both read. The same taxonomy the request shares spread the billed
368/// input over, minus the pieces that ride no message.
369fn 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                // Result-embedded images are wire bytes like any other.
383                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    /// A mirror with no history yet; the first request fills it.
408    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    /// Folds a request's messages into the log: an extension of what is
429    /// already here extends it; anything else — a fresh session, a
430    /// resume — replaces it, and the compaction state rebuilds from
431    /// nothing (the first request re-derives it; nothing derived is
432    /// ever persisted).
433    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        // The saved sections are adopted only when they tile a prefix
461        // of the log this mirror now holds: the resume then rides the
462        // compaction the previous session already paid for, dense
463        // from the first request. Anything else — a truncated log, an
464        // edited session — falls back to the rebuild. A fresh
465        // mirror's first observe lands here too: its log was empty,
466        // so the compare above took the extension branch.
467        if let Some(saved) = self.saved.take() {
468            let mut end = 0usize;
469            // The sections ride the session file: the arithmetic is
470            // checked, and a wrap — like a gap or a bad length — just
471            // fails the tiling and falls back to the rebuild.
472            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        // The pin map reads the committed state — which sections carry
489        // bodies whole decides verbatim versus pointer at the next
490        // flush — so it rebuilds whenever the sections can have
491        // changed, which includes resume adoption swapping all of
492        // them.
493        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    /// How many messages the log holds.
506    pub fn len(&self) -> usize {
507        self.log.len()
508    }
509
510    /// Whether the log is empty.
511    pub fn is_empty(&self) -> bool {
512        self.log.is_empty()
513    }
514
515    /// Message `id`, as `zoom(id, 1)` reads it.
516    pub fn message(&self, id: usize) -> Option<&Message> {
517        self.log.get(id)
518    }
519
520    /// The messages of `range`, as `zoom` reads them; `None` past the
521    /// log's end. The range arrives from the model's tool call, so
522    /// the arithmetic is checked: a huge `n` is a malformed call
523    /// answered with nothing, not a panic.
524    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    /// The compaction's benchmark snapshot.
530    pub fn stats(&self) -> TranscriptStats {
531        self.stats
532    }
533
534    /// Folds a saved session's benchmark counters in: a resumed
535    /// session's stats keep reading as one run. See
536    /// [`TranscriptStats::carry`] for what sums and what does not.
537    pub fn carry(&mut self, saved: &TranscriptStats) {
538        self.stats.carry(saved);
539    }
540
541    /// The committed sections, oldest first: what a session save
542    /// carries so the next session skips the re-fill.
543    pub fn saved_sections(&self) -> Vec<Section> {
544        self.sections.clone()
545    }
546
547    /// Hands a previous session's sections over. They are not adopted
548    /// on the spot: the resumed log is unknown until the first
549    /// request, and [`Transcript::observe`] validates them against it.
550    pub fn adopt_sections(&mut self, sections: Vec<Section>) {
551        self.saved = Some(sections);
552    }
553
554    /// Zeroes the benchmark counters; the log and the compaction state
555    /// stay — the next request rebuilds both anyway. A conversation
556    /// cleared mid-agent starts its accounting over.
557    pub fn reset_stats(&mut self) {
558        self.stats = TranscriptStats::default();
559    }
560
561    /// Sets the skills section's byte share of the system prompt —
562    /// measured by the agent that rendered it, since the section's
563    /// shape is the template's to decide. Zero (the default) folds the
564    /// whole system prompt into `system_prompt_tokens`.
565    pub fn set_skills_bytes(&mut self, bytes: usize) {
566        self.skills_bytes = bytes;
567    }
568
569    /// Sets the skills whose bodies pin whole into the block. A tool
570    /// result carrying one becomes its own verbatim section at flush
571    /// time instead of a digest; a re-read becomes a pointer to the
572    /// section already carrying the body. Safe to call at any point —
573    /// setting re-detects over the whole log — and normally called
574    /// once at wiring, from the same discovery the prompt rendered.
575    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    /// The skill a tool result carries, if any: the length gate first
588    /// (a shorter result cannot contain a body), then the head needle,
589    /// then the whole body. Matching by content rather than by the
590    /// command means any read that returned the whole file qualifies —
591    /// `cat`, `sed`, a subagent's result — and no shell line is
592    /// parsed.
593    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    /// Detects skill reads in the log from `from` on, appending to
605    /// [`Transcript::reads`] in order. A read needs the pair intact:
606    /// the result, and directly before it an assistant message
607    /// carrying exactly that one call. A multi-call turn digests
608    /// normally rather than splitting a section around one of its
609    /// results.
610    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                // The call directly before: exactly one ToolCall in
623                // the turn (a second call's result would orphan behind
624                // the pin), and it must be this result's id. Leading
625                // text blocks are fine — a real turn narrates.
626                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    /// The read whose call sits at `index`, if any.
654    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    /// Stages the byte shares of the request about to go out: what the
663    /// next [`Transcript::record_usage`] spreads the reported input
664    /// tokens over. The decorator calls it after the view is applied,
665    /// so the shares read what is actually sent.
666    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        // The tool definitions ride the request's tools array; they
679        // are input every step, and they share the calls' row.
680        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                        // Result-embedded images are wire bytes like any
698                        // other; the message budgets count their data.
699                        shares.tool_results += result
700                            .images
701                            .iter()
702                            .map(|image| image.data.len())
703                            .sum::<usize>();
704                    }
705                    // Shares spreads tokens over the categories it names;
706                    // image bytes are the message budgets' to count.
707                    Content::Image(_) => {}
708                }
709            }
710        }
711        self.pending_shares = shares;
712    }
713
714    /// Records one provider response's usage: the ground truth the
715    /// cache-hit rate reports from. The decorator calls this once per
716    /// forwarded request; the reported input tokens spread over the
717    /// staged shares by byte proportion.
718    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    /// Records one summarizer call the decorator issued outside the
739    /// mirror's lock; a [`WorkerOutcome::Failed`] counts it against the
740    /// worker.
741    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    /// Commits an upgraded section text from a [`PendingSummary`] a
749    /// worker task filled outside the request. `false` when the section no
750    /// longer matches — a concurrent plan rebuilt it — and the text is
751    /// dropped: every intermediate state stays a valid view.
752    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        // Pinned bodies ride after the fill: the worker condenses the
762        // range's events, the skill text stays whole beneath it.
763        for name in &section.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    /// The flush planner: decides whether the budget is breached, and
775    /// if so moves the cut and commits the new sections — extractive
776    /// stand-ins first — returning the summaries a [`Summarizer`]
777    /// should upgrade. Between flushes this touches nothing: the block
778    /// is append-only by construction.
779    ///
780    /// Called under the mirror's lock with `observe` already applied;
781    /// never blocks and never calls a model.
782    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        // The minimum band: walk back from the tail over whole
793        // messages while they fit. The walk stops at the cut — the
794        // block never un-commits.
795        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            // Breach. Overshoot to half the allowance: the band after
808            // the flush leaves a whole hysteresis of growth before the
809            // next one, so flushes — the only prefix breaks — stay
810            // far apart.
811            let half = allowance / 2;
812            let mut c_new = c_min;
813            let mut band = self.bytes(c_new, tail_start);
814            // The smallest position whose remaining band fits half:
815            // walk forward while the band still exceeds it. The break
816            // on `next > half` was the inverse and left the band at
817            // the full allowance — flushes every request.
818            while c_new < tail_start && band > half {
819                band -= self.sizes[c_new];
820                c_new += 1;
821            }
822            // The band must not open on a tool result: its call would
823            // stay behind in the last section. Trailing results come
824            // along into the block.
825            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    /// The view of the log: `None` passes it through unchanged — below
839    /// the budget, or nothing sectioned yet. Past it, the block
840    /// followed by the whole band from the cut.
841    ///
842    /// The one-stop form for callers without a summarizer:
843    /// [`Transcript::plan_flush`] plus this, with the extractive
844    /// stand-ins final. The decorator plans and renders under the
845    /// lock and hands the fill a background task.
846    pub fn view(&mut self, messages: &[Message]) -> Option<Vec<Message>> {
847        self.observe(messages);
848        self.plan_flush();
849        self.render()
850    }
851
852    /// Builds the request view from the committed state. Called once
853    /// per request, after any summarizer commits; updates the
854    /// benchmark counters.
855    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    /// The last user message's index, clamped to the cut: the tail is
896    /// never under the block. No user message at all — an
897    /// assistant-only history — and everything from the cut on is the
898    /// tail.
899    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    /// The wire bytes of `log[from..to]`, from the size cache.
909    fn bytes(&self, from: usize, to: usize) -> usize {
910        self.sizes[from..to].iter().sum()
911    }
912
913    /// The block's bytes: every section's header plus text.
914    fn block_bytes(&self) -> usize {
915        self.sections.iter().map(Section::view_bytes).sum()
916    }
917
918    /// Realigns the size cache with the log; `observe` maintains it,
919    /// so this is a defensive no-op in normal flow. When it does patch,
920    /// the running totals rebuild from scratch.
921    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    /// Sections `[start, end)` into the block: chunks at message
939    /// boundaries within the step and message-count bounds, each
940    /// ending so the next never opens on a tool result whose call the
941    /// chunk holds. Returns one pending summary per chunk.
942    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            // A skill read pins its call-and-result span as one
947            // section: the first read commits the body whole, a
948            // re-read a pointer to the section already carrying it.
949            // No pending goes out for it, so no model fill can rewrite
950            // the body into a count line.
951            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                // A read call opens its own section: close this chunk
991                // before it, whatever the budget says.
992                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            // The digest of whatever sits directly before this chunk:
1009            // the stand-in committed for the previous chunk, or the
1010            // block's last section for the first chunk of a flush.
1011            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    /// Consolidates the block past its cap: the two oldest sections
1030    /// merge into one — the union range re-derived from the log, not
1031    /// from the summaries — until the block is at half the cap. Each
1032    /// merge returns a pending summary for the model fill. Rare and
1033    /// stepped: this is the one append-only exception, and it is a
1034    /// full cache break when it fires.
1035    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                // Sections whose arithmetic does not hold against the
1049                // log (file-derived, adopted past validation): skip
1050                // the merge rather than panic on it.
1051                None => return pending,
1052            };
1053            let mut text = compact::section_digest(&messages);
1054            // The union keeps whatever bodies the merged sections
1055            // carried: the pin outlives the merge, re-appended after
1056            // the digest so the block never loses a skill's text. The
1057            // model fill re-appends them again at commit.
1058            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            // The union is the block's first section: nothing sits
1082            // before it to resolve references against.
1083            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
1095/// The transcript as a [`ModelProvider`]: wraps `inner`, rewrites every
1096/// request's messages through the stepped view, and refreshes the
1097/// shared mirror first. A configured [`Summarizer`] upgrades the new
1098/// sections' stand-ins in a background task after the request went
1099/// out — never inside it, never under the mirror's lock, so a slow or
1100/// failing worker delays nothing.
1101pub struct TranscriptView {
1102    inner: Arc<dyn ModelProvider>,
1103    transcript: Arc<Mutex<Transcript>>,
1104    summarizer: Option<Arc<dyn Summarizer>>,
1105}
1106
1107impl TranscriptView {
1108    /// The wrapper over `inner`, mirroring into `transcript` — the same
1109    /// mirror a [`crate::zoom::Zoom`] resolver reads. Build both from
1110    /// one mirror, or let [`crate::attach`] do it. No summarizer: new
1111    /// sections keep their extractive stand-ins.
1112    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    /// The wrapper with a summarizer for the model fill of new
1121    /// sections. The fill runs in tokio tasks: calling [`Self::complete`]
1122    /// with a summarizer requires a running runtime.
1123    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        // The inner provider is a trait object and stays opaque.
1139        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        // Plan under the lock: move the cut, commit stand-ins. Never
1151        // a model call, never an await here.
1152        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        // The fill runs after this request, outside it and outside the
1161        // lock: the request goes out now with the extractive
1162        // stand-ins, and the upgrades commit under the mirror's lock
1163        // as they land. A slow or failing worker delays nothing and
1164        // leaves the stand-in standing.
1165        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    /// Dispatches one background fill over `pending`: chunks of
1201    /// [`WORKER_CONCURRENCY`] summarize concurrently, and each chunk's
1202    /// answers commit under the mirror's lock as they resolve. The
1203    /// range check in [`Transcript::commit_summary`] rejects anything a
1204    /// concurrent plan rebuilt in the meantime, so a commit that races
1205    /// a flush or a consolidation drops its text and the stand-in
1206    /// stands.
1207    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                    // A task that panicked or was cancelled counts as a
1231                    // failure, like a provider error: the stand-in
1232                    // stands either way.
1233                    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                            // `commit_summary` reads the address, not
1241                            // the messages; the range check decides.
1242                            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    /// The test runtime is current-thread: spawned tasks run while the
1268    /// test yields. Wait until `done` holds, then stop yielding.
1269    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    /// A big tool result, in kilobytes.
1280    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    /// A history over the default budget: many 8 KiB tool rounds, then
1309    /// a final user message.
1310    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); // ~33 KiB
1330        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); // ~245 KiB
1341        let view = transcript.view(&messages).unwrap();
1342
1343        // One synthetic user message first, tagged, carrying the
1344        // block's sections.
1345        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        // Sections are addressed `id+n|`, tiling from the log's start.
1352        assert!(text.contains("0+"), "{text}");
1353
1354        // The tail — the last user message — is present whole.
1355        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); // ~40 KiB, over budget
1370        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        // Growth well under the hysteresis: no new flush, the
1378        // committed sections byte-identical, only the band grows.
1379        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)); // ~164 KiB
1399        }
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        // The band the flush leaves behind sits at or below half the
1405        // allowance — the overshoot — not at the full allowance.
1406        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        // The hysteresis is the half-allowance the overshoot bought:
1414        // growth inside it must not flush again.
1415        let mut grown = messages.clone();
1416        for i in 20..26 {
1417            grown.extend(call_round(&format!("c{i}"), 8)); // ~49 KiB < allowance/2
1418        }
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        // user, round c0 (16 KiB), round c1 (16 KiB), user.
1428        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        // Every tool result carried whole has its call carried too:
1436        // the pairs survive the cut together.
1437        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        // And no section's range opens on a result: the same pairing
1457        // holds inside the block, so a section never stands in for a
1458        // round it splits.
1459        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        // The sections tile the cut without gaps.
1484        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        // A stale pending — the range moved on — is dropped.
1506        // The context is irrelevant to the commit's range check.
1507        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        // The first session sections and upgrades the block.
1521        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        // The resumed session adopts them: the compaction the last
1532        // session paid for rides as-is, dense from the first request.
1533        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        // No re-sectioning, no pending fill: nothing to re-pay.
1539        assert!(second.plan_flush().is_empty());
1540
1541        // The band keeps growing from the adopted cut.
1542        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    // --- the skill pin ---
1550
1551    /// A skill body, frontmatter included, at a known size.
1552    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    /// The shell's wrapped form of a `cat` result: exit code, stdout,
1561    /// stderr (shell.rs renders exactly this around a command's
1562    /// output).
1563    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    /// One turn reading a skill: the bash cat, then the wrapped body.
1574    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        // The pinned span is its own two-message section, never queued
1612        // for the worker: no fill can rewrite the body into a count
1613        // line.
1614        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        // The rendered block carries the body verbatim, shell wrapper
1625        // and all detection passing.
1626        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        // One turn, two calls: the skill cat plus an unrelated command.
1680        // Pinning would orphan the second result behind its call.
1681        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        // The consolidation's live union pending: its range still
1765        // matches the section, and the section carries the pin.
1766        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        // The resume adopts those sections, then a re-read of the same
1802        // skill flushes to a pointer, not a second body.
1803        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        // The first id 5, not 0: the tiling rejects the adoption.
1830        assert_eq!(transcript.cut, 0);
1831        assert!(transcript.sections.is_empty());
1832        // The next request rebuilds from nothing.
1833        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        // The first section has no predecessor; every later one points
1843        // at the stand-in committed for the section before it.
1844        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        // A later flush's first chunk inherits the block's last
1851        // section as its context.
1852        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        // Read the predecessor before planning: plan_flush itself
1863        // pushes the new sections.
1864        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        // The surviving sections still tile the prefix.
1882        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        // A different history wholesale: the derived state resets.
1897        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        // System: 1500 base bytes + 500 skills bytes. Messages: one
1907        // user text, one assistant text with a call, one result.
1908        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        // The model's words: the history's assistant text plus the
1951        // generated output.
1952        assert_eq!(b.model_output_tokens, spread(400) + 100);
1953        // The shares consumed: a usage record without a staged request
1954        // spreads nothing.
1955        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            // The saved snapshot describes the log the resume replaced.
2000            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        // A mirror that has not looked at a log yet shows the saved
2016        // run whole — the counters and, until its first request, the
2017        // snapshot too.
2018        let mut fresh = TranscriptStats::default();
2019        fresh.carry(&saved);
2020        assert_eq!(fresh, saved);
2021
2022        // The mirror then counts requests of its own: each read folds
2023        // the same saved run in once, so the totals are the run's,
2024        // never doubled, and the snapshot is the live mirror's.
2025        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        // A later read: the mirror grew, the baseline still folds in
2049        // once.
2050        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    /// The log-size snapshot: what the conversation holds, each
2061    /// message counted once, refreshed on every request — pass-through
2062    /// ones included. The usage split's once-counted counterpart.
2063    #[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); // ~33 KiB, below the budget
2067        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        // An extension rides in; the next request recounts the log.
2073        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    /// The content split: the log's bytes by category, each message
2081    /// counted once. For this fixture the categories tile the log —
2082    /// no images, no system or tool-role text — so they sum to the
2083    /// log's whole size.
2084    #[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    /// Records what it was handed.
2137    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        // The mirror kept every message, none dropped.
2178        let mirror = transcript.lock().unwrap();
2179        assert_eq!(mirror.len(), messages.len());
2180        assert_eq!(mirror.message(0), Some(&messages[0]));
2181        // The usage landed: the hit rate is reportable.
2182        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        /// Answers every call with the same dense block.
2188        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        // The request went out with the extractive stand-ins: the fill
2221        // had not landed when it rendered.
2222        {
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        // The background fill upgrades every section.
2231        wait_for(|| {
2232            transcript
2233                .lock()
2234                .unwrap()
2235                .sections
2236                .iter()
2237                .all(|s| s.origin == SectionOrigin::Model)
2238        })
2239        .await;
2240
2241        // The guard drops at the brace: the runtime is current-thread,
2242        // so the next request cannot take the lock while it is held.
2243        {
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        // The next request carries the dense text.
2257        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        // The main call went out with the extractive stand-ins, and
2301        // never waited on the worker: the failure is counted by the
2302        // background fill, after the response.
2303        view.complete(req, &mut sink).await.unwrap();
2304        {
2305            // The guard drops before the wait: no lock is held across
2306            // an await.
2307            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    /// A summarizer worker that answers only once its gate opens; it
2325    /// spins on yields, so an inline await of it would hang the
2326    /// current-thread runtime.
2327    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        // The request goes out, and the turn finishes, while the worker
2372        // is still gated: the fill never sat in the request path.
2373        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        // Then the fill lands, and the section upgrades.
2381        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}