Skip to main content

forge_server/transport/
conversation.rs

1//! One seat's conversation, held by the transport and kept up to date.
2//!
3//! **The read is what costs.** Measured on a 68.9 MB transcript (13,451
4//! messages), one request spends 202 ms reading and parsing the file, 18 ms
5//! folding it into turns and 20 ms encoding those turns - so a fix caching
6//! only the fold removes 8% of the cost it was written for. The transport
7//! holds the read's own output and shares it: a page slices a window out of
8//! it, and a subscribe encodes the newest of it.
9//!
10//! **A seat's history has two sources, and the seed never overwrites the
11//! stream's.** The first is the session task, which produces the conversation:
12//! the fold sees `Connected` for every seat that starts or resumes while the
13//! transport runs, so the frames and the copy they build have one producer and
14//! one order.
15//!
16//! The second is the seat's own transcript, read when the transport holds
17//! nothing and a page opens on it - a sleeping seat has no live copy, and
18//! waiting for the spawn that would carry the same history back is the wait
19//! this exists to remove (`transport::wire::seed_from_transcript`). It only
20//! ever inserts: a copy the stream inserted while the file was being read is
21//! kept, and a copy of another occupant is waited for rather than replaced. A
22//! transcript is a row subset - it carries no `Result` frames - so a seed over
23//! a live copy would drop frames the stream had already delivered.
24//!
25//! **That is what makes a buffer unnecessary rather than merely deleted.** A
26//! frame emitted before a seat is held finds no conversation and is dropped -
27//! the precondition is that the fold is attached before the first
28//! `Connected`, which the transport's own test asserts. A frame emitted
29//! after is applied to what the seed left.
30//!
31//! **What is released is the front, and no more of it than the cap.** A page
32//! cursor names a message in the conversation's own numbering rather than in
33//! the held list's, so a cursor a drop has passed is answered as what it is -
34//! the floor, with nothing above it - rather than as a window the client did
35//! not ask for. The seat's copy is therefore kept for the seat's life, and
36//! what that costs is bounded by the cap below rather than by the transcript.
37
38use std::collections::HashMap;
39use std::sync::{Arc, Condvar, Mutex, PoisonError};
40
41use forge_primitives::{Message, SessionSlot};
42// The window this copy is kept in. The fan-out's ring is sized from the same
43// caps, because a ring exists to fill a copy that is behind; the ceilings' why,
44// and what the drop costs, are stated there.
45use forge_workspace::conversation_turns::{TurnScan, opens_for_debt};
46use forge_workspace::conversation_window::Window;
47// The delivered liveness rule, folded off the frames this copy sees - the same
48// one the client's `inFlightOf` keeps and the core folds into a seat's own
49// turn state.
50use forge_workspace::{TurnLiveness, liveness_of};
51
52use crate::SessionUpdate;
53use crate::transcript::Rendered;
54
55/// One seat's conversation: the messages, where its turns sit and how many
56/// times it has compacted.
57///
58/// All three come out of one read and one fold, so a page cut on the fold's
59/// boundaries and a record built from `messages` cannot disagree about where a
60/// turn begins, and the count cannot describe a different conversation than
61/// the one held.
62pub struct Conversation {
63    window: Window,
64    /// The fold's boundaries and task endings, which a page is cut on.
65    ///
66    /// **Nothing but those is held**, because the transport draws nothing: a
67    /// client folds the frames a page hands it, so the boundaries are what a
68    /// page here is cut on and the endings are what its turns carry.
69    rendered: Rendered,
70    compaction_count: u32,
71    /// The messages the cap has dropped off the front, ever.
72    ///
73    /// **A page cursor is measured against this rather than against the held
74    /// list.** A cursor names a message in the conversation's own numbering,
75    /// which a drop does not move, so a cursor that outlived one still names
76    /// the message it meant - and one naming a message the drop has taken is
77    /// answered with the floor rather than with whatever now sits at its old
78    /// place in the list.
79    dropped: usize,
80    /// The newest frame number this copy reflects, which a reseed resets.
81    ///
82    /// **An answer's number is taken from it**, because a reseed lands before
83    /// the fold's reset mark and a pre-seal number would have the client skip
84    /// the new run's first frames (#2443).
85    watermark: u64,
86    /// The messages moved since the fold last ran.
87    ///
88    /// **Memoized rather than made incremental**, because making it
89    /// incremental means deciding where a turn begins from the appended
90    /// message alone - a second turn-boundary rule, which is the drift
91    /// `turn_ranges`' own doc exists to prevent. One rule in one place, and
92    /// it runs when the messages changed rather than once per request.
93    ///
94    /// **And it never runs under this lock.** The socket folds the core's
95    /// stream in ONE task for every seat, so a fold holding a seat's lock
96    /// stalls update delivery for every client on every seat.
97    /// [`Held::fold`] is where it runs instead.
98    dirty: bool,
99    /// The messages are out being folded, so what is here is half a state.
100    ///
101    /// **A reader must wait rather than look.** A fold takes the messages out
102    /// in O(1), renders them off the lock, and puts them back with the spans,
103    /// so between those two the seat reads as no messages against the
104    /// PREVIOUS fold's boundaries, and a page sliced on those walks off the
105    /// end of an empty list. Two requests on one seat is the ordinary case
106    /// for that, not an exotic one.
107    folding: bool,
108    /// The uuids of the delivery rows this transport forged.
109    ///
110    /// **A forged row is a frame no transcript has**, and nothing about its
111    /// shape says so - it is a user row like the prompt it draws. Only the
112    /// site that forged it knows, which is why the set lives here rather than
113    /// in the row rule. [`Conversation::has_a_row`] is what reads it: a walk
114    /// below the window names this seat's oldest row TO THE FILE, and naming
115    /// one the file never wrote answers the read nothing - which reads as the
116    /// end of the history.
117    forged: std::collections::HashSet<String>,
118    /// The occupant this copy was last seeded for.
119    ///
120    /// **What tells a copy of the previous run from the current one.** A seal
121    /// is minted by the fanout and applied by the fold, and a read landing
122    /// between the two would otherwise pair the new epoch's count with the old
123    /// run's rows: a copy whose occupant is not the seat's is no copy at all.
124    occupant: Option<forge_primitives::SessionId>,
125    /// Whether the seat's turn is in flight right now, folded off the frames
126    /// this copy has seen (`liveness_of` - the delivered rule: the model's
127    /// own frames open a turn, its `Result` or error closes it).
128    ///
129    /// **The fold's caller carries this**, because the frame that would say it
130    /// directly (`session_state_changed`) is never sent: the fold reads it as
131    /// "the open turn the frames describe is live", which is what keeps a
132    /// mid-call delivery from being cut off from a result that has not
133    /// streamed yet (#2379).
134    live: bool,
135    /// Where the stream opens turns, folded over every frame this copy has
136    /// seen rather than over the window: a cut moves the window's front, not
137    /// the rule that says which frame opens a turn.
138    scan: TurnScan,
139    /// The thinking growth a cut took out of the turn open at the window's
140    /// end, which the page serving that turn carries back into its first
141    /// counter.
142    ///
143    /// **It never crosses a turn.** A `Result` closes the turn it belonged to,
144    /// and a frame that opens a turn starts the next one with nothing carried.
145    dropped_thinking: i64,
146}
147
148impl Conversation {
149    /// A conversation from the history a connect or a resume carried.
150    ///
151    /// **It does not fold.** The spans come from the next
152    /// [`Held::fold`], which runs on a blocking task; folding here would run
153    /// an 18 ms render on whatever task built this, and that task is the
154    /// socket's single stream folder.
155    ///
156    /// `carried` is the floor `history` arrives with: the messages the read it
157    /// came from left behind a window that was taken before the history
158    /// crossed (see [`Conversation::seed`]), so a window handed over on its
159    /// own still tells the whole conversation's numbering.
160    pub fn new(messages: Vec<Message>, compaction_count: u32, carried: usize) -> Self {
161        // **The history is converted in place before the cut**, so what is
162        // weighed and kept is the shape this copy holds rather than the shape
163        // the wire carried; and the cut is still what bounds what the copy
164        // costs, so a resume's transient is the history it handed over plus a
165        // window rather than two transcripts.
166        //
167        // The liveness is read before the cut, off the same frames: the
168        // seed's own ladder is what a copy materialised mid-turn reads from
169        // before any frame arrives.
170        let live = live_of_seed(&messages);
171        let blocks = as_blocks(messages);
172        // **The scan is primed over the whole history**, not the window: its
173        // state is what the frames before a turn left, and a cut takes frames
174        // the next opening would otherwise be read under.
175        let mut scan = TurnScan::default();
176        for message in &blocks {
177            scan.opens(message);
178        }
179        let (window, shed) = Window::tail_of(&blocks);
180        let dropped = carried.saturating_add(shed.frames);
181        Self {
182            window,
183            rendered: Rendered { turns: Vec::new(), endings: HashMap::new() },
184            compaction_count,
185            dropped,
186            watermark: 0,
187            dirty: true,
188            folding: false,
189            forged: std::collections::HashSet::new(),
190            occupant: None,
191            live,
192            scan,
193            // A settled turn still draws its report, so a seed keeps the debt
194            // its own cut produced whether or not the turn is still open.
195            dropped_thinking: shed.thinking,
196        }
197    }
198
199    /// The conversation a seat with nothing behind it answers as.
200    pub fn empty() -> Self {
201        Self::new(Vec::new(), 0, 0)
202    }
203
204    /// The occupant this copy was last seeded for.
205    pub fn occupant(&self) -> Option<&forge_primitives::SessionId> {
206        self.occupant.as_ref()
207    }
208
209    /// Say which occupant this copy is for.
210    pub fn set_occupant(&mut self, occupant: Option<forge_primitives::SessionId>) {
211        self.occupant = occupant;
212    }
213
214    /// The newest frame number this copy reflects.
215    pub fn watermark(&self) -> u64 {
216        self.watermark
217    }
218
219    /// Say how far this copy is current, which a reseed resets and an applied
220    /// frame advances.
221    pub fn set_watermark(&mut self, seq: u64) {
222        self.watermark = seq;
223    }
224
225    /// Replace the whole conversation, which is what a connect, a resume or a
226    /// `/new` says: the history it carries is the truth about this seat.
227    ///
228    /// **It marks the fold rather than running it**, for the reason
229    /// [`Conversation::new`] gives: this is reached from the stream fold, and
230    /// a render there stalls every seat rather than this one.
231    ///
232    /// `carried` is the floor the history arrives with - the messages the read
233    /// it came from left behind a window taken before it crossed, which
234    /// `Connected` and `SessionReplaced` carry beside the history they hand
235    /// over (a resume's read is windowed at the emit). Without it a seat
236    /// reseeded from a window reports the window's own front as the
237    /// conversation's, and a client's walk below it stops there instead of
238    /// crossing into the transcript.
239    ///
240    /// **The numbering restarts**, because a connect or a resume carries a
241    /// transcript READ - a row subset, not this copy's frame sequence - so an
242    /// index carried over it would be off by however far the two diverge. A
243    /// cursor from before resolves by its own number again and is answered
244    /// from the floor when that number falls below the front the new window
245    /// starts at.
246    pub fn seed(&mut self, messages: Vec<Message>, compaction_count: u32, carried: usize) {
247        // **Converted before the cut.** The copy a seat already holds went
248        // through `as_blocks` at its own seed, so a `<task-notification>` row
249        // and the block it converts to are one frame. The cut then runs over
250        // the converted history, so the window it keeps is the one weighed.
251        let live = live_of_seed(&messages);
252        let blocks = as_blocks(messages);
253        let mut scan = TurnScan::default();
254        for message in &blocks {
255            scan.opens(message);
256        }
257        let (kept, shed) = Window::tail_of(&blocks);
258        self.live = live;
259        self.window = kept;
260        self.scan = scan;
261        self.dropped_thinking = shed.thinking;
262        // **The boundaries go with the messages they describe.** The fold's
263        // turns name a prefix of the messages, and a reader that saw the new
264        // list under the old boundaries would slice off the end of it.
265        // Clearing them makes the pair consistent at every instant; the next
266        // fold rebuilds them.
267        self.rendered.turns.clear();
268        self.rendered.endings.clear();
269        self.dropped = carried.saturating_add(shed.frames);
270        // A frame whose id is no longer held cannot be met again, so the set
271        // that answers "did forge forge this" holds only what the seat keeps.
272        self.forged.retain(|id| self.window.messages().iter().any(|m| frame_id(m) == Some(id)));
273        self.compaction_count = compaction_count;
274        self.watermark = 0;
275        self.dirty = true;
276    }
277
278    /// One frame the session emitted.
279    pub fn append(&mut self, message: Message) {
280        self.push(message);
281    }
282
283    /// One row this transport forged for display, which no transcript holds.
284    ///
285    /// **Forged rows are frames the file counts no row for**, and nothing
286    /// about their shape says so - a delivery row is a user row like the
287    /// prompt it draws. So the seat keeps their ids, and [`Self::has_a_row`]
288    /// answers with them: a walk below the floor names the seat's oldest row
289    /// to the file, and one the file never wrote answers nothing.
290    pub fn append_forged(&mut self, message: Message) {
291        if let Some(id) = frame_id(&message) {
292            self.forged.insert(id.to_owned());
293        }
294        self.push(message);
295    }
296
297    /// Whether a transcript row carries `message`: the kinds the row rule
298    /// reads, and nothing this transport forged for display.
299    ///
300    /// **The seat's own answer, because half of it is the seat's.** The rule
301    /// knows the kinds a file writes; only the seat knows which of its own
302    /// rows it minted, and a forged row is a user row like the prompt it
303    /// draws. A walk below the window needs both halves: naming the file a row
304    /// it does not carry answers the read nothing, which a client draws as the
305    /// end of the history.
306    pub fn has_a_row(&self, message: &Message) -> bool {
307        forge_workspace::has_a_transcript_row(message)
308            && !frame_id(message).is_some_and(|id| self.forged.contains(id))
309    }
310
311    fn push(&mut self, message: Message) {
312        // **The scan sees every frame once, in stream order**, so its answer
313        // stays exact across a fold (which empties the window, never the
314        // scan). A queued prompt opens a turn in the fold the window serves,
315        // so it opens one for the debt too.
316        let opened = opens_for_debt(&mut self.scan, &message);
317        // **A boundary that arrives moves the count**, the same way the
318        // session task's own count does. Without this the record reports the
319        // value its last SEED carried, which is stale for every compaction
320        // since - and the header reads the count while the boundary draws its
321        // own row.
322        if matches!(message, Message::CompactBoundary { .. }) {
323            self.compaction_count = self.compaction_count.saturating_add(1);
324        }
325        // Every frame's own claim about the turn folds in here, the same walk
326        // a seat's `DomainSession` makes: the model's frames open a turn, its
327        // `Result` or error closes it, anything else says nothing.
328        if let Some(said) = liveness_of(&message) {
329            self.live = said == TurnLiveness::Working;
330        }
331        // **Before the push, so the drop pays for the room it makes.** After
332        // it, this one frame is what tips the store into a doubling the drop
333        // is about to throw away. Both ceilings are the window's own.
334        let shed = self.window.push(message, opened);
335        // **Only a new turn clears the debt.** A settled turn is still the
336        // newest turn a page serves, and its report draws the figure - so the
337        // debt survives a `Result` and an `Error` alike, the two turn ends
338        // `liveness_of` names, and only a frame that opens the next turn
339        // starts with nothing carried.
340        if opened {
341            self.dropped_thinking = 0;
342        } else {
343            self.dropped_thinking = self.dropped_thinking.saturating_add(shed.thinking);
344        }
345        if shed.frames > 0 {
346            self.dropped = self.dropped.saturating_add(shed.frames);
347            // A forged row the seat no longer holds cannot be met again, so
348            // the set that answers "did forge forge this" keeps only what it
349            // keeps.
350            let held: std::collections::HashSet<&str> =
351                self.window.messages().iter().filter_map(frame_id).collect();
352            self.forged.retain(|id| held.contains(id.as_str()));
353            // The same reason a seed clears them: the boundaries name message
354            // indices, and the drop has just moved every one of them.
355            self.rendered.turns.clear();
356            self.rendered.endings.clear();
357        }
358        self.dirty = true;
359    }
360
361    /// The messages if the fold is behind them, taken in O(1).
362    fn take_dirty(&mut self) -> Option<Window> {
363        if !self.dirty {
364            return None;
365        }
366        self.dirty = false;
367        self.folding = true;
368        Some(std::mem::take(&mut self.window))
369    }
370
371    /// Put a fold back, with whatever arrived while it ran appended.
372    ///
373    /// `complete` is false when the render did not reach its end, which is the
374    /// guard's path: the messages are back but the boundaries installed with
375    /// them describe nothing.
376    fn merge_fold(&mut self, mut folded: Window, rendered: Rendered, complete: bool) {
377        let folded_len = folded.len();
378        folded.append(&mut self.window);
379        self.window = folded;
380        self.rendered = rendered;
381        self.folding = false;
382        // The tail arrived while the fold ran and is not in those spans, and a
383        // fold that did not finish has no spans at all - either way the next
384        // reader folds again rather than cutting a turn on a list that has
385        // moved.
386        if !complete || self.window.len() != folded_len {
387            self.dirty = true;
388        }
389    }
390
391    pub fn messages(&self) -> &[Message] {
392        self.window.messages()
393    }
394
395    pub fn rendered(&self) -> &Rendered {
396        &self.rendered
397    }
398
399    pub fn compaction_count(&self) -> u32 {
400        self.compaction_count
401    }
402
403    /// How many messages the cap has dropped off the front, which is what
404    /// turns a held index into the one a page's cursor carries.
405    pub fn dropped(&self) -> usize {
406        self.dropped
407    }
408
409    /// The thinking growth a cut took out of the turn open at the window's
410    /// end, which a page of that turn carries back into its first counter.
411    pub fn dropped_thinking(&self) -> i64 {
412        self.dropped_thinking
413    }
414}
415
416/// The id a frame carries, for the frames that carry one.
417pub(crate) fn frame_id(message: &Message) -> Option<&str> {
418    match message {
419        Message::User { uuid, .. } | Message::Assistant { uuid, .. } => uuid.as_deref(),
420        Message::StopHookSummary { uuid, .. } | Message::CompactBoundary { uuid, .. } => Some(uuid),
421        _ => None,
422    }
423}
424
425/// A history as the CLI wrote it, with its task notices in the shape a view
426/// reads an ending from.
427///
428/// **The read converts these and the seed must too.** `notices_as_blocks` is
429/// applied where a conversation is READ off disk, and a held copy seeded from
430/// a connect takes the history as the WIRE carries it - so a
431/// resumed seat would serve a `<task-notification>` row as the reader's own
432/// words, with the ending unread and, for a notice in the same turn as its
433/// call, nothing ending that call at all. Idempotent, so a history that came
434/// through the read is unchanged.
435fn as_blocks(messages: Vec<Message>) -> Vec<Message> {
436    crate::transcript::notices_as_blocks(messages)
437}
438
439/// The delivered ladder folded over a seed's own frames: the last frame that
440/// says anything about the turn wins, the way every frame appended later
441/// folds in (see [`Conversation::live`]).
442fn live_of_seed(messages: &[Message]) -> bool {
443    messages.iter().filter_map(liveness_of).next_back() == Some(TurnLiveness::Working)
444}
445
446/// One seat's conversation, and the fold that may be running over it.
447///
448/// **Two locks, because a fold must not be held against the stream and a
449/// reader must not see half of one.** A fold takes the messages out in O(1)
450/// under the mutex, renders them outside it - so the socket's single stream
451/// folder never waits on a render - and puts them back under the mutex with
452/// the spans. Between those two the seat holds no messages and the PREVIOUS
453/// fold's boundaries, and a page sliced on those walks off the end of an
454/// empty list.
455///
456/// So the mutex is paired with a condvar: a reader waits out a fold in
457/// progress rather than looking at it. The wait is on the reader's own
458/// blocking task, and it is bounded by one render.
459pub struct Held {
460    conversation: Mutex<Conversation>,
461    folded: Condvar,
462}
463
464impl Held {
465    pub fn new(conversation: Conversation) -> Self {
466        Self { conversation: Mutex::new(conversation), folded: Condvar::new() }
467    }
468
469    /// Whether this copy belongs to the seat's current occupant.
470    ///
471    /// **A copy with no occupant matches anything, and so does a header
472    /// naming nobody**: a transcript that named no session seeds a copy with
473    /// no occupant, and a sleeping seat's header names no run - neither is
474    /// discarded for lacking a fact nothing ever recorded.
475    pub fn holds_occupant(&self, occupant: Option<&forge_primitives::SessionId>) -> bool {
476        match (self.lock().occupant.as_ref(), occupant) {
477            (Some(held), Some(current)) => held == current,
478            _ => true,
479        }
480    }
481
482    /// The conversation, waiting out a fold that is running over it.
483    pub fn lock(&self) -> std::sync::MutexGuard<'_, Conversation> {
484        let mut conversation = self.conversation.lock().unwrap_or_else(PoisonError::into_inner);
485        while conversation.folding {
486            conversation = self.folded.wait(conversation).unwrap_or_else(PoisonError::into_inner);
487        }
488        conversation
489    }
490
491    /// Put a fold's result back and wake every reader waiting on it.
492    fn install(&self, window: Window, rendered: Rendered, complete: bool) {
493        let mut conversation = self.conversation.lock().unwrap_or_else(PoisonError::into_inner);
494        conversation.merge_fold(window, rendered, complete);
495        self.folded.notify_all();
496    }
497
498    /// Fold if needed, then read, retrying once if a reseed landed in between.
499    ///
500    /// **Two acquisitions, and the gap between them is why this retries.** A
501    /// `Connected` or a `/resume` can replace the conversation
502    /// between the fold and the read, and what a reader then holds is the new
503    /// messages against boundaries the fold cleared - one turn covering the
504    /// whole conversation rather than a window of them. The retry closes the
505    /// common case; a second reseed inside the second window still gets
506    /// through, which is stated in the PR rather than claimed away.
507    pub fn read<T>(&self, read: impl FnOnce(&Conversation) -> T) -> T {
508        for _ in 0..2 {
509            self.fold();
510            let conversation = self.lock();
511            if conversation.dirty {
512                drop(conversation);
513                continue;
514            }
515            return read(&conversation);
516        }
517        read(&self.lock())
518    }
519
520    /// Fold if the messages moved, outside the lock and off the reactor.
521    ///
522    /// The caller is a blocking task that has nothing else to do with the
523    /// conversation until this returns.
524    pub fn fold(&self) {
525        let Some(taken) = Folding::begin(self) else {
526            return;
527        };
528        let rendered = crate::transcript::render(taken.messages(), taken.live());
529        taken.finish(rendered);
530    }
531}
532
533/// The messages a fold has taken out, and the promise that they go back.
534///
535/// **This is a guard rather than a straight-line take-and-restore because
536/// the render between them is a place a panic can land**, and a panic there
537/// with no guard is out of all proportion to its trigger: the messages are
538/// dropped, `folding` stays set, and `notify_all` is never reached - so every
539/// reader of the seat parks in [`Held::lock`] on a notification that never
540/// comes, and the seat is wedged for the process's life. Through
541/// [`Conversations::apply`] the same lock is the socket's single stream
542/// folder, so it is every client on every seat that stops being delivered to
543/// rather than one page.
544///
545/// With the guard, a panic costs one bad fold: the messages go back, the
546/// boundaries are left for the next fold to rebuild, and the readers wake.
547struct Folding<'a> {
548    held: &'a Held,
549    window: Option<Window>,
550    live: bool,
551}
552
553impl<'a> Folding<'a> {
554    /// Take the window if a fold is due, or `None` when there is nothing to
555    /// do - a fold already running, or nothing moved.
556    fn begin(held: &'a Held) -> Option<Self> {
557        let mut conversation = held.conversation.lock().unwrap_or_else(PoisonError::into_inner);
558        // A fold already running owns the messages; this caller has nothing
559        // to do, and waiting here would be waiting on a peer rather than on
560        // work it can start.
561        if conversation.folding {
562            return None;
563        }
564        let window = conversation.take_dirty()?;
565        // Read with the window, under the same lock: the state the fold is
566        // handed is the seat's own at the moment its messages went out.
567        let live = conversation.live;
568        Some(Self { held, window: Some(window), live })
569    }
570
571    /// The messages this fold took.
572    fn messages(&self) -> &[Message] {
573        self.window.as_ref().map_or(&[], Window::messages)
574    }
575
576    /// Whether the seat's turn was in flight when the window went out.
577    fn live(&self) -> bool {
578        self.live
579    }
580
581    /// Put the fold back and wake the readers.
582    fn finish(mut self, rendered: Rendered) {
583        let window = self.window.take().unwrap_or_default();
584        self.held.install(window, rendered, true);
585    }
586}
587
588impl Drop for Folding<'_> {
589    /// Unwinding out of the render: the messages go back, and the seat stays
590    /// due a fold so the next read rebuilds the boundaries.
591    fn drop(&mut self) {
592        let Some(window) = self.window.take() else {
593            return;
594        };
595        self.held.install(
596            window,
597            // No boundaries: the fold did not reach its end, so the next one
598            // rebuilds them rather than this leaving half of one behind.
599            Rendered { turns: Vec::new(), endings: HashMap::new() },
600            false,
601        );
602    }
603}
604
605/// The conversations the transport is holding, one per seat it has heard of.
606///
607/// **Every live seat, not only the watched ones, and no seat is let go.** A
608/// conversation has no read behind it any more, so releasing one is not a
609/// bound - nothing could rebuild it, and the next ask for that seat would
610/// have no answer. What it holds is therefore proportional to what is
611/// RUNNING plus the sleeping seats someone has opened: a sleeping seat's
612/// copy is seeded from its transcript and kept, which is what makes the
613/// read land before any spawn. What one seat costs is the window the
614/// ceilings keep rather than its transcript, and the window is the bound.
615#[derive(Default)]
616pub struct Conversations {
617    held: Mutex<HashMap<SessionSlot, Arc<Held>>>,
618    /// Fires when a seat's copy is first held or reseeded, so a read waiting
619    /// for it to become current knows it moved.
620    seeded: tokio::sync::Notify,
621    /// The newest number the fold loop has consumed for each seat, in a watch
622    /// so an encode can wait on it.
623    ///
624    /// **What an answer's copy is measured against.** A subscribe's answer
625    /// carries the number its seat's stream will continue from, and every
626    /// frame numbered below that must be inside the copy it carries - because
627    /// the client adopts `seq - 1` and treats a later delivery of anything at
628    /// or below it as already drawn. The copy is filled by the fold loop,
629    /// which consumes the fanout in a task of its own, so "the frame was
630    /// emitted before the counter was read" does not yet mean "the copy has
631    /// it": this is where that is known.
632    consumed: Mutex<HashMap<SessionSlot, tokio::sync::watch::Sender<u64>>>,
633    /// The lock a seat's fill runs under.
634    ///
635    /// **What makes "applied once" a fact rather than a hope.** The fold loop
636    /// and an encode's catch-up can both apply the same frames, and the
637    /// watermark between them is read and written by both; the lock spans each
638    /// side's check, applies and mark, so a frame crosses exactly once
639    /// whichever path got there first.
640    fills: Mutex<HashMap<SessionSlot, Arc<std::sync::Mutex<()>>>>,
641    /// A test's hold on the fold loop, so the window above can be driven
642    /// rather than raced: while it is set, the loop does not consume.
643    #[cfg(any(test, feature = "testing"))]
644    fold_held: std::sync::atomic::AtomicBool,
645    /// A test's hold on the next transcript seed, so a stream copy that lands
646    /// inside the seed's window is driven rather than raced.
647    #[cfg(any(test, feature = "testing"))]
648    seed_hold: Mutex<Option<Arc<SeedHold>>>,
649}
650
651/// Where a held transcript seed parks.
652///
653/// The seed announces it has arrived and waits to be let go, so a test can
654/// put the seat's own `Connected` into that window on purpose.
655#[cfg(any(test, feature = "testing"))]
656pub struct SeedHold {
657    /// Signalled once the seed has parked. A notify with nobody waiting keeps
658    /// its permit, so the test may start waiting after the seed arrives.
659    pub entered: tokio::sync::Notify,
660    /// Taken by the seed once the test lets it go.
661    pub release: tokio::sync::Semaphore,
662}
663
664#[cfg(any(test, feature = "testing"))]
665impl Default for SeedHold {
666    fn default() -> Self {
667        Self { entered: tokio::sync::Notify::new(), release: tokio::sync::Semaphore::new(0) }
668    }
669}
670
671impl Conversations {
672    pub fn new() -> Self {
673        Self::default()
674    }
675
676    /// The newest number the fold loop has consumed for `slot`.
677    pub fn consumed_for(&self, slot: &SessionSlot) -> u64 {
678        self.consumed
679            .lock()
680            .unwrap_or_else(PoisonError::into_inner)
681            .get(slot)
682            .map_or(0, |watch| *watch.borrow())
683    }
684
685    /// Say the fold loop has consumed `seq` for `slot`.
686    ///
687    /// **`send_replace`, not `send`.** The watch is created with its receiver
688    /// dropped, so `consumed_for` can read the watermark with nobody waiting -
689    /// and `send` is a no-op when a channel has no receivers, which would leave
690    /// the watermark at 0 forever and make every read of it wrong.
691    pub fn consumed(&self, slot: &SessionSlot, seq: u64) {
692        let mut watches = self.consumed.lock().unwrap_or_else(PoisonError::into_inner);
693        let watch = watches.entry(slot.clone()).or_insert_with(|| {
694            let (tx, _rx) = tokio::sync::watch::channel(0);
695            tx
696        });
697        watch.send_replace(seq);
698    }
699
700    /// Wait, up to `within`, for the fold to have consumed `want` for `slot`,
701    /// answering whether it did.
702    ///
703    /// **Bounded, because the caller has a fallback and a stuck fold must not
704    /// become a stall**: the caller fills the gap from the seat's own window
705    /// and refuses only if that cannot reach either.
706    pub async fn await_consumed(
707        &self,
708        slot: &SessionSlot,
709        want: u64,
710        within: std::time::Duration,
711    ) -> bool {
712        let mut watch = {
713            let mut watches = self.consumed.lock().unwrap_or_else(PoisonError::into_inner);
714            watches
715                .entry(slot.clone())
716                .or_insert_with(|| {
717                    let (tx, _rx) = tokio::sync::watch::channel(0);
718                    tx
719                })
720                .subscribe()
721        };
722        if *watch.borrow() >= want {
723            return true;
724        }
725        tokio::time::timeout(within, watch.wait_for(|seen| *seen >= want))
726            .await
727            .is_ok_and(|seen| seen.is_ok())
728    }
729
730    /// The lock one seat's fill runs under: taken by the fold loop and by an
731    /// encode's catch-up, so their watermark checks and applies cannot
732    /// interleave.
733    pub fn fill_lock(&self, slot: &SessionSlot) -> Arc<std::sync::Mutex<()>> {
734        let mut fills = self.fills.lock().unwrap_or_else(PoisonError::into_inner);
735        Arc::clone(fills.entry(slot.clone()).or_default())
736    }
737
738    /// Hold the fold loop, or let it go. Test-only: it is the seam that makes
739    /// the copy's lag deterministic instead of a race.
740    #[cfg(any(test, feature = "testing"))]
741    pub fn hold_the_fold(&self, held: bool) {
742        self.fold_held.store(held, std::sync::atomic::Ordering::Release);
743    }
744
745    /// Whether a test is holding the fold loop.
746    #[cfg(any(test, feature = "testing"))]
747    pub fn fold_is_held(&self) -> bool {
748        self.fold_held.load(std::sync::atomic::Ordering::Acquire)
749    }
750
751    /// Hold the next transcript seed before its read, so a test can land the
752    /// seat's own copy inside that window. Test-only.
753    #[cfg(any(test, feature = "testing"))]
754    pub fn hold_the_seed(&self, hold: Arc<SeedHold>) {
755        *self.seed_hold.lock().unwrap_or_else(PoisonError::into_inner) = Some(hold);
756    }
757
758    /// Park at a test's seed hold, when one is set: say it has arrived, then
759    /// wait to be let go.
760    #[cfg(any(test, feature = "testing"))]
761    pub async fn pass_the_seed_hold(&self) {
762        let hold = self.seed_hold.lock().unwrap_or_else(PoisonError::into_inner).clone();
763        if let Some(hold) = hold {
764            hold.entered.notify_one();
765            let _release = hold.release.acquire().await;
766        }
767    }
768
769    /// The seat's conversation if one is held.
770    pub fn get(&self, slot: &SessionSlot) -> Option<Arc<Held>> {
771        self.held.lock().unwrap_or_else(PoisonError::into_inner).get(slot).map(Arc::clone)
772    }
773
774    /// Whether the seat's turn is in flight right now, from its own frames.
775    ///
776    /// `false` for a seat this transport has not heard of: nothing holds a
777    /// turn there, and a reader folding its transcript is reading a settled
778    /// conversation.
779    pub fn live_of(&self, slot: &SessionSlot) -> bool {
780        self.get(slot).is_some_and(|held| held.lock().live)
781    }
782
783    /// Hold `conversation` for `slot`, unless the stream got there first.
784    pub fn insert(&self, slot: &SessionSlot, conversation: Conversation) -> Arc<Held> {
785        let held = {
786            let mut held = self.held.lock().unwrap_or_else(PoisonError::into_inner);
787            Arc::clone(
788                held.entry(slot.clone()).or_insert_with(|| Arc::new(Held::new(conversation))),
789            )
790        };
791        self.seeded.notify_waiters();
792        held
793    }
794
795    /// Replace a seat's copy outright.
796    ///
797    /// **What a copy built from a ring stretch needs**: `insert` keeps what is
798    /// there, and the stretch replaces it so the frames after it apply to the
799    /// copy the read is served from.
800    pub fn replace(&self, slot: &SessionSlot, conversation: Conversation) -> Arc<Held> {
801        let held = {
802            let mut held = self.held.lock().unwrap_or_else(PoisonError::into_inner);
803            let fresh = Arc::new(Held::new(conversation));
804            held.insert(slot.clone(), Arc::clone(&fresh));
805            fresh
806        };
807        self.seeded.notify_waiters();
808        held
809    }
810
811    /// A future that resolves when some seat's copy is first held or reseeded.
812    ///
813    /// **Enable it before looking the seat up**, or a seat seeded between the
814    /// lookup and the wait leaves the caller waiting for something that has
815    /// already happened - and bound the wait, because a seat whose session is
816    /// gone never answers at all.
817    ///
818    /// A reseed counts: a seal the header has already moved on from lands on
819    /// the copy the seat holds, in place, so a reader waiting for that copy
820    /// to become current is woken by it rather than by the seat's first hold.
821    pub fn seeded_notice(&self) -> tokio::sync::futures::Notified<'_> {
822        self.seeded.notified()
823    }
824
825    /// Fold one update into the seat it names.
826    ///
827    /// `seq` is the number the frame was minted under; the copy records it as
828    /// the newest it reflects.
829    pub fn apply(&self, update: &SessionUpdate, seq: Option<u64>) {
830        let Some(slot) = update.slot() else {
831            return;
832        };
833        match update {
834            // A connect and a resume say the same thing about this seat -
835            // here is its conversation - so they RESEED it, materialising it
836            // if this is the first word about the seat. Appending instead
837            // would put the history in front of the frames the seat already
838            // carried.
839            SessionUpdate::Connected { history, compaction_count, dropped, session_id, .. }
840            | SessionUpdate::SessionReplaced {
841                history,
842                compaction_count,
843                dropped,
844                session_id,
845                ..
846            } => {
847                self.reseed(slot, history, *compaction_count, *dropped, Some(session_id));
848            }
849            SessionUpdate::ChatAppended { msg, .. } => {
850                // **A compaction boundary is a frame like any other.** It
851                // once dropped the seat so the next ask would reseed it, and
852                // that is the door this change closed everywhere else:
853                // replacing a copy the task did not produce is how a frame it
854                // never sees gets lost. The boundary is appended, the task
855                // counts it, and a reader keeps the history a compaction did
856                // not take away.
857                if let Some(held) = self.get(slot) {
858                    let mut conversation = held.lock();
859                    conversation.append(msg.clone());
860                    if let Some(seq) = seq {
861                        conversation.set_watermark(seq);
862                    }
863                }
864            }
865            // A delivery is a row a CONNECTION forges per client at send
866            // time, so it never arrives here as a `ChatAppended` - and a held
867            // copy that ignored it would lose the row while its ANSWER
868            // stayed, which is a reply to nothing. Forged once here, where
869            // the update arrives, rather than in each connection that sends
870            // it.
871            //
872            // **Every slotted update also moves the copy's watermark**, not
873            // only the ones that forge a row: a frame that draws nothing still
874            // covers its number, and an answer that left it out would
875            // under-claim. That is why this arm takes the seat's lock for
876            // every update on a live seat, and why `Held::lock` waits out a
877            // running fold.
878            _ => {
879                let Some(held) = self.get(slot) else {
880                    return;
881                };
882                // The parse and its failure log run before the seat's lock is
883                // taken; the lock below covers only the append and the mark.
884                let forged = crate::delivery::delivery_turn(update, slot).map(|(msg, _)| msg);
885                let mut conversation = held.lock();
886                if let Some(msg) = forged {
887                    // Forged for display, and in no transcript: the seat has
888                    // to know, so a walk below the floor never names one to
889                    // the file as the row it stopped at.
890                    conversation.append_forged(msg);
891                }
892                if let Some(seq) = seq {
893                    conversation.set_watermark(seq);
894                }
895            }
896        }
897    }
898
899    /// Put `history` on `slot`, materialising the seat when this is the first
900    /// word about it.
901    ///
902    /// **Held means reseed and NOT insert**: `insert` keeps what is there, so
903    /// a connect on a seat already carrying a conversation would leave that
904    /// conversation in place and the new history discarded.
905    ///
906    /// The watermark goes to zero: a seal opens a new epoch, whose frame
907    /// count starts at 1.
908    fn reseed(
909        &self,
910        slot: &SessionSlot,
911        history: &[Message],
912        compaction_count: u32,
913        carried: usize,
914        occupant: Option<&forge_primitives::SessionId>,
915    ) {
916        if let Some(held) = self.get(slot) {
917            {
918                let mut conversation = held.lock();
919                conversation.seed(history.to_vec(), compaction_count, carried);
920                conversation.set_watermark(0);
921                if let Some(occupant) = occupant {
922                    conversation.set_occupant(Some(occupant.clone()));
923                }
924            }
925            // A reader waiting for a copy of another occupant is waiting for
926            // exactly this, and it landed in place.
927            self.seeded.notify_waiters();
928        } else {
929            let mut conversation = Conversation::new(history.to_vec(), compaction_count, carried);
930            conversation.set_watermark(0);
931            if let Some(occupant) = occupant {
932                conversation.set_occupant(Some(occupant.clone()));
933            }
934            self.insert(slot, conversation);
935        }
936    }
937
938    /// How many seats are held.
939    pub fn len(&self) -> usize {
940        self.held.lock().unwrap_or_else(PoisonError::into_inner).len()
941    }
942
943    pub fn is_empty(&self) -> bool {
944        self.len() == 0
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use forge_primitives::{ContentBlock, SessionId};
951    use forge_workspace::conversation_window::{CONVERSATION_CAP, CONVERSATION_SLACK};
952
953    use super::*;
954
955    fn a_seat() -> SessionSlot {
956        SessionSlot::lead("TestOrg", "proj")
957    }
958
959    /// **A copy with no occupant answers for any occupant.** The arm is
960    /// load-bearing, not defensive: a transcript that named no session seeds
961    /// a copy with no occupant, and reading it as "not the seat's" would drop
962    /// that copy's history.
963    #[test]
964    fn a_copy_with_no_occupant_matches_any_occupant() {
965        let bare = Held::new(Conversation::empty());
966        assert!(
967            bare.holds_occupant(Some(&SessionId::from("6c2d3e4f-5061-4b72-8839-ad1e2f3a4b56"))),
968            "an occupant-less copy must match a live header, or a sleeping seat answers empty",
969        );
970
971        let mut seeded = Conversation::empty();
972        let old = SessionId::from("1f2e3d4c-5b6a-4978-8801-1234567890ab");
973        let new = SessionId::from("2a3b4c5d-6e7f-4809-9a1b-234567890abc");
974        seeded.set_occupant(Some(old.clone()));
975        let seeded = Held::new(seeded);
976        assert!(seeded.holds_occupant(Some(&old)), "a copy of the current occupant is the seat's");
977        assert!(!seeded.holds_occupant(Some(&new)), "a copy of another occupant is no copy");
978    }
979
980    fn a_model() -> forge_primitives::runtime::CurrentModel {
981        forge_primitives::runtime::CurrentModel {
982            requested_id: None,
983            resolved_id: "claude-opus-5".to_owned(),
984            display_name_short: "Opus".to_owned(),
985            display_name_long: "Opus 5".to_owned(),
986            catalog_id: None,
987            supports_effort: false,
988            supported_effort_levels: Vec::new(),
989            supports_auto_mode: None,
990            supports_adaptive_thinking: None,
991            is_authoritative: false,
992        }
993    }
994
995    /// An assistant frame calling `tool`, optionally one a sub-agent made.
996    fn a_frame(text: &str) -> Message {
997        serde_json::from_value(serde_json::json!({
998            "type": "user",
999            "message": {"role": "user", "content": text},
1000            "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1001        }))
1002        .expect("a user frame")
1003    }
1004
1005    fn said(message: &Message) -> String {
1006        let Message::User { message, .. } = message else {
1007            return String::new();
1008        };
1009        message
1010            .content
1011            .iter()
1012            .find_map(|block| match block {
1013                ContentBlock::Text { text, .. } => Some(text.clone()),
1014                _ => None,
1015            })
1016            .unwrap_or_default()
1017    }
1018
1019    /// `turns` turns of three frames each: the row that opens one, a call the
1020    /// turn made, and the result answering it.
1021    ///
1022    /// **Three rather than one, so a drop lands mid-turn.** A cap that cut
1023    /// its window at a turn boundary whatever it did would be a cap this
1024    /// fixture could not tell from one that cut wherever it liked.
1025    fn a_long_history(turns: usize) -> Vec<Message> {
1026        let mut messages = Vec::with_capacity(turns * 3);
1027        for at in 0..turns {
1028            messages.push(a_frame(&format!("turn {at}")));
1029            messages.push(
1030                serde_json::from_value(serde_json::json!({
1031                    "type": "assistant",
1032                    "uuid": format!("a{at}"),
1033                    "message": {
1034                        "id": format!("m{at}"),
1035                        "role": "assistant",
1036                        "model": "claude-opus-5",
1037                        "content": [{
1038                            "type": "tool_use",
1039                            "id": format!("tu{at}"),
1040                            "name": "Bash",
1041                            "input": {"command": "ls"},
1042                        }],
1043                    },
1044                    "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1045                }))
1046                .expect("an assistant frame"),
1047            );
1048            messages.push(
1049                serde_json::from_value(serde_json::json!({
1050                    "type": "user",
1051                    "uuid": format!("u{at}"),
1052                    "message": {
1053                        "role": "user",
1054                        "content": [{
1055                            "type": "tool_result",
1056                            "tool_use_id": format!("tu{at}"),
1057                            "content": "ok",
1058                        }],
1059                    },
1060                    "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1061                }))
1062                .expect("a user frame"),
1063            );
1064        }
1065        messages
1066    }
1067
1068    /// The words each turn of a page opened on.
1069    fn turn_texts(page: &crate::transport::wire::Page) -> Vec<String> {
1070        page.turns
1071            .iter()
1072            .map(|turn| {
1073                turn.messages
1074                    .iter()
1075                    .find_map(|frame| {
1076                        frame["message"]["content"][0]["text"].as_str().map(str::to_owned)
1077                    })
1078                    .unwrap_or_default()
1079            })
1080            .collect()
1081    }
1082
1083    /// The newest page of a held conversation, as a client reads one.
1084    fn newest_page(held: &Held, turns: u32) -> crate::transport::wire::Page {
1085        held.read(|held| {
1086            crate::transport::wire::page(
1087                held.messages(),
1088                held.rendered(),
1089                held.dropped(),
1090                held.dropped_thinking(),
1091                None,
1092                turns,
1093            )
1094        })
1095    }
1096
1097    /// The newest page with the cut's thinking debt folded into it, which is
1098    /// the shape the transport serves.
1099    fn page_with_debt(held: &Held, turns: u32) -> crate::transport::wire::Page {
1100        held.read(|held| {
1101            crate::transport::wire::page_within(
1102                held.messages(),
1103                held.rendered(),
1104                held.dropped(),
1105                held.dropped_thinking(),
1106                None,
1107                turns,
1108                usize::MAX,
1109                "test",
1110                None,
1111            )
1112        })
1113    }
1114
1115    /// One thinking-token counter, with a cumulative absolute that is not its
1116    /// delta - so summing the wrong field fails.
1117    fn a_counter(at: usize, delta: i64) -> Message {
1118        let absolute = at.saturating_add(1) * usize::try_from(delta).expect("a positive delta");
1119        serde_json::from_value(serde_json::json!({
1120            "type": "system",
1121            "subtype": "thinking_tokens",
1122            "estimated_tokens": absolute,
1123            "estimated_tokens_delta": delta,
1124            "uuid": format!("t-{at}"),
1125            "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1126        }))
1127        .expect("a counter frame")
1128    }
1129
1130    /// The frame a compaction leaves, which the scan reads as a boundary.
1131    fn a_compact_boundary() -> Message {
1132        Message::CompactBoundary {
1133            trigger: "auto".to_owned(),
1134            pre_tokens: 100_000,
1135            post_tokens: 21_000,
1136            uuid: "b1c2d3e4-0000-4000-8000-000000000001".to_owned(),
1137            session_id: "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45".to_owned(),
1138            metadata_extras: serde_json::Map::new(),
1139            extras: serde_json::Map::new(),
1140        }
1141    }
1142
1143    /// The frame an error leaves, which ends a turn as a `Result` does.
1144    fn a_error_frame() -> Message {
1145        Message::Error { error: "the turn failed".to_owned(), extras: serde_json::Map::new() }
1146    }
1147
1148    /// One turn with `counters` thinking-token frames after its opening row.
1149    fn a_thought_heavy_turn(counters: usize) -> Vec<Message> {
1150        let mut messages = vec![a_frame("a thought-heavy turn")];
1151        for at in 0..counters {
1152            messages.push(a_counter(at, 1));
1153        }
1154        messages
1155    }
1156
1157    /// The sum of the counter deltas a page serves.
1158    fn served_thinking(page: &crate::transport::wire::Page) -> i64 {
1159        page.turns
1160            .iter()
1161            .flat_map(|turn| turn.messages.iter())
1162            .filter_map(|frame| {
1163                if frame["type"] == "system" && frame["subtype"] == "thinking_tokens" {
1164                    frame["estimated_tokens_delta"].as_i64()
1165                } else {
1166                    None
1167                }
1168            })
1169            .sum()
1170    }
1171
1172    /// **A cut inside a running turn leaves a debt the page pays.** The window
1173    /// caps at 4,000 frames plus 1,000 slack, and a thinking storm is exactly
1174    /// the turn that outgrows it: the cut takes the turn's leading counters,
1175    /// and the page must carry their growth back on the first counter it has.
1176    #[test]
1177    fn a_thought_heavy_turn_serves_its_whole_sum_after_the_window_sheds_its_head() {
1178        let counters = 5_100;
1179        let held = Held::new(Conversation::new(a_thought_heavy_turn(counters), 0, 0));
1180
1181        let dropped = held.read(Conversation::dropped_thinking);
1182        assert!(dropped > 0, "the cut took the turn's leading counters");
1183
1184        let page = page_with_debt(&held, u32::MAX);
1185        assert_eq!(
1186            served_thinking(&page),
1187            i64::try_from(counters).expect("the test's count fits"),
1188            "the page carries the whole turn, not only the counters that survived",
1189        );
1190    }
1191
1192    /// **A settled turn still draws its report, so its debt survives the
1193    /// result.** Only a frame that opens the next turn clears it, and the rule
1194    /// names no turn end at all - `Result` and `Error` are treated alike.
1195    #[test]
1196    fn the_dropped_thinking_survives_a_result_and_clears_on_the_next_turn() {
1197        let mut conversation = Conversation::empty();
1198        conversation.append(a_frame("a thought-heavy turn"));
1199        for at in 0..5_100 {
1200            conversation.append(a_counter(at, 1));
1201        }
1202        let carried = conversation.dropped_thinking();
1203        assert!(carried > 0, "the drop carries a debt");
1204
1205        conversation.append(a_result_frame());
1206        assert_eq!(
1207            conversation.dropped_thinking(),
1208            carried,
1209            "a settled turn is still the newest turn a page serves",
1210        );
1211
1212        conversation.append(a_error_frame());
1213        assert_eq!(conversation.dropped_thinking(), carried, "an error ends a turn the same way");
1214
1215        conversation.append(a_frame("the next turn"));
1216        assert_eq!(
1217            conversation.dropped_thinking(),
1218            0,
1219            "a frame that opens a turn starts with nothing carried",
1220        );
1221    }
1222
1223    /// **A turn that fits is served exactly as it was.** No cut means no debt,
1224    /// and the page's frames are the input's own bytes.
1225    #[test]
1226    fn a_turn_that_fits_is_served_unchanged() {
1227        let mut conversation = Conversation::empty();
1228        conversation.append(a_frame("a small turn"));
1229        for at in 0..10 {
1230            conversation.append(a_counter(at, 2));
1231        }
1232        assert_eq!(conversation.dropped_thinking(), 0, "no cut, no debt");
1233
1234        let expected: Vec<serde_json::Value> = conversation
1235            .messages()
1236            .iter()
1237            .map(|message| serde_json::to_value(message).expect("a frame encodes"))
1238            .collect();
1239        let held = Held::new(conversation);
1240        let page = page_with_debt(&held, u32::MAX);
1241        assert_eq!(served_thinking(&page), 20, "the page is the turn's own sum");
1242        assert_eq!(
1243            page.turns[0].messages, expected,
1244            "a fitting turn's frames are its input, byte for byte",
1245        );
1246    }
1247
1248    /// **A second drop in one turn adds to the debt; it does not replace it.**
1249    /// The live path drops a thousand frames at a time once the window passes
1250    /// its slack, and the counters it sheds accumulate until a turn opens.
1251    #[test]
1252    fn a_second_live_drop_accumulates_the_debt() {
1253        let counters = 6_100;
1254        let mut conversation = Conversation::empty();
1255        conversation.append(a_frame("a thought-heavy turn"));
1256        for at in 0..counters {
1257            conversation.append(a_counter(at, 1));
1258        }
1259        assert_eq!(conversation.dropped_thinking(), 2_000, "two drops of a thousand counters");
1260
1261        let held = Held::new(conversation);
1262        let page = page_with_debt(&held, u32::MAX);
1263        assert_eq!(
1264            served_thinking(&page),
1265            i64::try_from(counters).expect("the test's count fits"),
1266            "the page carries the whole turn",
1267        );
1268    }
1269
1270    /// **A settled seed keeps the debt its own cut produced.** The newest turn
1271    /// is settled, and its report still draws the figure.
1272    #[test]
1273    fn a_settled_seed_keeps_its_cut_debt() {
1274        let counters = 5_100;
1275        let mut history = a_thought_heavy_turn(counters);
1276        history.push(a_result_frame());
1277        let held = Held::new(Conversation::new(history, 0, 0));
1278
1279        assert!(held.read(Conversation::dropped_thinking) > 0, "the seed's cut carries a debt");
1280        let page = page_with_debt(&held, u32::MAX);
1281        assert_eq!(
1282            served_thinking(&page),
1283            i64::try_from(counters).expect("the test's count fits"),
1284            "the settled page carries the whole turn",
1285        );
1286    }
1287
1288    /// **A seed primes the scan, and an append after it continues from that
1289    /// state.** A compaction boundary in the seeded history is what makes the
1290    /// continuation that follows open no turn; a fresh scan would open one and
1291    /// clear the debt.
1292    #[test]
1293    fn a_seed_primes_the_scan_for_the_frames_after_it() {
1294        let mut history = a_thought_heavy_turn(5_100);
1295        history.push(a_compact_boundary());
1296        let mut conversation = Conversation::new(history, 0, 0);
1297        let carried = conversation.dropped_thinking();
1298        assert!(carried > 0, "precondition: the seed's cut carries a debt");
1299
1300        conversation.append(a_frame(
1301            "This session is being continued from a previous conversation that ran out of context.",
1302        ));
1303        assert_eq!(
1304            conversation.dropped_thinking(),
1305            carried,
1306            "the continuation is not an opening, so the debt stays",
1307        );
1308    }
1309
1310    fn a_connect(history: Vec<Message>, compaction_count: u32) -> SessionUpdate {
1311        SessionUpdate::Connected {
1312            key: a_seat(),
1313            session_id: SessionId::new("5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45"),
1314            cwd: "/tmp".to_owned(),
1315            current_model: a_model(),
1316            available_models: Vec::new(),
1317            mode: None,
1318            history,
1319            dropped: 0,
1320            compaction_count,
1321        }
1322    }
1323
1324    /// **The property the whole seed rests on: a seal is a reseed, not an
1325    /// append.** If it appended instead of replacing, a seat would carry its
1326    /// history in front of the frames it already had.
1327    #[test]
1328    fn a_connect_replaces_what_the_seat_held() {
1329        let history = vec![a_frame("first"), a_frame("second")];
1330
1331        let held = Conversations::new();
1332        held.insert(&a_seat(), Conversation::new(vec![a_frame("stale")], 0, 0));
1333        held.apply(&a_connect(history, 0), None);
1334
1335        let read: Vec<String> = {
1336            let conversation = held.get(&a_seat()).expect("the seat is held");
1337            let conversation = conversation.lock();
1338            conversation.messages().iter().map(said).collect()
1339        };
1340        assert_eq!(
1341            read,
1342            vec!["first".to_owned(), "second".to_owned()],
1343            "a connect replaces what the seat held",
1344        );
1345    }
1346
1347    /// A frame the model produced, which is what opens a turn's liveness.
1348    fn a_working_frame() -> Message {
1349        serde_json::from_value(serde_json::json!({
1350            "type": "assistant",
1351            "uuid": "a-working",
1352            "message": {
1353                "id": "msg_working",
1354                "role": "assistant",
1355                "model": "claude-opus-5",
1356                "content": [{ "type": "text", "text": "working" }],
1357            },
1358            "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1359        }))
1360        .expect("an assistant frame")
1361    }
1362
1363    /// The frame that ends a turn, per the delivered ladder.
1364    fn a_result_frame() -> Message {
1365        serde_json::from_value(serde_json::json!({
1366            "type": "result",
1367            "subtype": "success",
1368            "uuid": "r-1",
1369            "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1370            "is_error": false,
1371            "num_turns": 1,
1372            "duration_ms": 12,
1373            "duration_api_ms": 9,
1374        }))
1375        .expect("a result frame")
1376    }
1377
1378    /// An assistant frame calling `id`, which is both a call and the frame
1379    /// that opens a turn's liveness.
1380    fn a_call_frame(id: &str) -> Message {
1381        serde_json::from_value(serde_json::json!({
1382            "type": "assistant",
1383            "uuid": format!("a-{id}"),
1384            "message": {
1385                "id": "msg_call",
1386                "role": "assistant",
1387                "model": "claude-opus-5",
1388                "content": [{
1389                    "type": "tool_use",
1390                    "id": id,
1391                    "name": "mcp__forge__agents__despawn",
1392                    "input": { "label": "probe" },
1393                }],
1394            },
1395            "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1396        }))
1397        .expect("an assistant frame")
1398    }
1399
1400    /// The chase's board notice, as the CLI presses it into a running turn:
1401    /// a text row under the id forge minted for the dispatch.
1402    fn a_forge_row(uuid: &str) -> Message {
1403        serde_json::from_value(serde_json::json!({
1404            "type": "user",
1405            "uuid": uuid,
1406            "message": {
1407                "role": "user",
1408                "content": [{
1409                    "type": "text",
1410                    "text": "task board: \"probe holds no row\" has no live owner",
1411                }],
1412            },
1413            "session_id": "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45",
1414        }))
1415        .expect("a user frame")
1416    }
1417
1418    /// **The hop that carries the seat's flag into the fold is pinned.**
1419    ///
1420    /// The fold's own tests hand `render` the flag directly, so nothing there
1421    /// would notice a caller passing `false` - and #2379's fix would revert
1422    /// quietly with a full green suite. This folds THROUGH `Held` with a live
1423    /// seat and the mid-call shape, asserting the one boundary the hop exists
1424    /// to protect.
1425    #[test]
1426    fn a_live_seats_mid_call_notice_folds_into_one_turn_through_held() {
1427        let held = Held::new(Conversation::new(
1428            vec![
1429                a_frame("despawn the probe"),
1430                a_call_frame("call_1"),
1431                a_forge_row("forge_nudge_1_deadbeef"),
1432            ],
1433            0,
1434            0,
1435        ));
1436
1437        let turns = held.read(|held| held.rendered().turns.len());
1438        assert_eq!(turns, 1, "the notice stays in the turn the seat's ladder reports live");
1439    }
1440
1441    /// **The seat's turn state travels to the fold, folded off its frames.**
1442    ///
1443    /// The frame that would say a turn is running (`session_state_changed`)
1444    /// is never sent, so the fold cannot read liveness off its own list: the
1445    /// transport derives the delivered ladder - the model's own frames open a
1446    /// turn, its `Result` (or the error it gives up with) closes it - and the
1447    /// fold reads that as "the open turn is live", which is what keeps a
1448    /// mid-call delivery from being cut off from a result that has not
1449    /// streamed yet (#2379). A seed materialised mid-turn starts live for the
1450    /// same reason.
1451    #[test]
1452    fn the_seats_live_turn_travels_to_the_fold() {
1453        let held = Conversations::new();
1454        held.insert(&a_seat(), Conversation::empty());
1455        assert!(!held.live_of(&a_seat()), "nothing is open on an empty seat");
1456
1457        held.apply(
1458            &SessionUpdate::ChatAppended { key: a_seat(), msg: a_working_frame(), source: None },
1459            None,
1460        );
1461        assert!(held.live_of(&a_seat()), "the model's own frame opens a turn");
1462
1463        held.apply(
1464            &SessionUpdate::ChatAppended { key: a_seat(), msg: a_result_frame(), source: None },
1465            None,
1466        );
1467        assert!(!held.live_of(&a_seat()), "and the turn's report closes it");
1468
1469        held.apply(&a_connect(vec![a_frame("mid-turn"), a_working_frame()], 0), None);
1470        assert!(held.live_of(&a_seat()), "a seed materialised mid-turn reads live");
1471    }
1472
1473    /// A frame the session emits after the seed joins the conversation, which
1474    /// is what the held copy is for.
1475    #[test]
1476    fn a_frame_after_a_seed_joins_the_conversation() {
1477        let held = Conversations::new();
1478        held.insert(&a_seat(), Conversation::empty());
1479        held.apply(&a_connect(vec![a_frame("from the replay")], 0), None);
1480
1481        held.apply(
1482            &SessionUpdate::ChatAppended { key: a_seat(), msg: a_frame("after"), source: None },
1483            None,
1484        );
1485
1486        let conversation = held.get(&a_seat()).expect("the seat is held");
1487        let conversation = conversation.lock();
1488        let said: Vec<String> = conversation.messages().iter().map(said).collect();
1489        assert_eq!(said, vec!["from the replay".to_owned(), "after".to_owned()]);
1490    }
1491
1492    /// A compaction the session reports MOVES the seat's count, which is what
1493    /// the header reads.
1494    ///
1495    /// **The boundary arrives as a frame rather than in a seed**, so a record
1496    /// that took only the count its last connect carried would report the
1497    /// number from before every compaction since.
1498    #[test]
1499    fn a_compact_boundary_frame_moves_the_seats_count() {
1500        let held = Conversations::new();
1501        held.insert(&a_seat(), Conversation::empty());
1502        held.apply(&a_connect(vec![a_frame("before the compaction")], 0), None);
1503
1504        held.apply(
1505            &SessionUpdate::ChatAppended {
1506                key: a_seat(),
1507                msg: Message::CompactBoundary {
1508                    trigger: "auto".to_owned(),
1509                    pre_tokens: 100_000,
1510                    post_tokens: 21_000,
1511                    uuid: "b1c2d3e4-0000-4000-8000-000000000001".to_owned(),
1512                    session_id: "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45".to_owned(),
1513                    metadata_extras: serde_json::Map::new(),
1514                    extras: serde_json::Map::new(),
1515                },
1516                source: None,
1517            },
1518            None,
1519        );
1520
1521        let conversation = held.get(&a_seat()).expect("the seat is held");
1522        let conversation = conversation.lock();
1523        assert_eq!(
1524            conversation.compaction_count(),
1525            1,
1526            "the count follows the frames the session emits, not only the seed",
1527        );
1528    }
1529
1530    /// A seed carries the count it brings, on both routes in.
1531    ///
1532    /// **The frame test above pins the half that moves; this is the half that
1533    /// arrives WITH a history**, and forcing it to zero at either constructor
1534    /// would report "no compactions" on a resumed seat with nothing failing.
1535    #[test]
1536    fn a_seed_carries_the_count_it_brings() {
1537        let fresh = Conversations::new();
1538        fresh.apply(&a_connect(vec![a_frame("resumed")], 3), None);
1539        let conversation = fresh.get(&a_seat()).expect("the seat is held");
1540        assert_eq!(conversation.lock().compaction_count(), 3, "a connect's count is kept");
1541
1542        let reseeded = Conversations::new();
1543        reseeded.insert(&a_seat(), Conversation::new(vec![a_frame("stale")], 0, 0));
1544        reseeded.apply(&a_connect(vec![a_frame("replayed")], 2), None);
1545        let conversation = reseeded.get(&a_seat()).expect("the seat is held");
1546        assert_eq!(
1547            conversation.lock().compaction_count(),
1548            2,
1549            "and a reseed replaces it rather than zeroing it",
1550        );
1551    }
1552
1553    /// A connect holds the seat's conversation whether or not anyone is
1554    /// watching it, because nothing can rebuild one that was let go: the
1555    /// conversation has no read behind it, so a seat dropped is a seat whose
1556    /// next ask has no answer.
1557    #[test]
1558    fn a_connect_holds_the_seat_without_anyone_watching() {
1559        let held = Conversations::new();
1560
1561        held.apply(&a_connect(vec![a_frame("running, unwatched")], 0), None);
1562
1563        assert_eq!(held.len(), 1, "the seat is held from its connect");
1564        let conversation = held.get(&a_seat()).expect("the seat is held");
1565        let conversation = conversation.lock();
1566        assert_eq!(conversation.messages().len(), 1, "carrying the history the connect brought");
1567    }
1568
1569    /// The dispatch rule and its flag moved to `forge-workspace` with the push
1570    /// that announces the raise: the rule's own test lives beside it there
1571    /// (`a_dispatch_is_an_assistant_frame_calling_task_or_agent`), and this
1572    /// crate answers the flag through the view surface rather than computing
1573    /// it.
1574    /// A reseed leaves the boundaries consistent with the messages they
1575    /// describe, so a page over a just-reseeded seat cannot slice past the end
1576    /// of the list it is reading.
1577    ///
1578    /// **`messages` and `spans` are one value in two fields**, and a reader
1579    /// sees both or neither: the spans name a prefix of the messages, and a
1580    /// list that shrank under boundaries that did not is how a page walks off
1581    /// the end of an empty one.
1582    #[test]
1583    fn a_reseed_leaves_the_boundaries_describing_the_messages() {
1584        let held = Conversations::new();
1585        held.insert(&a_seat(), Conversation::new(vec![a_frame("one"), a_frame("two")], 0, 0));
1586        let conversation = held.get(&a_seat()).expect("the seat is held");
1587        conversation.fold();
1588        assert_eq!(conversation.lock().rendered().turns.len(), 2, "precondition: two turns folded");
1589
1590        held.apply(&a_connect(Vec::new(), 0), None);
1591
1592        let conversation = held.get(&a_seat()).expect("the seat is held");
1593        let held = conversation.lock();
1594        assert!(held.messages().is_empty(), "the reseed replaced the messages");
1595        assert!(
1596            held.rendered().turns.is_empty(),
1597            "and the boundaries went with them, rather than describing a list that is gone",
1598        );
1599    }
1600
1601    /// A fold that does not reach its end gives the messages back, and is
1602    /// folded again by the next read.
1603    ///
1604    /// **The guard's whole job, and the failure it prevents is a wedge rather
1605    /// than a bad page**: the messages are out of the seat while the render
1606    /// runs, so a panic that unwound past them would drop the conversation,
1607    /// leave `folding` set, and never wake the readers - parking every page
1608    /// request on the seat, and through `apply` the socket's single stream
1609    /// folder, for the process's life.
1610    ///
1611    /// **And the boundaries a guard installs describe nothing**, so the seat
1612    /// has to stay due a fold: a read that took it as folded would cut its
1613    /// page on no turns and hand the whole conversation back as one - the
1614    /// unbounded response this change exists to bound.
1615    #[test]
1616    fn a_fold_that_does_not_finish_gives_the_messages_back() {
1617        let conversation = Conversation::new(vec![a_frame("kept")], 0, 0);
1618
1619        let held = Held::new(conversation);
1620        let taken = Folding::begin(&held).expect("a fold is due");
1621        // Read through the raw mutex rather than `lock`: `lock` WAITS for
1622        // a fold in progress, which is the state this test is standing in.
1623        let out = held.conversation.lock().expect("the mutex").messages().len();
1624        assert_eq!(out, 0, "precondition: the messages are out of the seat");
1625
1626        // Dropped rather than finished, which is what unwinding does.
1627        drop(taken);
1628
1629        {
1630            let held = held.lock();
1631            assert!(!held.folding, "the seat is not left waiting for a fold that will never end");
1632            assert_eq!(held.messages().len(), 1, "and the messages it took are back in it");
1633        }
1634
1635        let turns = held.read(|conversation| conversation.rendered().turns.len());
1636        assert_eq!(turns, 1, "the next read folds them, rather than serving no boundaries");
1637    }
1638
1639    /// A delivery row the transport forges for a seat is part of the
1640    /// conversation it is holding.
1641    ///
1642    /// **It is forged per CONNECTION at send time, so it never arrives here as
1643    /// a `ChatAppended`** - and a held copy that ignored it would lose the row
1644    /// while its answer stayed, which is a reply to nothing. Nothing observed
1645    /// this: with the append removed the whole workspace suite stays green.
1646    #[test]
1647    fn a_forged_delivery_row_joins_the_conversation_it_is_held_for() {
1648        let held = Conversations::new();
1649        held.insert(&a_seat(), Conversation::new(vec![a_frame("asked")], 0, 0));
1650
1651        held.apply(
1652            &SessionUpdate::CronPromptAppended {
1653                key: a_seat(),
1654                text: "the cron fired".to_owned(),
1655                uuid: "cap-cron".to_owned(),
1656                cron_id: "c1".to_owned(),
1657                description: Some("Morning summary".to_owned()),
1658            },
1659            None,
1660        );
1661
1662        let conversation = held.get(&a_seat()).expect("the seat is held");
1663        let conversation = conversation.lock();
1664        let said: Vec<String> = conversation.messages().iter().map(said).collect();
1665        assert_eq!(
1666            said,
1667            vec!["asked".to_owned(), "[Cron]\n\nthe cron fired".to_owned()],
1668            "the delivery the connection forges is in the conversation a page is built from",
1669        );
1670    }
1671
1672    /// A read after a reseed answers a page over the reseeded conversation, and
1673    /// never a slice past the end of it.
1674    ///
1675    /// **What this pins is the OUTCOME and not the retry that usually brings
1676    /// it about.** A reseed landing between `read`'s fold and its read is a
1677    /// race with no deterministic trigger, so the retry cannot be made to fire
1678    /// on demand; what a test can hold is that the state a reseed leaves is one
1679    /// a page can be cut on. A raced probe of 300 iterations saw no incoherent
1680    /// page, and that is evidence rather than proof.
1681    #[test]
1682    fn a_read_after_a_reseed_is_a_page_and_not_a_panic() {
1683        let held = Conversations::new();
1684        held.insert(&a_seat(), Conversation::new(vec![a_frame("one"), a_frame("two")], 0, 0));
1685
1686        // A reseed with no fold after it: the boundaries are cleared, so what
1687        // a reader gets is the whole list as one turn rather than a slice past
1688        // the end of it.
1689        held.apply(&a_connect(vec![a_frame("replaced")], 0), None);
1690
1691        let conversation = held.get(&a_seat()).expect("the seat is held");
1692        let page = conversation.read(|held| {
1693            crate::transport::wire::page(
1694                held.messages(),
1695                held.rendered(),
1696                held.dropped(),
1697                held.dropped_thinking(),
1698                None,
1699                5,
1700            )
1701        });
1702        assert_eq!(page.turns.len(), 1, "the reseeded conversation is one turn");
1703        assert_eq!(
1704            page.turns[0].messages.len(),
1705            1,
1706            "and the page carries what the reseed put there",
1707        );
1708    }
1709
1710    /// A task notice a history carries reaches the conversation in the shape a
1711    /// view reads an ending from, not as the reader's own words.
1712    ///
1713    /// **The read converts these and a seed takes the history raw**, so
1714    /// without this a resumed seat serves the `<task-notification>` XML as
1715    /// text, the ending goes unread, and for a notice in the same turn as its
1716    /// call nothing ends that call at all - the conversion #1364 shipped,
1717    /// undone by the held copy.
1718    #[test]
1719    fn a_task_notice_in_a_seeded_history_is_a_block_and_not_text() {
1720        let notice = "<task-notification><tool-use-id>tu1</tool-use-id>\
1721                      <status>completed</status><summary>done</summary></task-notification>";
1722
1723        // Both routes a history takes in - a connect that materialises the
1724        // seat and a reseed that replaces one - because each converts it
1725        // itself, and either could be the one that forgot.
1726        let fresh = Conversation::new(vec![a_frame(notice)], 0, 0);
1727        let mut reseeded = Conversation::new(vec![a_frame("stale")], 0, 0);
1728        reseeded.seed(vec![a_frame(notice)], 0, 0);
1729
1730        for (conversation, route) in [(&fresh, "a connect"), (&reseeded, "a reseed")] {
1731            let Message::User { message, .. } = &conversation.messages()[0] else {
1732                panic!("a user frame is what the history carried ({route})");
1733            };
1734            assert!(
1735                matches!(
1736                    &message.content[0],
1737                    ContentBlock::QueuedCommand { command_mode: Some(mode), .. }
1738                        if mode == "task-notification"
1739                ),
1740                "the notice is served as the block a view reads an ending from, not as text \
1741                 ({route}): {:?}",
1742                message.content[0],
1743            );
1744        }
1745    }
1746
1747    /// A frame on a seat nothing has seeded is dropped rather than inventing
1748    /// a conversation: a seat whose connect this transport never saw has no
1749    /// copy, and there is nothing to append to.
1750    #[test]
1751    fn a_frame_before_a_seat_is_seeded_holds_nothing() {
1752        let held = Conversations::new();
1753
1754        held.apply(
1755            &SessionUpdate::ChatAppended {
1756                key: a_seat(),
1757                msg: a_frame("before the seed"),
1758                source: None,
1759            },
1760            None,
1761        );
1762
1763        assert_eq!(held.len(), 0, "a frame alone does not materialise a seat");
1764    }
1765
1766    /// The cap bounds what a seat holds: appending past its slack drops the
1767    /// oldest messages and keeps the newest.
1768    ///
1769    /// **The drop runs at the slack and keeps the cap**, so the seat carries
1770    /// a window plus a slack's worth of room for the appends that refill it -
1771    /// and it runs before the push, which is what keeps the store from
1772    /// doubling past the slack it was sized for.
1773    #[test]
1774    fn an_append_past_the_caps_slack_drops_the_oldest_messages() {
1775        let mut conversation = Conversation::empty();
1776        for at in 0..CONVERSATION_CAP + CONVERSATION_SLACK {
1777            conversation.append(a_frame(&format!("{at}")));
1778        }
1779        assert_eq!(
1780            conversation.messages().len(),
1781            CONVERSATION_CAP + CONVERSATION_SLACK,
1782            "precondition: the slack is filled and nothing has been dropped",
1783        );
1784
1785        conversation.append(a_frame("one past the slack"));
1786
1787        assert_eq!(
1788            conversation.messages().len(),
1789            CONVERSATION_CAP + 1,
1790            "the drop keeps the cap, and the frame that triggered it joins them",
1791        );
1792        assert_eq!(
1793            conversation.dropped(),
1794            CONVERSATION_SLACK,
1795            "and it counted every message it left behind",
1796        );
1797        assert_eq!(
1798            said(&conversation.messages()[0]),
1799            format!("{CONVERSATION_SLACK}"),
1800            "the oldest kept is the one the cap reaches back to",
1801        );
1802        assert_eq!(
1803            said(conversation.messages().last().expect("a newest")),
1804            "one past the slack",
1805            "and the newest is the frame that just arrived",
1806        );
1807    }
1808
1809    /// **A seed over the cap keeps the window and reports the cut** - the
1810    /// store's own shape is the window module's to pin, and this is the seat's
1811    /// half: what it keeps and what it counts as left behind.
1812    #[test]
1813    fn a_conversation_over_the_cap_keeps_the_window_and_reports_the_cut() {
1814        let one = a_frame("a message");
1815        let history = vec![one; CONVERSATION_CAP * 4];
1816
1817        let conversation = Conversation::new(history, 0, 0);
1818
1819        assert_eq!(conversation.messages().len(), CONVERSATION_CAP, "a seed keeps the cap");
1820        assert_eq!(conversation.dropped(), CONVERSATION_CAP * 3, "and reports what it left behind");
1821    }
1822
1823    /// **The drop does not touch the window.** `SUBSCRIBE_TURNS` is what every
1824    /// client is handed when it attaches, so the page read over a capped
1825    /// conversation has to answer exactly what it answers over the same
1826    /// conversation with nothing dropped - the same turns, whole, and a
1827    /// cursor naming the same message.
1828    #[test]
1829    fn the_drop_leaves_the_newest_page_exactly_as_it_was() {
1830        let messages = a_long_history(CONVERSATION_CAP + CONVERSATION_SLACK);
1831        let whole = crate::transcript::render(&messages, false);
1832        let unbounded = crate::transport::wire::page(
1833            &messages,
1834            &whole,
1835            0,
1836            0,
1837            None,
1838            crate::transport::wire::SUBSCRIBE_TURNS,
1839        );
1840
1841        let conversation = Conversation::new(messages, 0, 0);
1842        assert!(conversation.dropped() > 0, "precondition: the seed was over the cap");
1843        let held = Held::new(conversation);
1844        let capped = newest_page(&held, crate::transport::wire::SUBSCRIBE_TURNS);
1845
1846        assert_eq!(
1847            turn_texts(&capped),
1848            turn_texts(&unbounded),
1849            "the newest page is the conversation's, not the window's",
1850        );
1851        assert_eq!(
1852            capped.cursor, unbounded.cursor,
1853            "and the cursor above it names the same message: a drop renumbers the held list, \
1854             not the conversation",
1855        );
1856    }
1857
1858    /// **A connect restarts the numbering.** It hands over a history read off
1859    /// the transcript, which is a row subset rather than this seat's own frame
1860    /// sequence, so an offset carried over it would be off by however far the
1861    /// two diverge.
1862    #[test]
1863    fn a_connects_history_restarts_the_numbering() {
1864        let held = Conversations::new();
1865        held.apply(&a_connect(a_long_history(CONVERSATION_CAP + CONVERSATION_SLACK), 0), None);
1866        let conversation = held.get(&a_seat()).expect("the seat is held");
1867        let dropped = conversation.lock().dropped();
1868        assert!(dropped > 0, "precondition: the connect was over the cap");
1869
1870        let window = conversation.lock().messages().to_vec();
1871        held.apply(&a_connect(window, 0), None);
1872        assert_eq!(
1873            conversation.lock().dropped(),
1874            0,
1875            "a connect's history restarts it, however its window looks",
1876        );
1877    }
1878
1879    /// **A cursor keeps its numbering across a drop the APPENDS ran too.**
1880    ///
1881    /// The two tests beside this one seed their drops through a constructor,
1882    /// where the offset is set once; a seat a frame at a time past its slack
1883    /// is the ordinary case, and the numbering has to accumulate there -
1884    /// a drop that overwrote the count instead of adding to it would resolve
1885    /// the cursor that many messages high and answer a client the newest
1886    /// window rather than the page it asked for.
1887    #[test]
1888    fn a_cursor_keeps_its_numbering_across_an_append_driven_drop() {
1889        let held = Held::new(Conversation::new(
1890            a_long_history(CONVERSATION_CAP + CONVERSATION_SLACK),
1891            0,
1892            0,
1893        ));
1894        let dropped_at_seed = held.lock().dropped();
1895        assert!(dropped_at_seed > 0, "precondition: the seed was over the cap");
1896
1897        // The page above the newest one, taken while the seat holds what the
1898        // seed left it.
1899        let cursor = newest_page(&held, 2).cursor.expect("a page above the newest one");
1900        let above_before = held.read(|held| {
1901            crate::transport::wire::page(
1902                held.messages(),
1903                held.rendered(),
1904                held.dropped(),
1905                held.dropped_thinking(),
1906                None,
1907                4,
1908            )
1909        });
1910
1911        // Frames until the appends spend the slack and a second drop runs.
1912        // A couple past the slack, because the seed's own drop is measured to
1913        // the turn it lands on and so can leave the held list a frame or two
1914        // further under the cap.
1915        for at in 0..=CONVERSATION_SLACK + 2 {
1916            held.lock().append(a_frame(&format!("later {at}")));
1917        }
1918        assert!(
1919            held.lock().dropped() > dropped_at_seed,
1920            "precondition: the appends outgrew the cap's slack and a drop ran",
1921        );
1922
1923        let above = held.read(|held| {
1924            crate::transport::wire::page(
1925                held.messages(),
1926                held.rendered(),
1927                held.dropped(),
1928                held.dropped_thinking(),
1929                Some(&cursor),
1930                2,
1931            )
1932        });
1933        assert_eq!(
1934            turn_texts(&above),
1935            turn_texts(&above_before)[..2].to_vec(),
1936            "the page above a cursor is the page above the turn it named, drop or no drop",
1937        );
1938    }
1939
1940    /// **A cursor outlives the drop it was written before.** It names a
1941    /// message in the conversation's own numbering, so the page above it is
1942    /// still the page above it - and a cursor the drop has passed is answered
1943    /// with the floor, which is what the client holding it can actually read,
1944    /// rather than with whatever now sits at that index in the held list.
1945    #[test]
1946    fn a_cursor_survives_a_drop_and_stops_at_the_floor() {
1947        let conversation =
1948            Conversation::new(a_long_history(CONVERSATION_CAP + CONVERSATION_SLACK), 0, 0);
1949        let held = Held::new(conversation);
1950        let dropped = held.lock().dropped();
1951        assert!(dropped > 0, "precondition: the seed was over the cap");
1952
1953        // The newest page, and the cursor it hands back for the page above it.
1954        let newest = held.read(|held| {
1955            crate::transport::wire::page(
1956                held.messages(),
1957                held.rendered(),
1958                held.dropped(),
1959                held.dropped_thinking(),
1960                None,
1961                2,
1962            )
1963        });
1964        let cursor = newest.cursor.expect("there is a page above the newest one");
1965
1966        let above = held.read(|held| {
1967            crate::transport::wire::page(
1968                held.messages(),
1969                held.rendered(),
1970                held.dropped(),
1971                held.dropped_thinking(),
1972                Some(&cursor),
1973                2,
1974            )
1975        });
1976        let carried = held.read(|held| {
1977            crate::transport::wire::page(
1978                held.messages(),
1979                held.rendered(),
1980                held.dropped(),
1981                held.dropped_thinking(),
1982                None,
1983                4,
1984            )
1985        });
1986        assert_eq!(
1987            turn_texts(&above),
1988            turn_texts(&carried)[..2].to_vec(),
1989            "the cursor names the turn it was handed for, and the page above it is that page's \
1990             own",
1991        );
1992
1993        // A client whose oldest turn is below the floor is asking for what is
1994        // above a conversation this seat no longer holds: the page above it
1995        // is empty, and its `None` is what stops the walk.
1996        let floor = held.read(|held| {
1997            crate::transport::wire::page(
1998                held.messages(),
1999                held.rendered(),
2000                held.dropped(),
2001                held.dropped_thinking(),
2002                Some(&dropped.saturating_sub(1).to_string()),
2003                2,
2004            )
2005        });
2006        assert!(
2007            floor.turns.is_empty(),
2008            "a cursor the drop passed is answered with nothing above it: {:?}",
2009            turn_texts(&floor),
2010        );
2011        assert!(
2012            floor.cursor.is_none(),
2013            "and with no page above it to ask for: the turn it named is not held any more",
2014        );
2015    }
2016}