forge_server/live.rs
1//! What a view has learned from the stream, which its first render and every
2//! later one both read.
3//!
4//! Three of the facts here are announced on the wire once and retained by
5//! nobody: whether a completion has been shown, which seats a view is
6//! showing, and whether a seat's failed turn has been shown since it failed.
7//! A view that attaches to an already-running session cannot reconstruct
8//! them, so they are folded here rather than in the view.
9
10use std::collections::HashMap;
11use std::sync::{Mutex, MutexGuard};
12use std::time::SystemTime;
13
14use forge_primitives::Message;
15use forge_primitives::SessionSlot;
16use forge_primitives::runtime::RuntimeSessionState;
17
18use crate::SessionUpdate;
19use crate::composer::Composer;
20use crate::surface::is_success_result;
21use crate::translate::state_parsing::parse_runtime_session_state;
22use crate::unseen::Unseen;
23
24/// What the view has learned from the stream, which the first render and
25/// every later one both read.
26#[derive(Default)]
27pub struct Live {
28 unseen: Unseen,
29 /// The seats a page is open on, by how many connections are showing
30 /// them: a turn finishing on one of those is a turn the reader watched.
31 attached: HashMap<SessionSlot, usize>,
32 /// When each seat was last shown, by any connection. A failure newer
33 /// than the stamp is one the reader has not seen, which is what the
34 /// rail's failure mark reads (#1612): one `Live` is folded per server
35 /// and shared by every connection, so showing a seat spends its mark
36 /// for all of them.
37 seen_failed: HashMap<SessionSlot, SystemTime>,
38 composer: Composer,
39}
40
41/// What the stream has said, as one render reads it. A render takes this
42/// rather than the lock: the guard is not `Send`, and a handler that held
43/// it across its own awaits could not be one.
44#[derive(Default)]
45pub struct LiveState {
46 pub unseen: Unseen,
47 pub composer: Composer,
48 /// The seats a page is open on, by how many connections are showing
49 /// them, and when each seat was last shown: together these answer
50 /// whether a failure on it is the reader's to see, which the home read
51 /// filters rows with.
52 attached: HashMap<SessionSlot, usize>,
53 seen_failed: HashMap<SessionSlot, SystemTime>,
54}
55
56impl LiveState {
57 /// The failure instant the rail should draw for `slot`, or `None`
58 /// when there is no failure to mark: the seat is being shown (some
59 /// connection is watching it fail), or it has been shown since the
60 /// failure landed.
61 pub fn failed_mark(&self, slot: &SessionSlot, at: SystemTime) -> Option<SystemTime> {
62 mark_showing(&self.attached, &self.seen_failed, slot, at).then_some(at)
63 }
64}
65
66/// Whether a failure at `at` is still the reader's to see.
67///
68/// One predicate for its two readers - the home's rows, and the attach that
69/// spends the mark - so the test the home draws by and the test that decides
70/// whether a refresh is owed cannot drift apart.
71fn mark_showing(
72 attached: &HashMap<SessionSlot, usize>,
73 seen_failed: &HashMap<SessionSlot, SystemTime>,
74 slot: &SessionSlot,
75 at: SystemTime,
76) -> bool {
77 if attached.contains_key(slot) {
78 return false;
79 }
80 !matches!(seen_failed.get(slot), Some(shown) if shown >= &at)
81}
82
83impl Live {
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 /// A panicking task must not take the view's live state with it.
89 pub fn lock(live: &Mutex<Self>) -> MutexGuard<'_, Self> {
90 live.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
91 }
92
93 pub fn snapshot(&self) -> LiveState {
94 LiveState {
95 unseen: self.unseen.clone(),
96 composer: self.composer.clone(),
97 attached: self.attached.clone(),
98 seen_failed: self.seen_failed.clone(),
99 }
100 }
101
102 /// This view has shown `slot`, so nothing about it is unseen.
103 pub fn seen(&mut self, slot: &SessionSlot) {
104 self.unseen.clear(slot);
105 self.seen_failed.insert(slot.clone(), SystemTime::now());
106 }
107
108 /// A page is open on `slot`, which is a connection showing it, so a mark
109 /// armed before the page opened goes with it. Counted, because two tabs
110 /// on one seat are one seat still being shown.
111 ///
112 /// **Answers whether a mark was actually spent** - the diamond this view
113 /// was carrying, or a failure at `failed_at` still showing for it. A
114 /// connection that holds the home is owed a fresh one exactly then: a
115 /// re-encode with nothing to carry is 448 KB and 113-263 ms of server
116 /// time for a home the client already has (measured 2026-10-09).
117 pub fn attach(&mut self, slot: &SessionSlot, failed_at: Option<SystemTime>) -> bool {
118 let spent = self.unseen.is_unseen(slot)
119 || failed_at
120 .is_some_and(|at| mark_showing(&self.attached, &self.seen_failed, slot, at));
121 *self.attached.entry(slot.clone()).or_default() += 1;
122 self.unseen.clear(slot);
123 self.seen_failed.insert(slot.clone(), SystemTime::now());
124 spent
125 }
126
127 /// One page on `slot` has gone. The seat is let go with the last of them.
128 pub fn detach(&mut self, slot: &SessionSlot) {
129 let Some(count) = self.attached.get_mut(slot) else {
130 return;
131 };
132 *count -= 1;
133 if *count == 0 {
134 self.attached.remove(slot);
135 // The seat is no longer shown, and it was shown up to now: a
136 // failure that landed while the page was open is not news.
137 self.seen_failed.insert(slot.clone(), SystemTime::now());
138 }
139 }
140
141 /// Whether a page is holding `slot`.
142 pub fn is_attached(&self, slot: &SessionSlot) -> bool {
143 self.attached.contains_key(slot)
144 }
145
146 /// A write sent the seat's words down a path the update stream does not
147 /// carry; the composer's own rule decides whether they were its landed
148 /// take's (#2283).
149 pub fn composer_sent(&mut self, slot: &SessionSlot, words: &str) {
150 self.composer.sent(slot, words);
151 }
152
153 /// Fold one update in: the marks it moves, and the composer's own record.
154 ///
155 /// The classification is what keeps a busy turn from mattering here for
156 /// every token of it: only the updates that can change what a row draws
157 /// move a mark.
158 pub fn apply(&mut self, update: &SessionUpdate) {
159 self.composer.apply(update);
160 match fleet_news(update) {
161 // A turn finished on a session this page is not showing, so the
162 // row earns its diamond until the session is opened. A seat a
163 // page is open on has already shown it, so it earns nothing,
164 // and the row settles out of running like any other.
165 FleetNews::Completed(key) => {
166 if !self.attached.contains_key(key) {
167 self.unseen.mark_completed(key);
168 }
169 }
170 // Work started again, which supersedes the completion the
171 // diamond marks, and a fresh occupant whose history is not a
172 // completion this page failed to show. Serving the seat's page
173 // clears it too, so this is the clear for a seat nobody opened.
174 FleetNews::Running(key) | FleetNews::Occupant(key) => {
175 self.unseen.clear(key);
176 }
177 FleetNews::Nothing | FleetNews::Redraw => {}
178 }
179 }
180}
181
182/// What one update asks of the fleet region: the rows, and the marks on them.
183///
184/// One classification for its two readers - the view folding the stream, and
185/// the socket deciding which subscribers an update belongs to - because a
186/// second table of the variants would drift from this one.
187pub enum FleetNews<'a> {
188 /// The region draws nothing of it. Most of the stream: a turn's own words
189 /// are the bulk of it, and no row shows one.
190 Nothing,
191 /// A row or a mark changed.
192 Redraw,
193 /// A turn finished on the seat.
194 Completed(&'a SessionSlot),
195 /// Work started again on the seat.
196 Running(&'a SessionSlot),
197 /// A fresh occupant took the seat.
198 Occupant(&'a SessionSlot),
199}
200
201impl FleetNews<'_> {
202 /// Whether the region draws anything of this update at all.
203 pub fn any(&self) -> bool {
204 !matches!(self, Self::Nothing)
205 }
206}
207
208/// Classify one update for the fleet region.
209///
210/// The filter is what keeps a busy turn from re-sending the fleet for every
211/// token of it: only the updates that can change what a row draws belong here.
212pub fn fleet_news(update: &SessionUpdate) -> FleetNews<'_> {
213 match update {
214 // A prompt frame is a user turn: neither arm below draws anything of
215 // it, so the origin does not change what the fleet folds.
216 SessionUpdate::ChatAppended { key, msg, .. } => match msg {
217 Message::Result { is_error, subtype, .. } => {
218 if is_success_result(*is_error, subtype) {
219 FleetNews::Completed(key)
220 } else {
221 // A failed turn can arm the row's failure mark, so the
222 // rows are re-read; a cancelled turn only redraws the
223 // rows it left.
224 FleetNews::Redraw
225 }
226 }
227 Message::System { subtype, data, .. } if subtype == "session_state_changed" => {
228 if parse_runtime_session_state(data.get("state")) == Some(RuntimeSessionState::Running)
229 {
230 FleetNews::Running(key)
231 } else {
232 FleetNews::Redraw
233 }
234 }
235 Message::BackgroundTasksChanged { .. } => FleetNews::Redraw,
236 _ => FleetNews::Nothing,
237 },
238 // The row set, and what each row is.
239 SessionUpdate::Spawning { key, .. }
240 | SessionUpdate::Connected { key, .. }
241 | SessionUpdate::SessionReplaced { key, .. } => FleetNews::Occupant(key),
242 // Everything else that changes what a row or a card says. The
243 // catalog, the dictation snapshot and the claude version all arrive
244 // after the listener binds: a page opened in those first seconds
245 // would otherwise keep the empty answer it painted until the next
246 // tick.
247 SessionUpdate::CatalogLoaded
248 | SessionUpdate::CliVersionChanged
249 // The account pool, which the band's own card draws and no row does.
250 // A page that read it once drew `0 ready, probing` until the next
251 // unrelated redraw.
252 | SessionUpdate::AccountsChanged { .. }
253 | SessionUpdate::DictateAvailability
254 | SessionUpdate::ConnectionFailed { .. }
255 | SessionUpdate::TurnError { .. }
256 | SessionUpdate::TurnCancelled { .. }
257 // The turn's start, and the earliest frame that says a turn was
258 // accepted: `turn_pending` is stamped when the prompt is routed and
259 // cleared only on the turn's result, so the read this asks for is
260 // answered with the row already running. Every state is taken,
261 // `state` being free-form by design - a state this build has not
262 // seen has to move a row rather than be dropped. `queued` and
263 // `started` are the start; `completed` sits at the boundary the
264 // result frame also announces; `cancelled`, `discarded` and
265 // `refused` are a prompt that will not run, where a row read at the
266 // start has no other frame guaranteed to say so. Without this a row
267 // kept the idle it last read until an unrelated frame happened to be
268 // news (#1887, measured at ~40s while an agent ran underneath).
269 | SessionUpdate::PromptLifecycle { .. }
270 | SessionUpdate::PermissionRequest { .. }
271 | SessionUpdate::QuestionRequest { .. }
272 // Answering moves the seat out of the rail's needs-you group and
273 // drops the inspector's pending row, so it redraws a row even though
274 // it is the composer that asked for it.
275 | SessionUpdate::PendingInteractionResolved { .. }
276 // A held draft is the third kind of ask: its seat moves into the
277 // needs-you group while it waits and back out when it resolves, so a
278 // home-only subscriber has to be sent the pair or its row reads as
279 // it stood before the draft (#1758). A browser hand-off is the same
280 // shape on the same grounds.
281 | SessionUpdate::SlackPostPending { .. }
282 | SessionUpdate::SlackDraftResolved { .. }
283 | SessionUpdate::BrowserHandOffPending { .. }
284 | SessionUpdate::BrowserHandOffResolved { .. }
285 // A terminal hand-off is the same shape again, and a terminal that
286 // opened, exited or was closed moves the row's strip.
287 | SessionUpdate::TerminalHandOffPending { .. }
288 | SessionUpdate::TerminalHandOffResolved { .. }
289 | SessionUpdate::TerminalChanged { .. }
290 // **A hand-off has to reach the client wherever the reader is.** The
291 // viewer opens on the raise, and the home subscription is the one
292 // every client holds - a seat-keyed raise a reader sitting on the
293 // home never heard drew no viewer at all (measured 2026-10-12, the
294 // e2e). It redraws no row; the arm is what carries it there.
295 | SessionUpdate::DocumentHandOffPending { .. }
296 | SessionUpdate::DocumentHandOffResolved { .. }
297 // A held configuration write is the same shape again: the seat
298 // moves into the needs-you group while the dock waits on the
299 // reader's answer and back out when it resolves.
300 | SessionUpdate::SettingsWritePending { .. }
301 | SessionUpdate::SettingsWriteResolved { .. }
302 // A held vault call is the same shape again: the seat moves into
303 // the needs-you group while its card waits on the reader's answer
304 // and back out when it resolves.
305 | SessionUpdate::VaultAskPending { .. }
306 | SessionUpdate::VaultAskResolved { .. }
307 | SessionUpdate::WorkerStatusChanged { .. }
308 // The seat's release has begun: its row says where it is going until
309 // the roster lands it asleep or gone. Announced at the start of the
310 // teardown rather than at the end, because the teardown is what takes
311 // the seconds - and a close made from another view reaches this page
312 // through this arm alone (#1930).
313 | SessionUpdate::Releasing { .. }
314 // The project's task set, its schedules and its connector
315 // subscriptions moved - the three sections the board's project row
316 // draws from this stream. The home hears the task write too, because
317 // its fleet counts are derived from the same tasks (#2517).
318 | SessionUpdate::TasksChanged { .. }
319 | SessionUpdate::CronSchedulesChanged { .. }
320 | SessionUpdate::ConnectorSubscriptionsChanged { .. } => FleetNews::Redraw,
321 // Everything else is the conversation, which no row shows.
322 _ => FleetNews::Nothing,
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 /// The pool's frame with an empty pool, which is all these classifications
331 /// read of it.
332 fn accounts_changed() -> SessionUpdate {
333 SessionUpdate::AccountsChanged {
334 accounts: forge_workspace::AccountPoolView {
335 loading: Vec::new(),
336 all_loaded: false,
337 gateway: forge_workspace::AccountGatewayView {
338 ready: false,
339 port: 0,
340 bind_error: None,
341 },
342 usage: Vec::new(),
343 },
344 }
345 }
346
347 fn result_message(subtype: &str, is_error: bool) -> Message {
348 serde_json::from_value(serde_json::json!({
349 "type": "result",
350 "subtype": subtype,
351 "duration_ms": 1,
352 "duration_api_ms": 1,
353 "is_error": is_error,
354 "num_turns": 1,
355 "session_id": "s",
356 }))
357 .expect("parse a result message")
358 }
359
360 fn appended(key: &SessionSlot, msg: Message) -> SessionUpdate {
361 SessionUpdate::ChatAppended { key: key.clone(), msg, source: None }
362 }
363
364 /// The failure mark is the view's own: a failure on a seat this page
365 /// has not shown marks; showing the seat clears it; a failure after
366 /// that marks again; and one that lands while the page is showing the
367 /// seat marks nothing, because the reader is watching it happen.
368 #[test]
369 fn a_failure_marks_until_the_view_shows_the_seat() {
370 let slot = SessionSlot::lead("Org", "forge");
371 let mut live = Live::new();
372 let first = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(100);
373
374 assert_eq!(
375 live.snapshot().failed_mark(&slot, first),
376 Some(first),
377 "a failure nobody has shown marks",
378 );
379
380 live.attach(&slot, None);
381 assert_eq!(
382 live.snapshot().failed_mark(&slot, first),
383 None,
384 "showing the seat clears the mark",
385 );
386 live.detach(&slot);
387 assert_eq!(
388 live.snapshot().failed_mark(&slot, first),
389 None,
390 "and it stays clear once the page leaves - the reader saw it",
391 );
392
393 // The two stamps are taken from the clock, so each instant below is
394 // given a gap to sit strictly after the one before it: on a coarse
395 // clock, `now()` twice in a row reads equal.
396 std::thread::sleep(std::time::Duration::from_millis(2));
397 let second = std::time::SystemTime::now();
398 assert_eq!(
399 live.snapshot().failed_mark(&slot, second),
400 Some(second),
401 "a failure after the showing is news again",
402 );
403
404 live.attach(&slot, None);
405 std::thread::sleep(std::time::Duration::from_millis(2));
406 let third = std::time::SystemTime::now();
407 assert_eq!(
408 live.snapshot().failed_mark(&slot, third),
409 None,
410 "a failure landing while the page is open marks nothing",
411 );
412 live.detach(&slot);
413 assert_eq!(
414 live.snapshot().failed_mark(&slot, third),
415 None,
416 "and leaving does not resurrect it",
417 );
418 }
419
420 /// **An attach answers whether it spent anything**, which is what a home
421 /// refresh after it is owed for: the diamond it was carrying, or a failure
422 /// mark still showing. Nothing spent means the home the client holds is
423 /// the home it would be sent - and re-encoding it anyway is 448 KB and
424 /// 113-263 ms of server time for no change (measured 2026-10-09).
425 #[test]
426 fn an_attach_answers_whether_a_mark_was_spent() {
427 let slot = SessionSlot::lead("Org", "forge");
428 let mut live = Live::new();
429
430 let first = live.attach(&slot, None);
431 assert!(!first, "a seat carrying no marks spends nothing");
432 let second = live.attach(&slot, None);
433 assert!(!second, "and a second page on it spends nothing either");
434 live.detach(&slot);
435 live.detach(&slot);
436
437 // The diamond: a completion on a seat nobody is showing arms it.
438 live.apply(&appended(&slot, result_message("success", false)));
439 assert!(
440 live.attach(&slot, None),
441 "a completion nobody has shown is spent by showing the seat",
442 );
443
444 // A failure mark: a failure instant after the last time the seat was
445 // shown. The stamps come from the clock, so each instant is given a
446 // gap to sit strictly after the one before it.
447 live.detach(&slot);
448 std::thread::sleep(std::time::Duration::from_millis(2));
449 let failed = std::time::SystemTime::now();
450 assert!(
451 live.attach(&slot, Some(failed)),
452 "a failure nobody has shown is spent by showing the seat",
453 );
454 live.detach(&slot);
455 let again = live.attach(&slot, Some(failed));
456 assert!(!again, "a failure already shown is not spent twice");
457 }
458
459 fn session_state(state: &str) -> Message {
460 serde_json::from_value(serde_json::json!({
461 "type": "system",
462 "subtype": "session_state_changed",
463 "session_id": "s",
464 "state": state,
465 }))
466 .expect("parse a state message")
467 }
468
469 /// A diamond goes when its seat moves on without being looked at: work
470 /// started again, or a fresh occupant took the slot. The other clear,
471 /// the seat's page being served, is pinned through the route.
472 #[test]
473 fn starting_work_again_clears_the_diamond() {
474 let slot = SessionSlot::lead("Org", "forge");
475 let mut live = Live::new();
476 live.apply(&appended(&slot, result_message("success", false)));
477 assert!(live.snapshot().unseen.is_unseen(&slot), "precondition: the turn armed it");
478
479 let starting = appended(&slot, session_state("running"));
480 assert!(fleet_news(&starting).any(), "a turn starting redraws the page");
481 live.apply(&starting);
482 assert!(
483 !live.snapshot().unseen.is_unseen(&slot),
484 "and clears a completion nobody looked at",
485 );
486
487 // The same for a slot a new occupant took: its history is not a
488 // completion this page failed to show.
489 let taken = SessionSlot::lead("Org", "other");
490 live.apply(&appended(&taken, result_message("success", false)));
491 assert!(live.snapshot().unseen.is_unseen(&taken), "precondition: armed");
492
493 live.apply(&SessionUpdate::Connected {
494 key: taken.clone(),
495 session_id: forge_primitives::SessionId::new("new"),
496 cwd: "/proj".to_owned(),
497 current_model: forge_primitives::CurrentModel {
498 resolved_id: "claude".to_owned(),
499 display_name_short: "claude".to_owned(),
500 display_name_long: "claude".to_owned(),
501 requested_id: None,
502 catalog_id: None,
503 supports_effort: false,
504 supported_effort_levels: Vec::new(),
505 supports_auto_mode: None,
506 supports_adaptive_thinking: None,
507 is_authoritative: true,
508 },
509 available_models: Vec::new(),
510 mode: None,
511 history: Vec::new(),
512 dropped: 0,
513 compaction_count: 0,
514 });
515
516 assert!(
517 !live.snapshot().unseen.is_unseen(&taken),
518 "a replaced occupant starts from a clean row",
519 );
520
521 // And clearing one slot leaves the rest alone, which is the
522 // property the fold has to keep: a new turn somewhere is not news
523 // about somewhere else.
524 let untouched = SessionSlot::lead("Org", "untouched");
525 live.apply(&appended(&slot, result_message("success", false)));
526 live.apply(&appended(&untouched, result_message("success", false)));
527 live.apply(&appended(&slot, session_state("running")));
528 let unseen = live.snapshot().unseen;
529 assert!(!unseen.is_unseen(&slot), "the slot that started again is cleared");
530 assert!(unseen.is_unseen(&untouched), "and the slot that did not keeps its diamond");
531 }
532
533 /// Catches a diamond armed by the wrong result, and a failed turn that
534 /// redraws the rows without arming one - the mark it arms is the
535 /// failure mark, not the diamond.
536 #[test]
537 fn only_a_finished_turn_arms_the_diamond() {
538 let slot = SessionSlot::lead("Org", "forge");
539 let mut live = Live::new();
540
541 let failed = appended(&slot, result_message("error_during_execution", true));
542 assert!(fleet_news(&failed).any(), "a failed turn redraws the rows the failure mark rides");
543 live.apply(&failed);
544 assert!(
545 !live.snapshot().unseen.is_unseen(&slot),
546 "but a turn that failed is not a turn that finished, so no diamond",
547 );
548
549 let finished = appended(&slot, result_message("success", false));
550 assert!(fleet_news(&finished).any(), "a finished turn redraws the page");
551 live.apply(&finished);
552 assert!(live.snapshot().unseen.is_unseen(&slot), "and leaves the diamond");
553
554 let other = SessionSlot::lead("Org", "other");
555 let elsewhere = appended(&other, result_message("success", false));
556 assert!(fleet_news(&elsewhere).any(), "a second slot's finish is the same kind of event");
557 live.apply(&elsewhere);
558 let unseen = live.snapshot().unseen;
559 assert!(unseen.is_unseen(&other), "and earns its own diamond");
560 assert!(unseen.is_unseen(&slot), "without clearing the first slot's");
561 }
562
563 /// A turn that finishes on a seat whose page is open is a turn the reader
564 /// watched, so it earns no diamond - and the row still redraws, because
565 /// the turn that was running has ended.
566 #[test]
567 fn an_attached_seat_earns_no_diamond_and_still_settles() {
568 let slot = SessionSlot::lead("Org", "forge");
569 let mut live = Live::new();
570 live.attach(&slot, None);
571
572 let finished = appended(&slot, result_message("success", false));
573 assert!(fleet_news(&finished).any(), "the row settles out of running, so the page redraws");
574 live.apply(&finished);
575 assert!(
576 !live.snapshot().unseen.is_unseen(&slot),
577 "and the page that is open on it has shown the turn",
578 );
579 }
580
581 /// The other half: with the page gone, a completion is unlooked again.
582 #[test]
583 fn a_seat_arms_again_once_its_page_closes() {
584 let slot = SessionSlot::lead("Org", "forge");
585 let mut live = Live::new();
586 live.attach(&slot, None);
587 live.detach(&slot);
588
589 let finished = appended(&slot, result_message("success", false));
590 assert!(fleet_news(&finished).any(), "the diamond is the home's news again");
591 live.apply(&finished);
592 assert!(
593 live.snapshot().unseen.is_unseen(&slot),
594 "so a seat nobody is showing earns its diamond",
595 );
596 }
597
598 /// Two tabs on one seat are two connections and one seat still being
599 /// shown. Catches holding the attachment as a flag, where closing either
600 /// tab re-arms a mark the other tab is still displaying.
601 #[test]
602 fn two_pages_on_one_seat_hold_it_until_both_close() {
603 let slot = SessionSlot::lead("Org", "forge");
604 let mut live = Live::new();
605 live.attach(&slot, None);
606 live.attach(&slot, None);
607 live.detach(&slot);
608
609 live.apply(&appended(&slot, result_message("success", false)));
610 assert!(
611 !live.snapshot().unseen.is_unseen(&slot),
612 "the tab still open on it has shown the turn",
613 );
614
615 live.detach(&slot);
616 live.apply(&appended(&slot, result_message("success", false)));
617 assert!(
618 live.snapshot().unseen.is_unseen(&slot),
619 "and with both gone the seat is unlooked again",
620 );
621 }
622
623 /// The window between the page being served and its stream attaching: a
624 /// completion landing in it would otherwise sit on a page that is open.
625 #[test]
626 fn attaching_clears_a_mark_that_armed_before_it() {
627 let slot = SessionSlot::lead("Org", "forge");
628 let mut live = Live::new();
629 live.apply(&appended(&slot, result_message("success", false)));
630 assert!(live.snapshot().unseen.is_unseen(&slot), "precondition: the turn armed it");
631
632 live.attach(&slot, None);
633 assert!(
634 !live.snapshot().unseen.is_unseen(&slot),
635 "the page opening is the reader being shown the seat",
636 );
637 }
638
639 /// A seat nobody is showing leaves the map rather than sitting in it at
640 /// zero, which on a process up for a week is a seat-shaped leak.
641 #[test]
642 fn a_closed_page_leaves_no_entry_behind() {
643 let slot = SessionSlot::lead("Org", "forge");
644 let mut live = Live::new();
645 live.attach(&slot, None);
646 assert_eq!(live.attached.len(), 1, "precondition: the seat is held");
647
648 live.detach(&slot);
649 assert!(
650 live.attached.is_empty(),
651 "the last page closing takes the seat out of the map: {:?}",
652 live.attached,
653 );
654 }
655
656 /// **A turn's start is the home's news, and without the arm the row kept
657 /// the idle it last read** (#1887, measured at ~40s while an agent ran
658 /// underneath). The read is what turns the row running: the roster folds
659 /// `turn_pending`, which is stamped when the prompt is routed rather than
660 /// when a frame lands, so the state is already there for the asking.
661 #[test]
662 fn a_prompts_lifecycle_redraws_the_page() {
663 let slot = SessionSlot::lead("Org", "forge");
664
665 for state in ["queued", "started", "completed", "cancelled", "discarded", "refused"] {
666 let update = SessionUpdate::PromptLifecycle {
667 key: slot.clone(),
668 uuid: "p1".to_owned(),
669 state: state.to_owned(),
670 };
671 assert!(fleet_news(&update).any(), "a prompt's {state} is what a row is re-read for");
672 }
673
674 // The free-form state the protocol promises to carry rather than
675 // drop: a build that has not seen it still moves the row.
676 let unseen = SessionUpdate::PromptLifecycle {
677 key: slot,
678 uuid: "p1".to_owned(),
679 state: "a-state-from-a-newer-cli".to_owned(),
680 };
681 assert!(fleet_news(&unseen).any(), "an unseen state is news too");
682 }
683
684 /// The registry that moves `has_background_work`, which is the promotion
685 /// that draws an idle-but-busy seat as running. The CLI announces it as a
686 /// conversation frame, and that frame is what the home re-reads for; the
687 /// `BackgroundTasksChanged` mirror of the same instant carries the
688 /// inspector's set and is not the fleet's.
689 #[test]
690 fn the_clis_background_announcement_redraws_the_page() {
691 let slot = SessionSlot::lead("Org", "forge");
692
693 // The wire's own shape: the typed decode needs the frame's `uuid`,
694 // and a fixture without one falls back to the generic system variant,
695 // which no arm here answers.
696 let announced = serde_json::from_value(serde_json::json!({
697 "type": "system",
698 "subtype": "background_tasks_changed",
699 "tasks": [{ "task_id": "t1", "task_type": "local_bash", "description": "a wait" }],
700 "uuid": "u1",
701 "session_id": "s",
702 }))
703 .expect("parse a background announcement");
704
705 assert!(
706 fleet_news(&appended(&slot, announced)).any(),
707 "the registry moving is a row's re-read",
708 );
709 }
710
711 /// A release that has begun moves a row, wherever the close came from.
712 /// Its own frame is the only one a close made from another view sends
713 /// before the removal lands (#1930), so a home subscriber that did not
714 /// re-read here kept the row reading as working through the teardown.
715 #[test]
716 fn a_release_that_has_begun_redraws_the_page() {
717 let slot = SessionSlot::lead("Org", "forge");
718
719 assert!(
720 fleet_news(&SessionUpdate::Releasing { key: slot }).any(),
721 "the seat going to sleep is a row's re-read",
722 );
723 }
724
725 /// The bulk of the stream is the conversation, which the home does not
726 /// draw; re-sending it for one would rebuild the fleet per token.
727 #[test]
728 fn a_chat_message_does_not_redraw_the_page() {
729 let slot = SessionSlot::lead("Org", "forge");
730
731 assert!(
732 !fleet_news(&SessionUpdate::ChatAppended {
733 key: slot,
734 source: None,
735 msg: serde_json::from_value(serde_json::json!({
736 "type": "user",
737 "message": { "role": "user", "content": "hello" },
738 "session_id": "s",
739 }))
740 .expect("parse a user message"),
741 })
742 .any(),
743 "a chat message is not something the home draws",
744 );
745 }
746
747 /// A take's readings are the composer's news, not the fleet's. The
748 /// region this stream re-sends draws rows, so redrawing it for every
749 /// level would rebuild the whole page twenty times a second while a
750 /// take runs.
751 #[test]
752 fn a_takes_readings_are_not_the_fleets_news() {
753 let slot = SessionSlot::lead("Org", "forge");
754
755 for update in [
756 SessionUpdate::DictateStarted {
757 key: slot.clone(),
758 floor_db: -50.0,
759 generation: 1,
760 initiator: None,
761 },
762 SessionUpdate::DictateLevel {
763 key: slot.clone(),
764 peak_db: -20.0,
765 generation: 1,
766 initiator: None,
767 },
768 ] {
769 assert!(!fleet_news(&update).any(), "{update:?} is not a row changing");
770 }
771 }
772
773 /// The updates that land after the listener binds, and that a page
774 /// opened in that window has already painted an answer for: the
775 /// catalog scan, the dictation snapshot, the claude version probe and
776 /// the account pool settling. Catches a page that keeps the empty
777 /// answer until the next tick.
778 ///
779 /// The pool is the one that bites hardest, because nothing else in the
780 /// stream mentions it: a quiet forge emits no other update, so a card
781 /// that read `0 ready, probing` at subscribe reads it for the life of
782 /// the connection.
783 #[test]
784 fn the_late_boot_updates_redraw_the_page() {
785 for update in [
786 SessionUpdate::CatalogLoaded,
787 SessionUpdate::DictateAvailability,
788 SessionUpdate::CliVersionChanged,
789 accounts_changed(),
790 ] {
791 assert!(fleet_news(&update).any(), "{update:?} is exactly a render wake-up");
792 }
793 }
794}