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}