Skip to main content

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}