Skip to main content

forge_server/transport/
envelope.rs

1//! What a client and this server exchange.
2//!
3//! Both enums are tagged on `kind`, which is added beside a message's own
4//! fields rather than replacing anything, so a reader can tell what a
5//! message is without knowing its shape. **The tag is the contract**:
6//! adding a variant is compatible and renaming one is not.
7
8use forge_primitives::document::DocumentKind;
9use forge_primitives::git_diff::LayerState;
10use forge_primitives::{ClientConfig, SessionSlot};
11use forge_workspace::DictateAxes;
12use forge_workspace::env::git_diff::content::FileContent;
13use serde::{Deserialize, Serialize};
14
15use super::wire::ReviewedThreadWire;
16use crate::live::fleet_news;
17use crate::{Command, SessionUpdate};
18
19/// What a client can watch. A subscription names one of these and is
20/// answered with that subject's snapshot.
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Subject {
24    Home,
25    /// Every project's board detail: its rows, its schedules and its
26    /// connector subscriptions.
27    ///
28    /// A subject of its own rather than fields of [`Subject::Home`] because
29    /// the board is its own page's data: the home is a glance at the fleet,
30    /// and carrying every project's rows made a home read about 708 KB, most
31    /// of it board data a home page draws none of (#2517).
32    Board,
33    Session(SessionSlot),
34    /// The token/cost pool, scanned on the ask.
35    ///
36    /// It belongs to no seat - the report is the whole session-JSONL pool,
37    /// bucketed by project and model - and no update carries it: the pool
38    /// changes as transcripts are written, and nothing in the stream says a
39    /// transcript's tokens moved. So a subscription is answered with one scan
40    /// and hears nothing after it, and a client asks again by subscribing
41    /// again.
42    Usage,
43    /// The models page's read: the pinned models, the catalogue check,
44    /// the feed's rows and the updates it proposes.
45    ///
46    /// Carried as a subject of its own rather than inside `Home` because
47    /// the rows are the whole catalogue and only the models page draws
48    /// them. Unlike `Usage` it does hear updates: a check landing, boot's
49    /// or a page's own, arrives as `SessionUpdate::DictateModelsChanged`.
50    DictateModels,
51    /// The Extensions page's read: the plugin inventory, the marketplace
52    /// health and the last update run.
53    ///
54    /// A subject of its own for `DictateModels`' reason - only the
55    /// Extensions page draws any of it - and it hears the whole `Plugins*`
56    /// family: an action's outcome, a run's progress, and the refreshed
57    /// inventory are all news about what this subscription is showing.
58    Extensions,
59}
60
61impl Subject {
62    /// Whether an update belongs to what this subscription asked for.
63    ///
64    /// A session subscription is a seat, so the workspace is ASKED which seat
65    /// a variant routes to rather than this matching the variants itself.
66    ///
67    /// A home subscription is a row per seat plus the App-level facts, and it
68    /// takes the classification that already exists for the row half:
69    /// [`fleet_news`], which is what the view folding this stream draws the
70    /// fleet from. It is narrow on purpose - a row states a seat's lifecycle,
71    /// its status and its pending state, and no row shows a word of its
72    /// conversation - so a second table of variants here would both drift and
73    /// hand a home subscriber every token of every seat in the fleet.
74    ///
75    /// **The fleet region is not the whole home.** Two home fields carry no
76    /// slot and are not fleet news: the service status and the fatal error.
77    /// They come with the slot-less arm below, and without it a client would
78    /// draw what it read once at subscribe for the life of the connection.
79    /// The account pool is a home field too, but its announcement is also a
80    /// fleet redraw, so it arrives through the first arm.
81    ///
82    /// **That arm is wider than the client's read rule, by the plugin
83    /// family.** The plugin records cross to a home subscriber only because
84    /// they name no seat; no field of the home snapshot carries them, their
85    /// reader is the `extensions` subject, and `coversHome`
86    /// (`client/src/wire/fleet.ts`) declines the home read for them on
87    /// purpose. Delivery here is the superset.
88    pub fn covers(&self, update: &SessionUpdate) -> bool {
89        match self {
90            // **The board's own variants are not the home's.** A cron or a
91            // connector set left the home's payload with the rows, so a home
92            // subscriber that heard one would be asked to re-read for news its
93            // snapshot cannot carry (rule 25). `TasksChanged` stays: the
94            // fleet's counts are derived from the same tasks, so the home's
95            // answer does carry its effect.
96            Self::Home => {
97                (fleet_news(update).any() || update.slot().is_none()) && !board_detail(update)
98            }
99            // The board snapshot carries every project's rows, crons and
100            // connector sets, so the variants that move them - plus the seat
101            // events a row's `stale` mark is read from - belong here. A row's
102            // time-derived marks are the page's own beat, not a frame.
103            Self::Board => {
104                board_detail(update)
105                    || matches!(update, SessionUpdate::TasksChanged { .. })
106                    || seat_liveness(update)
107            }
108            Self::Session(seat) => update.slot() == Some(seat),
109            // Nothing carries the pool: its snapshot is a scan, and a
110            // transcript's tokens moving is not an update any variant
111            // announces.
112            Self::Usage => false,
113            // The check's own landing, and the load's signal: a page open
114            // during a first 3 GB download hears models finished loading
115            // through `DictateAvailability` and reads again, so its state
116            // chips are not stuck on `pending` until the next check. The
117            // feed belongs to no seat, so nothing else reaches here.
118            Self::DictateModels => matches!(
119                update,
120                SessionUpdate::DictateModelsChanged { .. } | SessionUpdate::DictateAvailability
121            ),
122            // The whole `Plugins*` family: every one is news about what
123            // this subscription draws - an action's outcome, a run's rows
124            // moving, the refreshed inventory, a failed refresh, a
125            // rollback's result. The family belongs to no seat, so nothing
126            // else routes here.
127            Self::Extensions => matches!(
128                update,
129                SessionUpdate::PluginsInventoryUpdated { .. }
130                    | SessionUpdate::PluginsInventoryRefreshFailed { .. }
131                    | SessionUpdate::PluginsCliActionSucceeded { .. }
132                    | SessionUpdate::PluginsCliActionFailed { .. }
133                    | SessionUpdate::PluginsUpdateRunProgress { .. }
134                    | SessionUpdate::PluginsUpdateRunFinished { .. }
135                    | SessionUpdate::PluginsRollbackSucceeded { .. }
136                    | SessionUpdate::PluginsRollbackFailed { .. }
137            ),
138        }
139    }
140}
141
142/// The updates whose whole effect is the board's own snapshot.
143///
144/// Crons and connector sets moved out of the home's payload with the rows
145/// (#2517), so the home must not hear them; the board carries both.
146fn board_detail(update: &SessionUpdate) -> bool {
147    matches!(
148        update,
149        SessionUpdate::CronSchedulesChanged { .. }
150            | SessionUpdate::ConnectorSubscriptionsChanged { .. }
151    )
152}
153
154/// The seat events a board row's `stale` mark is read from.
155///
156/// `seat_is_live` reads the domain/agent registry and the worker registry,
157/// and these are the frames that change either: a seat starting, connecting,
158/// being replaced, releasing, failing to connect, or a worker's status
159/// moving. Turn frames are not here - a task write is what moves a row's work,
160/// and a page's own beat advances its clocks.
161///
162/// **The coverage is not exact.** A session task exiting and a worktree
163/// rollback release the handle with no frame of their own, and `Spawning`
164/// fires before the handle exists - so in those windows a row's mark can lag
165/// until the seat's next covered frame, or until the page's beat re-reads.
166fn seat_liveness(update: &SessionUpdate) -> bool {
167    matches!(
168        update,
169        SessionUpdate::Spawning { .. }
170            | SessionUpdate::Connected { .. }
171            | SessionUpdate::SessionReplaced { .. }
172            | SessionUpdate::Releasing { .. }
173            | SessionUpdate::ConnectionFailed { .. }
174            | SessionUpdate::WorkerStatusChanged { .. }
175    )
176}
177
178/// What a client sends.
179#[derive(Debug, Serialize, Deserialize)]
180#[serde(tag = "kind", rename_all = "snake_case")]
181pub enum ClientMessage {
182    /// A liveness probe, sent by a transport after a quiet spell and by the
183    /// page on a return from the background.
184    ///
185    /// **Application-level rather than a WebSocket control frame**: a browser
186    /// page cannot send a Ping, so control frames would leave the web build
187    /// with no probe at all. Any inbound frame answers one; this exists so a
188    /// quiet peer is always asked something.
189    Ping,
190    Subscribe {
191        what: Subject,
192        /// Whether this client can answer the prompts it is shown.
193        ///
194        /// Off unless the client says otherwise, because the core parks a
195        /// turn on the reply of whoever registered as answering: a client
196        /// counted as able to answer a prompt it has no surface to draw hangs
197        /// the turn rather than failing it. A client with a dock to answer
198        /// from says so here; the connection registers with the core
199        /// accordingly before it forwards anything.
200        ///
201        /// **It is the CONNECTION's role, not the subject's.** The core keeps
202        /// one role per connection and it only ever rises, so a client that
203        /// can answer states it on the first subscribe it makes - usually the
204        /// home - and not only while a seat it could draw is showing.
205        ///
206        /// **What it guards is an install with no answerer anywhere.** Every
207        /// update is delivered to every subscriber regardless of role; the
208        /// role decides only what the core may PARK on. A forge with an
209        /// answering client attached already has an answerer, so a
210        /// second client that says nothing is a guest beside one that can.
211        /// Where nobody can answer, an ask is cancelled at birth rather than
212        /// held for a reply that could not come. Declaring this means that
213        /// case never arises while this client is attached.
214        #[serde(default)]
215        answering: bool,
216        /// The review page's own read: refresh the branch diff's base before
217        /// the record is encoded, and pin the stats and content reads to the
218        /// sha that refresh resolved.
219        ///
220        /// **Off for every other read.** The ordinary record read is the one
221        /// behind every seat page and must not wait on the network; a page
222        /// that draws a diff asks again with this set when it opens, so the
223        /// state beside that diff belongs to the moment it was read.
224        #[serde(default)]
225        review: bool,
226    },
227    Unsubscribe {
228        what: Subject,
229    },
230    /// Which seat this connection has on screen, `None` when it shows none.
231    ///
232    /// **A state assertion, not an event**: the last one wins and repeating it
233    /// is a no-op. A showing spends a seat's diamond, its failure mark and its
234    /// death trace - once the seat is also subscribed, so a client that keeps
235    /// every visited seat subscribed spends nothing until the reader opens
236    /// one. Nothing answers it.
237    Showing {
238        seat: Option<SessionSlot>,
239    },
240    /// `reply_to` is set only when the client wants an answer - which is the
241    /// eight commands whose reply rides a oneshot, and nothing else.
242    ///
243    /// Boxed because a command is 360 bytes and every other variant here is
244    /// a fraction of that (clippy::large_enum_variant). Serde writes a box
245    /// as the value inside it, so the wire form is unchanged.
246    Command {
247        command: Box<Command>,
248        reply_to: Option<u64>,
249    },
250    More {
251        conversation: SessionSlot,
252        before: Option<String>,
253        turns: u32,
254    },
255    /// The frames a seat's stream emitted between two numbers, for a client
256    /// that read a gap in them.
257    ///
258    /// **The numbers are the seat's own**, minted as each frame crossed, so a
259    /// repair folds into the client's stream exactly where the frames fell
260    /// rather than reading as a second conversation. `epoch` is the occupant
261    /// those numbers belong to: a seat whose occupant has changed has started
262    /// counting again, and a repair across the two is refused rather than
263    /// answered.
264    SeatRange {
265        slot: SessionSlot,
266        epoch: Option<forge_primitives::SessionId>,
267        from: u64,
268        to: u64,
269    },
270    /// One commit's own diff, for the review canvas' commit scopes.
271    ///
272    /// Asked on demand rather than carried on the record: a branch can
273    /// hold dozens of commits, and every reader of a record would pay
274    /// their content on every read for a scope only one page draws.
275    CommitDiff {
276        session: SessionSlot,
277        sha: String,
278    },
279    /// Attach this connection to one of a seat's browser tabs, as a viewer.
280    ///
281    /// `tab` is the tab to drive; `None` is the asking seat's current tab,
282    /// else its profile's first. The answer is a `browser_view` naming the
283    /// profile's wire id and the tab, then a `browser_nav_state`, then frames
284    /// as they paint. Attaching again replaces what this connection was
285    /// viewing.
286    BrowserView {
287        seat: SessionSlot,
288        tab: Option<u16>,
289        /// Answers the attach: `{"ok": true, "profile": <the one attached>,
290        /// "tab": <the tab>}`, or `{"ok": false, "why": ...}` with the
291        /// server's own words. **A raise that cannot be answered is a dark
292        /// surface**, so a client that can wait sends one; the profile and tab
293        /// are what the page's Done lowers.
294        reply_to: Option<u64>,
295    },
296    /// Open the page a parked hand-off named: the same attach, addressed by
297    /// the page's CDP target instead of a tab number.
298    ///
299    /// **A number is not an identity across a host restart.** The successor
300    /// host's first tab takes number 1 again, so an ask parked through the
301    /// replacement must name its page; a page that is gone is refused with
302    /// the tabs that are open now.
303    BrowserViewPage {
304        seat: SessionSlot,
305        target: String,
306        /// The same answer `BrowserView` gives: the profile and tab attached,
307        /// or the server's refusal.
308        reply_to: Option<u64>,
309    },
310    /// The files a person chose for a page's dialog, or none for a dismiss.
311    ///
312    /// **The bytes cross base64** because the wire is JSON; the server
313    /// writes them to its own temp directory and hands the host the paths,
314    /// because the host reads a file's content at the moment it is answered.
315    BrowserFileAnswer {
316        id: u64,
317        files: Vec<BrowserFileWire>,
318    },
319    /// Stop viewing the attached profile. **Lowering the takeover is a
320    /// detach**: the idle clock counts an attached viewer as a use, and a
321    /// connection that switched profiles must not leave a phantom viewer on
322    /// the old one.
323    BrowserDetach,
324    /// Attach this connection to one of a seat's terminals, as a viewer. The
325    /// answer is a `terminal_view` carrying the screen as a VT stream, then
326    /// output frames as the shell writes them.
327    TerminalView {
328        seat: SessionSlot,
329        /// The terminal id, or `None` to open the seat's terminal - creating
330        /// it when the seat has none - and attach to it. **The person's door
331        /// on a seat every agent has left alone.**
332        #[serde(default)]
333        id: Option<u16>,
334        /// **The grid the client will draw on**, sent with the attach so the
335        /// repaint is laid out for it: a client that attached first and
336        /// resized after had to draw nothing until a second repaint arrived,
337        /// which read as the screen flushing.
338        #[serde(default)]
339        cols: Option<u16>,
340        #[serde(default)]
341        rows: Option<u16>,
342        /// Answers the attach: `{"ok": true, "id": <the one attached>}` or
343        /// `{"ok": false, "why": ...}`. A raise that cannot be answered is a
344        /// dark surface, so a client that can wait sends one.
345        reply_to: Option<u64>,
346    },
347    /// Stop viewing the attached terminal: the idle clock and the resize
348    /// arbitration both read the truth from it.
349    TerminalDetach,
350    /// The attached surface changed size: the PTY and the model resize.
351    TerminalResize {
352        seat: SessionSlot,
353        id: u16,
354        cols: u16,
355        rows: u16,
356    },
357    /// Client-driven flow control, so a phone that backgrounded stops being
358    /// written to rather than being dropped. A resume is answered with a
359    /// fresh `terminal_view`, because the frames it missed are not replayed.
360    TerminalPause {
361        paused: bool,
362    },
363    /// Take the person's control of an attached terminal, or give it back.
364    /// **One controller at a time, like the browser's role**: a take always
365    /// lands, and the previous holder hears it lost the role.
366    TerminalControl {
367        seat: SessionSlot,
368        id: u16,
369        take: bool,
370    },
371    /// The person's close door: end the terminal's process group.
372    TerminalClose {
373        seat: SessionSlot,
374        id: u16,
375    },
376    /// One input event from the controlling viewer.
377    ///
378    /// `seq` is the viewer's own ordering: the server drops an event at or
379    /// below the last one the role carried, so a superseded gesture never
380    /// lands late. The event is the gesture, not CDP - the server maps it.
381    BrowserInput {
382        seq: u64,
383        event: forge_primitives::browser::BrowserInputEvent,
384    },
385    /// Take the controller role, or give it back.
386    BrowserControl {
387        take: bool,
388    },
389    /// Move through the page's history: the takeover bar's arrows.
390    BrowserNav {
391        back: bool,
392    },
393    /// The controller's surface changed size: the page is laid out to it.
394    ///
395    /// `width` and `height` are the surface's DEVICE pixels - the client
396    /// multiplies its CSS box by the ratio it draws at - and `scale` is that
397    /// ratio, which the host gives CEF.
398    BrowserResize {
399        width: u32,
400        height: u32,
401        scale: f64,
402    },
403    /// The tabs of this seat's profile, for a client's strip. Answered
404    /// through `reply_to` with `{"ok": true, "tabs": {
405    /// "profile", "id", "up", "connected", "closing_at_ms", "rows" }}`, and
406    /// then pushed as a `browser_tabs` whenever they change.
407    BrowserTabs {
408        seat: SessionSlot,
409        reply_to: Option<u64>,
410    },
411    /// One document, read out of a seat's own tree for a viewer's route.
412    ///
413    /// `path` resolves against the seat's working directory, and `reviewable`
414    /// is what the hand-off asked for - the hand-off's own fact, refused on a
415    /// PDF because that kind can never carry a review. Whether the DOCUMENT
416    /// is reviewable is the read's own answer, not this flag's echo. The
417    /// answer is a `document_read` message or an `error` naming it; those are
418    /// its only two.
419    DocumentRead {
420        session: SessionSlot,
421        path: String,
422        reviewable: bool,
423    },
424    /// One file's whole content at a revision, for the review page's
425    /// between-hunk expansion.
426    ///
427    /// The record's diff carries bounded hunks, so the lines BETWEEN them
428    /// are exactly what it does not carry - and only a page whose reader
429    /// opened a gap needs them, which is why this is asked for on demand
430    /// rather than carried on every record. `sha` names the scope's own
431    /// revision: a commit's tree for a commit scope, and `None` for the
432    /// seat's branch tip, which is the new side of the all-changes diff.
433    /// The answer is a `file_content` message; a read that could not run
434    /// answers as itself rather than as an error.
435    FileContent {
436        session: SessionSlot,
437        path: String,
438        sha: Option<String>,
439    },
440    /// One page's own perf records, for a client with no shell to write them.
441    ///
442    /// **The records stay JSON, unparsed here**: they are the page's instrument
443    /// (`client/src/perf.ts`), and the server's part is the file - written
444    /// beside `forge.log` so a web client produces diagnostics like the shells
445    /// do. Nothing answers it; a batch that could not be written is a debug
446    /// record, not the client's failure.
447    ClientPerf {
448        records: Vec<serde_json::Value>,
449    },
450    /// A batched save of the effective config. A save that writes is
451    /// answered with [`ServerMessage::SettingsSaved`]; one that does not is
452    /// answered with [`ServerMessage::SettingsRefused`]. A refused save
453    /// writes nothing, so no push follows it.
454    SettingsEdit {
455        /// The fingerprint of the raw file text the page loaded.
456        fingerprint: String,
457        edits: Vec<forge_primitives::settings::SettingsEditRequest>,
458        /// The page's own number for this save, echoed by
459        /// [`ServerMessage::SettingsSaved`] and
460        /// [`ServerMessage::SettingsRefused`] so a reply is matched to the
461        /// request that made it. Required: a client that forgets it is
462        /// refused rather than silently answered as id 0.
463        id: u64,
464    },
465}
466
467/// Why a settings save was refused.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
469#[serde(rename_all = "snake_case")]
470pub enum SettingsRefusal {
471    /// The file moved since the page loaded it.
472    Stale,
473    /// The candidate failed the boot loader.
474    Invalid,
475    /// A guard refused the save.
476    Refused,
477    /// The file could not be read or written. Nothing was applied: a write
478    /// that lands and then fails its directory fsync is reported as written.
479    Io,
480}
481
482/// One chosen file on the wire: its name and its bytes, base64.
483#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
484pub struct BrowserFileWire {
485    pub name: String,
486    pub data: String,
487}
488
489/// What the server sends.
490#[derive(Debug, Serialize, Deserialize)]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum ServerMessage {
493    /// The answer to [`ClientMessage::Ping`], and proof the peer is reading.
494    Pong,
495    /// The effective config, pushed once after the greeting and again after
496    /// every applied reload. Server-wide rather than seat-scoped: every
497    /// connection draws the same document, and a connection that attaches
498    /// while a reload is in flight reads the current load here - which is
499    /// why no settings read verb exists.
500    Settings {
501        document: forge_primitives::SettingsDocument,
502    },
503    /// A settings save that wrote nothing. `message` is the loader's own
504    /// words for an invalid candidate or the guard's own for a refusal;
505    /// `key_path` anchors it at one of the save's keys where one is known.
506    SettingsRefused {
507        reason: SettingsRefusal,
508        message: String,
509        #[serde(default)]
510        id: u64,
511        #[serde(default, skip_serializing_if = "Option::is_none")]
512        key_path: Option<String>,
513    },
514    /// A settings save that wrote. The reload push carries the new document;
515    /// this is the saver's own acknowledgement, matched by `id`, so a page
516    /// clears its draft only for its own save.
517    SettingsSaved {
518        id: u64,
519    },
520    Greeting {
521        version: u32,
522        /// The build that sent this, in the two forms a view's own
523        /// header draws. The greeting is the only thing a client is
524        /// guaranteed to have before it reads or refuses anything, so a skew
525        /// can name both halves from here and nowhere else.
526        forge_version: String,
527        forge_version_short: String,
528        settings: ClientSettings,
529    },
530    Snapshot {
531        subject: Subject,
532        data: serde_json::Value,
533    },
534    /// Boxed for the same reason as a command: the update is the largest
535    /// thing on this wire and every other variant is small beside it.
536    Update {
537        update: Box<SessionUpdate>,
538        /// The number the frame's oldest update was minted under, `None` for
539        /// a frame belonging to no seat.
540        ///
541        /// **A client reads the numbers to see what it missed.** Every frame
542        /// the workspace emits for a seat is numbered on that seat's counter,
543        /// so a client that reads 5 and then 7 knows exactly one frame went
544        /// missing and asks the seat for it (`seat_range`); the snapshot's
545        /// `header.seq` says where a fresh reader joins.
546        seq: Option<u64>,
547        /// How many of the seat's numbers this frame stands for: 1 for a
548        /// frame that is one update, and n for a run the batch folded into
549        /// one - a run of token appends crosses as the single frame they
550        /// draw as, and its span is what keeps the client from reading the
551        /// numbers it already drew as a gap.
552        ///
553        /// **0 only on the turn the batch forges from a delivery**, which
554        /// has no number; every other frame stands for at least one, the
555        /// out-of-band `read_call_output` answer (`seq: null`, `span: 1`)
556        /// included.
557        span: u64,
558    },
559    /// A page of one conversation, in answer to `more`: whole turns, each with
560    /// the key the server named it by and the messages it ran as. Grouping a
561    /// run of calls inside a turn is the client's, so the messages cross and no
562    /// drawing of them does.
563    Page {
564        conversation: SessionSlot,
565        turns: Vec<crate::transport::wire::TurnWire>,
566        cursor: Option<String>,
567        /// The schedules the fired prompts in `turns` came from, keyed by the
568        /// prompt's own id: what titles a cron row a page carries long after
569        /// its delivery frame is gone.
570        #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
571        cron_names: std::collections::BTreeMap<String, forge_primitives::cron::CronName>,
572    },
573    /// The answer to `seat_range`: the frames numbered in `from..=to`, each
574    /// with the number it was minted under, oldest first.
575    ///
576    /// **Answered from the seat's own window while it holds them, and from the
577    /// transcript below that.** A range the window has shed comes back as the
578    /// conversation's own rows numbered to END at the range's end, so a client
579    /// resumes where it was reading instead of asking again for a stretch that
580    /// cannot arrive. A range from another epoch, or one forge holds no seat
581    /// for, is an `error` naming `seat_range`.
582    SeatFrames {
583        slot: SessionSlot,
584        epoch: Option<forge_primitives::SessionId>,
585        frames: Vec<SeatFrameWire>,
586    },
587    /// The answer to `commit_diff`: one commit's own bounded diff, in the
588    /// same layer shape the record's content read carries, so a client
589    /// draws both scopes through one parse.
590    ///
591    /// **A `sha` git cannot read is the scan's own `scan_failed` layer, not
592    /// an `error`** - the diff-viewer contract keeps a read that answered
593    /// something apart from a read that could not run, and the client draws
594    /// the state as itself. Only a seat forge holds no session for is an
595    /// `error` naming `commit_diff`.
596    CommitDiff {
597        sha: String,
598        files: LayerState<Vec<FileContent>>,
599        /// Something was withheld for the read's budget, the flag the
600        /// record's own content read carries.
601        truncated: bool,
602    },
603    /// The answer to `file_content`: the file's lines at the revision asked
604    /// for, or why the read could not run.
605    ///
606    /// `session`, `path` and `sha` are named back so a page holding two
607    /// seats, two scopes or several files routes each answer to its own ask.
608    FileContent {
609        session: SessionSlot,
610        path: String,
611        sha: Option<String>,
612        content: crate::transport::wire::FileContentWire,
613    },
614    /// The answer to a `Command` that carried a `reply_to`, including a
615    /// refusal, so a client awaiting one is never left watching a channel
616    /// that stays empty.
617    Reply {
618        reply_to: u64,
619        body: serde_json::Value,
620    },
621    /// One tab's viewer state: which viewer controls it, and how many are
622    /// attached.
623    ///
624    /// Sent on attach and whenever either changes, so every viewer draws the
625    /// same truth about who is driving. `id` is the profile's wire id, the
626    /// same one the binary frames carry; `tab` is the tab this state is for;
627    /// and `viewer` is THIS connection's own - the number `controller` names
628    /// when this viewer holds the role, so a bar can say "Take control"
629    /// rather than guessing.
630    BrowserView {
631        id: u16,
632        profile: String,
633        tab: u16,
634        controller: Option<u64>,
635        viewers: u32,
636        viewer: u64,
637    },
638    /// One terminal's screen, as a VT stream a client can draw, and where the
639    /// byte stream continues from.
640    TerminalView {
641        seat: SessionSlot,
642        id: u16,
643        cols: u16,
644        rows: u16,
645        /// The last sequence the repaint reflects; the next frame carries
646        /// one above it.
647        seq: u32,
648        /// The visible screen as a VT stream, valid UTF-8.
649        screen: String,
650        /// How many clients are attached.
651        viewers: u32,
652        /// This connection's own viewer number, so a control change can say
653        /// whether the holder is this viewer.
654        viewer: u64,
655        /// The connection holding the person's control, `None` when the agent
656        /// does.
657        controller: Option<u64>,
658    },
659    /// Who holds a terminal's input, pushed to every viewer of it on a take
660    /// or a release.
661    TerminalControl {
662        seat: SessionSlot,
663        id: u16,
664        controller: Option<u64>,
665    },
666    /// Where the viewed tab's history sits: what the bar's arrows may do.
667    BrowserNavState {
668        id: u16,
669        tab: u16,
670        can_back: bool,
671        can_forward: bool,
672    },
673    /// The viewed tab's browser stopped.
674    ///
675    /// A host that died under a viewer is said out loud: a frozen last frame
676    /// reads as a slow page, and the person has no way to tell the two apart.
677    BrowserSurfaceFailed {
678        id: u16,
679        tab: u16,
680        reason: String,
681    },
682    /// The tabs of the viewer's profile, as a client's strip draws them.
683    ///
684    /// Pushed to a connection that asked with `browser_tabs`, once per change:
685    /// `up` is the host process, `connected` is a driver attached to it, and
686    /// `closing_at_ms` is the idle clock's deadline.
687    BrowserTabs {
688        id: u16,
689        profile: String,
690        up: bool,
691        connected: bool,
692        closing_at_ms: Option<u64>,
693        rows: Vec<forge_primitives::browser::BrowserTabRow>,
694    },
695    /// A page asked for a file: the shell opens its picker and answers
696    /// [`ClientMessage::BrowserFileAnswer`] under the same id. **`tab` is the
697    /// tab that asked**, so a viewer is asked only for the one it is on.
698    BrowserFileDialog {
699        id: u64,
700        tab: u16,
701        multiple: bool,
702    },
703    /// A page's download finished on the server: the shell opens its save
704    /// door and writes the bytes, or says `error` and writes nothing. `tab`
705    /// is the tab it started from.
706    BrowserDownload {
707        /// The tab the download started from. **Absent on a profile-level
708        /// notice**: the backlog carries every tab's offers, so the ones it
709        /// dropped may belong to any tab of the profile.
710        #[serde(default, skip_serializing_if = "Option::is_none")]
711        tab: Option<u16>,
712        name: String,
713        /// The file's bytes, base64; absent when `error` says why not.
714        data: Option<String>,
715        error: Option<String>,
716        /// Whether the offer was dropped before the viewer read it, rather
717        /// than a save that failed: the shell titles the two differently.
718        /// Additive and defaulted - an older client reads the loss as the
719        /// refusal it already draws.
720        #[serde(default)]
721        dropped: bool,
722        /// How many offers the backlog dropped. **The title says one loss or
723        /// many from it**, so it agrees with the count in the body. Additive
724        /// and absent on a save.
725        #[serde(default, skip_serializing_if = "Option::is_none")]
726        lost: Option<u64>,
727    },
728    Error {
729        what: String,
730        why: String,
731        /// The conversation the refusal belongs to, where it belongs to one: a
732        /// `more` for a seat the server could not answer names its seat, so a
733        /// client holding several seats' asks drains only its own. Optional and
734        /// additive - a client of either age reads it, and a refusal that names
735        /// no seat is read the way it always was.
736        seat: Option<SessionSlot>,
737        /// The dispatch a refused command answered by, echoed from what the
738        /// press minted: a client that holds a draft until its own dispatch
739        /// answers tells this refusal from any other by it.
740        dispatch_id: Option<u64>,
741    },
742    /// One document, as the read answered: the seat it was read for, the
743    /// path it resolved to, the bytes, the detected kind, the flag the
744    /// hand-off passed, the document's own hash, and the review the
745    /// document already carries.
746    ///
747    /// `session` is named back so a client holding two seats routes each
748    /// answer to its own viewer - the same reason a refusal names its seat.
749    /// `bytes` are base64: the ceiling makes a document up to a third
750    /// larger than its file, well inside the socket's own frame bound.
751    /// `document_kind` and not `kind`: a field named `kind` collides with
752    /// the envelope's own tag.
753    ///
754    /// `threads` and `reviews` are the document scope's own (`doc:<path>`)
755    /// store read, taken in the same handler: the viewer draws its comments,
756    /// and each thread carries where its anchor sits in THIS document - the
757    /// code review's re-anchoring, resolved here because only the server
758    /// holds the whole file's lines. A thread the revision left nowhere is
759    /// carried with an `outdated` landing rather than dropped.
760    /// `content_hash` crosses as DECIMAL DIGITS for the same reason a
761    /// thread's anchor hash does - JS numbers are exact only to 2^53.
762    DocumentRead {
763        session: SessionSlot,
764        path: String,
765        document_kind: DocumentKind,
766        bytes: String,
767        reviewable: bool,
768        content_hash: String,
769        threads: Vec<ReviewedThreadWire>,
770        reviews: Vec<forge_primitives::ReviewSet>,
771    },
772}
773
774/// One repaired frame: the number the seat minted for it, and the update
775/// itself in the same shape a live `update` carries.
776///
777/// **The same shape is the point.** A repaired frame folds through the same
778/// pipeline a live one does, so a client has one path for a conversation's
779/// frames rather than a second one for the frames it had to ask for.
780#[derive(Debug, Serialize, Deserialize)]
781pub struct SeatFrameWire {
782    pub seq: u64,
783    pub update: SessionUpdate,
784}
785
786/// What the client draws with, from `forge.toml` by way of the server.
787///
788/// The client never reads the config file: `[client] mark`, `[client] theme`
789/// and `[client] font` arrive here, and `None` on any of them means the
790/// built-in rather than a pinned value - a commented line in a hand-authored
791/// file has to mean "unset".
792#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(rename_all = "snake_case")]
794pub struct ClientSettings {
795    pub mark: Option<String>,
796    pub theme: Option<String>,
797    pub font: Option<String>,
798    /// The axes a client that captures starts on and resets to: the
799    /// `[dictate]` keys, over the crate's own defaults. A client that
800    /// captures sends the values in force with each take, so this is the
801    /// default it departs from rather than anything the server remembers.
802    pub dictate: DictateAxes,
803}
804
805impl ClientSettings {
806    /// The client's settings: the `[client]` keys off the config, and the
807    /// axes the workspace resolved.
808    pub fn new(client: &ClientConfig, dictate: DictateAxes) -> Self {
809        Self {
810            mark: client.mark.clone(),
811            theme: client.theme.clone(),
812            font: client.font.clone(),
813            dictate,
814        }
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    /// The pool's frame with an empty pool, which is all these routing tests
823    /// read of it.
824    fn accounts_changed() -> SessionUpdate {
825        SessionUpdate::AccountsChanged {
826            accounts: forge_workspace::AccountPoolView {
827                loading: Vec::new(),
828                all_loaded: false,
829                gateway: forge_workspace::AccountGatewayView {
830                    ready: false,
831                    port: 0,
832                    bind_error: None,
833                },
834                usage: Vec::new(),
835            },
836        }
837    }
838
839    /// **The pool's fields are inline, not nested, and every field the client
840    /// narrows crosses.** The client reads the frame's payload with the same
841    /// function it reads a home snapshot's `accounts` field with, so
842    /// `accounts_changed.loading` has to be the row list itself, and the
843    /// gateway, the usage rows and their names have to be the same shapes.
844    #[test]
845    fn the_pools_frame_carries_the_pool_inline() {
846        let mut update = accounts_changed();
847        let SessionUpdate::AccountsChanged { accounts } = &mut update else {
848            unreachable!("the helper builds this variant")
849        };
850        accounts.loading.push(forge_workspace::AccountLoadingRow {
851            display_name: "A".to_owned(),
852            state: forge_workspace::LoadingState::Ready,
853            last_error: None,
854            retry_after: Some(std::time::Duration::from_secs(30)),
855            auth: forge_primitives::account::AccountAuth::Token,
856            at_cap: true,
857            cooling: false,
858        });
859        accounts.all_loaded = true;
860        accounts.gateway.ready = true;
861        accounts.gateway.port = 8787;
862        accounts.gateway.bind_error = Some("port in use".to_owned());
863        accounts.usage.push(forge_workspace::AccountUsageView {
864            display_name: "A".to_owned(),
865            snapshot: None,
866        });
867
868        let json = serde_json::to_value(&update).expect("encode");
869        let body = json.get("accounts_changed").expect("the variant tag is the frame's key");
870        assert!(body.get("accounts").is_none(), "the payload is not nested twice: {body}");
871        assert_eq!(body["all_loaded"], true);
872        assert_eq!(body["gateway"]["port"].as_u64(), Some(8787), "the port crosses: {body}");
873        assert_eq!(body["gateway"]["bind_error"], "port in use");
874        assert_eq!(body["loading"][0]["display_name"], "A");
875        assert_eq!(body["loading"][0]["state"], "ready");
876        assert_eq!(body["loading"][0]["at_cap"], true, "the selection signal crosses");
877        assert_eq!(body["loading"][0]["cooling"], false);
878        assert_eq!(body["usage"][0]["display_name"], "A");
879        assert!(body["usage"][0]["snapshot"].is_null(), "an absent snapshot crosses as null");
880        let back: SessionUpdate = serde_json::from_value(json).expect("decode");
881        let SessionUpdate::AccountsChanged { accounts } = back else {
882            panic!("the frame decoded into another variant")
883        };
884        assert_eq!(accounts.loading.len(), 1, "the row survives the round trip");
885        assert_eq!(accounts.gateway.bind_error.as_deref(), Some("port in use"));
886        assert_eq!(accounts.usage.len(), 1);
887    }
888
889    /// **A file chooser and its answer name the same dialog.** The answer's
890    /// bytes cross base64 because the wire is JSON, and a client that lost
891    /// the id would hand the server files for a dialog it cannot match.
892    #[test]
893    fn a_browser_file_dialog_and_its_answer_round_trip() {
894        let ask = ServerMessage::BrowserFileDialog { id: 7, tab: 2, multiple: true };
895        let json = serde_json::to_string(&ask).expect("encode");
896        assert!(json.contains("\"kind\":\"browser_file_dialog\""), "{json}");
897        let ServerMessage::BrowserFileDialog { id, tab, multiple } =
898            serde_json::from_str(&json).expect("decode")
899        else {
900            panic!("{json} decoded into another message")
901        };
902        assert_eq!((id, multiple), (7, true));
903
904        let answer = ClientMessage::BrowserFileAnswer {
905            id: 7,
906            files: vec![BrowserFileWire { name: "cv.pdf".to_owned(), data: "AAEC".to_owned() }],
907        };
908        let json = serde_json::to_string(&answer).expect("encode");
909        assert!(json.contains("\"kind\":\"browser_file_answer\""), "{json}");
910        let ClientMessage::BrowserFileAnswer { id, files } =
911            serde_json::from_str(&json).expect("decode")
912        else {
913            panic!("{json} decoded into another message")
914        };
915        assert_eq!(id, 7);
916        assert_eq!(tab, 2, "the ask names the tab it belongs to");
917        assert_eq!(files[0].name, "cv.pdf");
918        assert_eq!(files[0].data, "AAEC", "the bytes cross base64");
919
920        // **A download carries either bytes or its refusal, never both.**
921        let saved = ServerMessage::BrowserDownload {
922            tab: Some(2),
923            name: "report.pdf".to_owned(),
924            data: Some("AAEC".to_owned()),
925            error: None,
926            dropped: false,
927            lost: None,
928        };
929        let json = serde_json::to_string(&saved).expect("encode");
930        assert!(json.contains("\"kind\":\"browser_download\""), "{json}");
931        let ServerMessage::BrowserDownload { tab, name, data, error, dropped, lost } =
932            serde_json::from_str(&json).expect("decode")
933        else {
934            panic!("{json} decoded into another message")
935        };
936        assert_eq!(tab, Some(2), "the save names the tab it started from");
937        assert_eq!(name, "report.pdf");
938        assert_eq!(data.as_deref(), Some("AAEC"));
939        assert!(error.is_none());
940        assert!(!dropped, "a save the person can take is not a dropped offer");
941        assert!(lost.is_none(), "and a save carries no loss count");
942
943        let refused = ServerMessage::BrowserDownload {
944            tab: Some(2),
945            name: "huge.bin".to_owned(),
946            data: None,
947            error: Some("this file is larger than 32 MiB".to_owned()),
948            dropped: false,
949            lost: None,
950        };
951        let json = serde_json::to_string(&refused).expect("encode");
952        let ServerMessage::BrowserDownload { data, error, dropped, .. } =
953            serde_json::from_str(&json).expect("decode")
954        else {
955            panic!("{json} decoded into another message")
956        };
957        assert!(data.is_none(), "a refusal carries no bytes");
958        assert!(error.is_some_and(|why| why.contains("32 MiB")));
959        assert!(!dropped, "a write that failed is not a dropped offer");
960
961        // **A drop carries the flag the shell reads**, under that key: the
962        // two samples above both say false, and a rename or a skip would
963        // leave the shell's title silently wrong. **It names no tab and
964        // carries its count**: the loss is the profile's, and the title is
965        // pluralized from `lost`.
966        let dropped = ServerMessage::BrowserDownload {
967            tab: None,
968            name: "download".to_owned(),
969            data: None,
970            error: Some("2 finished downloads were dropped".to_owned()),
971            dropped: true,
972            lost: Some(2),
973        };
974        let json = serde_json::to_string(&dropped).expect("encode");
975        assert!(json.contains("\"dropped\":true"), "the wire key the shell reads: {json}");
976        assert!(json.contains("\"lost\":2"), "and the count the title is read from: {json}");
977        assert!(!json.contains("\"tab\""), "a profile-level loss names no tab: {json}");
978        let ServerMessage::BrowserDownload { dropped, lost, tab, .. } =
979            serde_json::from_str(&json).expect("decode")
980        else {
981            panic!("{json} decoded into another message")
982        };
983        assert!(dropped, "and it round-trips as a drop");
984        assert_eq!(lost, Some(2), "with its count");
985        assert_eq!(tab, None, "and no tab");
986    }
987
988    /// **A viewer's attach, its state, and the input it sends.**
989    ///
990    /// The profile id is what pairs the state with the binary frames, and the
991    /// tab is which of them the viewer is on; the input carries the person's
992    /// gesture and its own sequence, never a CDP method - the server is what
993    /// maps one to the other.
994    #[test]
995    fn a_browser_view_and_its_input_round_trip() {
996        let attached = ClientMessage::BrowserView {
997            seat: SessionSlot::lead("TestOrg", "proj"),
998            tab: Some(3),
999            reply_to: Some(41),
1000        };
1001        let json = serde_json::to_string(&attached).expect("encode");
1002        assert!(json.contains("\"kind\":\"browser_view\""), "{json}");
1003        let ClientMessage::BrowserView { seat, tab, reply_to } =
1004            serde_json::from_str(&json).expect("decode")
1005        else {
1006            panic!("{json} decoded into another message")
1007        };
1008        assert_eq!(seat, SessionSlot::lead("TestOrg", "proj"), "the seat crosses");
1009        assert_eq!(tab, Some(3), "and the tab it named");
1010        assert_eq!(reply_to, Some(41), "and the answer the asker waits on");
1011
1012        let paged = ClientMessage::BrowserViewPage {
1013            seat: SessionSlot::lead("TestOrg", "proj"),
1014            target: "page-3".to_owned(),
1015            reply_to: Some(42),
1016        };
1017        let json = serde_json::to_string(&paged).expect("encode");
1018        assert!(json.contains("\"kind\":\"browser_view_page\""), "{json}");
1019        let ClientMessage::BrowserViewPage { seat, target, reply_to } =
1020            serde_json::from_str(&json).expect("decode")
1021        else {
1022            panic!("{json} decoded into another message")
1023        };
1024        assert_eq!(seat, SessionSlot::lead("TestOrg", "proj"), "the seat crosses");
1025        assert_eq!(target, "page-3", "and the page the hand-off named");
1026        assert_eq!(reply_to, Some(42), "and the answer the asker waits on");
1027
1028        let view = ServerMessage::BrowserView {
1029            id: 3,
1030            profile: "shared".to_owned(),
1031            tab: 2,
1032            controller: Some(9),
1033            viewers: 2,
1034            viewer: 9,
1035        };
1036        let json = serde_json::to_string(&view).expect("encode");
1037        assert!(json.contains("\"kind\":\"browser_view\""), "{json}");
1038        let ServerMessage::BrowserView { id, tab, controller, viewers, viewer, .. } =
1039            serde_json::from_str(&json).expect("decode")
1040        else {
1041            panic!("{json} decoded into another message")
1042        };
1043        assert_eq!(id, 3, "the id the frames carry");
1044        assert_eq!(tab, 2, "the tab the state is for");
1045        assert_eq!(controller, Some(9), "who holds the role");
1046        assert_eq!(viewers, 2, "and how many are attached");
1047        assert_eq!(viewer, 9, "and this connection's own number");
1048
1049        let input = ClientMessage::BrowserInput {
1050            seq: 4,
1051            event: forge_primitives::browser::BrowserInputEvent::Mouse {
1052                action: forge_primitives::browser::MouseAction::Press,
1053                x: 1.0,
1054                y: 2.0,
1055                button: 0,
1056                clicks: 1,
1057                modifiers: forge_primitives::browser::InputModifiers::default(),
1058            },
1059        };
1060        let json = serde_json::to_string(&input).expect("encode");
1061        assert!(json.contains("\"kind\":\"browser_input\""), "{json}");
1062        assert!(json.contains("\"action\":\"press\""), "the gesture crosses as itself: {json}");
1063        let ClientMessage::BrowserInput { seq, event } =
1064            serde_json::from_str(&json).expect("decode")
1065        else {
1066            panic!("{json} decoded into another message")
1067        };
1068        assert_eq!(seq, 4);
1069        assert!(matches!(event, forge_primitives::browser::BrowserInputEvent::Mouse { .. }));
1070
1071        let failed = ServerMessage::BrowserSurfaceFailed {
1072            id: 3,
1073            tab: 2,
1074            reason: "the browser host exited".to_owned(),
1075        };
1076        let json = serde_json::to_string(&failed).expect("encode");
1077        assert!(json.contains("\"kind\":\"browser_surface_failed\""), "{json}");
1078        assert!(json.contains("\"tab\":2"), "the failure names the tab that stopped: {json}");
1079    }
1080
1081    /// **The strip's whole read crosses as one list**, so a client draws the
1082    /// tabs, the host's health and the idle deadline from one frame rather
1083    /// than a read per field. The rows carry the owning session, which is what
1084    /// the strip groups by.
1085    #[test]
1086    fn a_browser_tabs_list_carries_its_rows_and_the_profile_state() {
1087        let asked = ClientMessage::BrowserTabs {
1088            seat: SessionSlot::lead("TestOrg", "proj"),
1089            reply_to: Some(7),
1090        };
1091        let json = serde_json::to_string(&asked).expect("encode");
1092        assert!(json.contains("\"kind\":\"browser_tabs\""), "{json}");
1093
1094        let list = ServerMessage::BrowserTabs {
1095            id: 4,
1096            profile: "TestOrg/default".to_owned(),
1097            up: true,
1098            connected: false,
1099            closing_at_ms: Some(1_760_000_000_000),
1100            rows: vec![forge_primitives::browser::BrowserTabRow {
1101                number: 2,
1102                title: "portal".to_owned(),
1103                url: "https://x/".to_owned(),
1104                owner: SessionSlot::worker("TestOrg", "proj", "w"),
1105                opener: None,
1106                state: forge_primitives::browser::BrowserTabState::InUse,
1107                dialog: Some(forge_primitives::browser::BrowserTabDialog {
1108                    kind: "confirm".to_owned(),
1109                    message: "Leave this page?".to_owned(),
1110                }),
1111            }],
1112        };
1113        let json = serde_json::to_string(&list).expect("encode");
1114        assert!(json.contains("\"kind\":\"browser_tabs\""), "{json}");
1115        assert!(
1116            json.contains("\"dialog\":{\"kind\":\"confirm\""),
1117            "the dialog crosses with its kind: {json}"
1118        );
1119        let ServerMessage::BrowserTabs { id, up, connected, closing_at_ms, rows, .. } =
1120            serde_json::from_str(&json).expect("decode")
1121        else {
1122            panic!("{json} decoded into another message")
1123        };
1124        assert_eq!(id, 4, "the id the binary frames carry");
1125        assert!(up && !connected, "the host and the driver are two facts");
1126        assert_eq!(closing_at_ms, Some(1_760_000_000_000));
1127        assert_eq!(rows.len(), 1, "and the rows the strip draws");
1128        assert_eq!(rows[0].owner, SessionSlot::worker("TestOrg", "proj", "w"));
1129    }
1130
1131    /// A subscribe that says nothing about answering reads as one that cannot
1132    /// answer: the core parks a turn on an answering client, so the default
1133    /// has to be the safe one.
1134    #[test]
1135    fn a_subscribe_defaults_to_not_answering() {
1136        let older: ClientMessage = serde_json::from_str(r#"{"kind":"subscribe","what":"home"}"#)
1137            .expect("a bare subscribe decodes");
1138        let ClientMessage::Subscribe { answering, .. } = older else {
1139            panic!("a bare subscribe is still a subscribe")
1140        };
1141        assert!(!answering, "a client that says nothing cannot answer");
1142    }
1143
1144    /// **A page carries the schedules its fired prompts came from.** The
1145    /// delivery frame names a schedule once; a row replayed from a transcript
1146    /// afterwards has only the read, so the pairing crosses here - typed, and
1147    /// keyed by the prompt's own id (#1815).
1148    #[test]
1149    fn a_page_carries_the_cron_pairing_it_serves() {
1150        let seat = SessionSlot::lead("TestOrg", "proj");
1151        let page = ServerMessage::Page {
1152            conversation: seat,
1153            turns: vec![crate::transport::wire::TurnWire {
1154                key: Some("t1".to_owned()),
1155                messages: vec![serde_json::json!({ "type": "user", "uuid": "p-1" })],
1156            }],
1157            cursor: None,
1158            cron_names: std::collections::BTreeMap::from([(
1159                "p-1".to_owned(),
1160                forge_primitives::cron::CronName {
1161                    cron_id: "c1".to_owned(),
1162                    description: Some("Morning summary".to_owned()),
1163                },
1164            )]),
1165        };
1166        let json = serde_json::to_string(&page).expect("encode");
1167        assert!(json.contains("\"cron_names\":{\"p-1\":{\"cron_id\":\"c1\""), "{json}");
1168        let ServerMessage::Page { cron_names, .. } = serde_json::from_str(&json).expect("decode")
1169        else {
1170            panic!("{json} decoded into another message")
1171        };
1172        assert_eq!(
1173            cron_names.get("p-1").and_then(|name| name.description.as_deref()),
1174            Some("Morning summary"),
1175            "the schedule a reloaded row titles itself by",
1176        );
1177    }
1178
1179    /// The document read a viewer's route makes, and the answer it gets.
1180    ///
1181    /// The request names the seat whose tree the path resolves against; the
1182    /// answer names that seat BACK, so a client holding two seats routes
1183    /// each answer to its own viewer rather than to whichever asked last.
1184    #[test]
1185    fn a_document_read_request_and_its_answer_round_trip_through_json() {
1186        let seat = SessionSlot::lead("TestOrg", "proj");
1187        let asked = ClientMessage::DocumentRead {
1188            session: seat.clone(),
1189            path: "docs/readme.md".to_owned(),
1190            reviewable: true,
1191        };
1192        let json = serde_json::to_string(&asked).expect("encode");
1193        assert!(json.contains("\"kind\":\"document_read\""), "{json}");
1194        let ClientMessage::DocumentRead { session, path, reviewable } =
1195            serde_json::from_str(&json).expect("decode")
1196        else {
1197            panic!("{json} decoded into another message")
1198        };
1199        assert_eq!(session, seat, "the seat whose tree the path resolves against");
1200        assert_eq!(path, "docs/readme.md");
1201        assert!(reviewable, "the reviewable flag the request carried");
1202
1203        let answer = ServerMessage::DocumentRead {
1204            session: seat.clone(),
1205            path: "/Users/me/proj/docs/readme.md".to_owned(),
1206            document_kind: DocumentKind::Markdown,
1207            bytes: "aGk=".to_owned(),
1208            reviewable: true,
1209            content_hash: "18446744073709551615".to_owned(),
1210            threads: Vec::new(),
1211            reviews: Vec::new(),
1212        };
1213        let json = serde_json::to_string(&answer).expect("encode");
1214        assert!(json.contains("\"kind\":\"document_read\""), "{json}");
1215        assert!(
1216            json.contains("\"content_hash\":\"18446744073709551615\""),
1217            "the document's hash crosses as decimal digits, as a thread's anchor does: {json}",
1218        );
1219        let ServerMessage::DocumentRead {
1220            session,
1221            path,
1222            document_kind,
1223            bytes,
1224            reviewable,
1225            content_hash,
1226            threads,
1227            reviews,
1228        } = serde_json::from_str(&json).expect("decode")
1229        else {
1230            panic!("{json} decoded into another message")
1231        };
1232        assert_eq!(session, seat, "the answer names its seat back");
1233        assert_eq!(path, "/Users/me/proj/docs/readme.md", "the resolved absolute path");
1234        assert_eq!(document_kind, DocumentKind::Markdown, "the kind the read detected");
1235        assert_eq!(bytes, "aGk=", "the document's bytes, base64, as they cross");
1236        assert!(reviewable);
1237        assert_eq!(content_hash, "18446744073709551615", "the hash survives the round trip");
1238        assert!(threads.is_empty(), "an uncommented document carries no threads");
1239        assert!(reviews.is_empty(), "and no review sets");
1240    }
1241
1242    /// **A page's perf batch crosses as its own message**, with the records
1243    /// exactly as the page serialised them: the server writes them, it does
1244    /// not re-shape them, so a field the page learned reaches the file.
1245    #[test]
1246    fn a_client_perf_batch_round_trips_with_its_records() {
1247        let batch = ClientMessage::ClientPerf {
1248            records: vec![serde_json::json!({
1249                "schema": "forge-perf/v1",
1250                "kind": "frame",
1251                "run_id": "r",
1252                "ts_ms": 3,
1253                "ms": 120.5,
1254            })],
1255        };
1256        let json = serde_json::to_string(&batch).expect("encode");
1257        assert!(json.contains("\"kind\":\"client_perf\""), "{json}");
1258        let ClientMessage::ClientPerf { records } = serde_json::from_str(&json).expect("decode")
1259        else {
1260            panic!("{json} decoded into another message")
1261        };
1262        assert_eq!(records.len(), 1);
1263        assert_eq!(records[0]["kind"], "frame", "the page's own shape survives");
1264        assert_eq!(records[0]["ms"], 120.5);
1265    }
1266
1267    #[test]
1268    fn a_subscribe_round_trips_through_json() {
1269        let sent = ClientMessage::Subscribe { what: Subject::Home, answering: true, review: false };
1270        let json = serde_json::to_string(&sent).expect("encode");
1271        assert!(json.contains("\"kind\":\"subscribe\""), "{json}");
1272
1273        let back: ClientMessage = serde_json::from_str(&json).expect("decode");
1274        let ClientMessage::Subscribe { what, answering, .. } = back else {
1275            panic!("{json} decoded into another message");
1276        };
1277        assert_eq!(what, Subject::Home, "the subject survives the round trip");
1278        assert!(answering, "and so does the capability the client declared");
1279    }
1280
1281    /// **The exact frames Dock.svelte writes for a vault answer decode.** The
1282    /// fixture is the same file the client test asserts the dock writes, so
1283    /// neither side can drift into a different nesting on its own.
1284    #[test]
1285    fn the_docks_vault_answer_frames_decode() {
1286        use forge_primitives::vault::VaultAskDecision;
1287
1288        let frames: serde_json::Value = serde_json::from_str(include_str!(
1289            "../../../../client/src/composer/vault-answer-frames.json"
1290        ))
1291        .expect("the shared frames are json");
1292
1293        for name in ["approve", "send_back"] {
1294            let frame = frames.get(name).expect("the shared frame exists");
1295            let json = serde_json::json!({
1296                "kind": "command",
1297                "command": frame,
1298                "reply_to": null,
1299            })
1300            .to_string();
1301            let ClientMessage::Command { command, reply_to } =
1302                serde_json::from_str(&json).expect("the dock's answer decodes")
1303            else {
1304                panic!("the dock's answer decoded into another message");
1305            };
1306            assert!(reply_to.is_none());
1307            let Command::RespondVaultAsk { answer, .. } = *command else {
1308                panic!("the dock's answer carries another command");
1309            };
1310            match name {
1311                "approve" => {
1312                    assert_eq!(answer.decision, VaultAskDecision::Approve);
1313                    assert_eq!(answer.master_password.as_deref(), Some("open sesame"));
1314                    assert_eq!(answer.two_factor.as_deref(), Some("123456"));
1315                }
1316                _ => assert!(
1317                    matches!(
1318                        &answer.decision,
1319                        VaultAskDecision::SendBack { note }
1320                            if note == "ask for the password, not the TOTP"
1321                    ),
1322                    "the send-back variant survives: {:?}",
1323                    answer.decision,
1324                ),
1325            }
1326        }
1327    }
1328
1329    /// The models subject hears its own landing and the load's signal -
1330    /// the only push that says the models finished loading - and nothing
1331    /// else. It is the whole live half of that page: a subscriber that
1332    /// never hears a landing reads as quiet, not as broken.
1333    #[test]
1334    fn the_models_subject_hears_the_landing_and_the_load() {
1335        let landing = SessionUpdate::DictateModelsChanged {
1336            models: forge_workspace::catalogue::DictateModelsSnapshot {
1337                enabled: true,
1338                models_dir: None,
1339                speaking: forge_workspace::TtsView {
1340                    enabled: false,
1341                    snapshot: forge_workspace::TtsSnapshot::default(),
1342                    models_dir: None,
1343                    speaking_model: forge_workspace::tts::SPEAKING_MODEL.to_owned(),
1344                    active: forge_workspace::tts::SpeakingVariantView {
1345                        variant: forge_workspace::tts::PINNED_VARIANT.to_owned(),
1346                        file: "model_quantized.onnx".to_owned(),
1347                        quant: "int8".to_owned(),
1348                        size: 0,
1349                        installed: false,
1350                        active: true,
1351                    },
1352                    from: forge_workspace::tts::SpeakingFrom::Pin,
1353                    variants: Vec::new(),
1354                    voices: Vec::new(),
1355                    voice: None,
1356                    preview_error: None,
1357                },
1358                in_use: Vec::new(),
1359                check: forge_workspace::catalogue::CatalogueCheck::Never,
1360                updates: Vec::new(),
1361                rows: Vec::new(),
1362                install: forge_workspace::install::InstallState::Idle,
1363                activate: forge_workspace::install::ActivateState::Idle,
1364                installed: Vec::new(),
1365                bench: forge_workspace::bench::BenchState::Idle,
1366                results: Vec::new(),
1367                speaking_bench: forge_workspace::bench::SpeakingBenchState::Idle,
1368                speaking_results: Vec::new(),
1369                read_aloud: forge_workspace::bench::ReadAloudState {
1370                    recordings: Vec::new(),
1371                    recording: false,
1372                    error: None,
1373                    passage: String::new(),
1374                    terms: Vec::new(),
1375                },
1376            },
1377        };
1378
1379        assert!(
1380            Subject::DictateModels.covers(&landing),
1381            "the check's landing is the subject's whole live half",
1382        );
1383        assert!(
1384            Subject::DictateModels.covers(&SessionUpdate::DictateAvailability),
1385            "and the preflight's signal, so a page open during a first download re-reads its \
1386             state chips instead of drawing `pending` until the next check",
1387        );
1388        assert!(
1389            !Subject::DictateModels.covers(&accounts_changed()),
1390            "another machine-level update is not the models page's to redraw",
1391        );
1392        assert!(
1393            Subject::Home.covers(&landing),
1394            "and the slot-less arm carries it home, which is what the client's own census \
1395             classifies it against",
1396        );
1397    }
1398
1399    /// The pool belongs to no seat, so a home subscription hears it and a
1400    /// seat's does not.
1401    ///
1402    /// **Both home arms carry it, and the slot-less one is the load-bearing
1403    /// assertion here.** `fleet_news` classifies it a redraw because the
1404    /// band's own card is part of the region the web home re-sends; taking it
1405    /// out of that arm would leave this test green, because the slot-less arm
1406    /// below delivers it anyway. What the test can fail on is the variant
1407    /// gaining a seat: a key would send the pool to one session's page and
1408    /// leave every other subscriber drawing a card that never moves.
1409    #[test]
1410    fn the_pools_announcement_reaches_home_and_no_seat() {
1411        let update = accounts_changed();
1412        let seat = SessionSlot::lead("TestOrg", "proj");
1413
1414        assert!(update.slot().is_none(), "the pool addresses no seat, which is what routes it");
1415        assert!(Subject::Home.covers(&update), "home is the only subscription that could carry it");
1416        assert!(
1417            !Subject::Session(seat).covers(&update),
1418            "a seat's subscription is a seat, and the pool is not one",
1419        );
1420    }
1421
1422    /// The output read reaches the seat that asked and not the home: it is a
1423    /// row's own content, and no home row draws a word of conversation.
1424    ///
1425    /// **The keyed pair is what this fails on.** A `CallOutput` that lost its
1426    /// slot would be taken by the home's slot-less arm - sending one seat's
1427    /// tail to every fleet subscriber - so `Session(covers)` and
1428    /// `Home(does not)` are asserted together.
1429    #[test]
1430    fn the_output_read_reaches_its_seat_and_not_the_home() {
1431        let seat = SessionSlot::lead("TestOrg", "proj");
1432        let update = SessionUpdate::CallOutput {
1433            key: seat.clone(),
1434            call_id: "tu-1".to_owned(),
1435            output: forge_primitives::CallOutput::NoPath,
1436        };
1437
1438        assert!(
1439            Subject::Session(seat.clone()).covers(&update),
1440            "the seat that asked is sent its own answer",
1441        );
1442        assert!(
1443            !Subject::Home.covers(&update),
1444            "and the home is not: no row draws a conversation's words, and the slot-less arm \
1445             would take it if the key went",
1446        );
1447    }
1448
1449    /// **The Extensions page hears the whole `Plugins` family and nothing
1450    /// else.** A variant dropped from the arm leaves the page drawing a run
1451    /// that ended, or an inventory an action already moved, with no way to
1452    /// tell it has fallen behind; a variant added to the arm would hand the
1453    /// page a seat's own news.
1454    #[test]
1455    fn extensions_covers_the_plugins_family_and_no_seat_news() {
1456        for update in [
1457            SessionUpdate::PluginsInventoryUpdated {
1458                cwd_raw: "/tmp/proj".to_owned(),
1459                snapshot: forge_primitives::plugins::PluginsInventorySnapshot::default(),
1460                claude_path: std::path::PathBuf::from("claude"),
1461            },
1462            SessionUpdate::PluginsInventoryRefreshFailed {
1463                cwd_raw: "/tmp/proj".to_owned(),
1464                message: "no".to_owned(),
1465                trigger: forge_primitives::plugins::PluginUpdateTrigger::Manual,
1466            },
1467            SessionUpdate::PluginsCliActionSucceeded {
1468                cwd_raw: "/tmp/proj".to_owned(),
1469                result: forge_primitives::plugins::PluginsCliActionSuccess {
1470                    snapshot: forge_primitives::plugins::PluginsInventorySnapshot::default(),
1471                    message: "done".to_owned(),
1472                    claude_path: std::path::PathBuf::from("claude"),
1473                },
1474                dispatch_id: Some(1),
1475            },
1476            SessionUpdate::PluginsCliActionFailed {
1477                cwd_raw: "/tmp/proj".to_owned(),
1478                message: "no".to_owned(),
1479                dispatch_id: None,
1480            },
1481            SessionUpdate::PluginsUpdateRunProgress {
1482                cwd_raw: "/tmp/proj".to_owned(),
1483                run: forge_primitives::plugins::PluginUpdateRun {
1484                    trigger: forge_primitives::plugins::PluginUpdateTrigger::Manual,
1485                    finished: false,
1486                    rows: Vec::new(),
1487                },
1488            },
1489            SessionUpdate::PluginsUpdateRunFinished {
1490                cwd_raw: "/tmp/proj".to_owned(),
1491                run: forge_primitives::plugins::PluginUpdateRun {
1492                    trigger: forge_primitives::plugins::PluginUpdateTrigger::Manual,
1493                    finished: true,
1494                    rows: Vec::new(),
1495                },
1496                snapshot: None,
1497                claude_path: None,
1498            },
1499            SessionUpdate::PluginsRollbackSucceeded {
1500                cwd_raw: "/tmp/proj".to_owned(),
1501                plugin_id: "a@probe".to_owned(),
1502                scope: "user".to_owned(),
1503                message: "done".to_owned(),
1504                snapshot: forge_primitives::plugins::PluginsInventorySnapshot::default(),
1505                claude_path: std::path::PathBuf::from("claude"),
1506            },
1507            SessionUpdate::PluginsRollbackFailed {
1508                cwd_raw: "/tmp/proj".to_owned(),
1509                plugin_id: "a@probe".to_owned(),
1510                message: "no".to_owned(),
1511                snapshot: None,
1512            },
1513        ] {
1514            assert!(
1515                Subject::Extensions.covers(&update),
1516                "the Extensions page must hear {update:?}; a dropped arm leaves it stale",
1517            );
1518        }
1519
1520        let seat = SessionSlot::lead("TestOrg", "proj");
1521        let session_news = SessionUpdate::TurnComplete { key: seat.clone(), terminal_reason: None };
1522        assert!(
1523            !Subject::Extensions.covers(&session_news),
1524            "a seat's own news is not the Extensions page's",
1525        );
1526        assert!(
1527            !Subject::Extensions.covers(&SessionUpdate::CatalogLoaded),
1528            "and neither is the catalog's",
1529        );
1530    }
1531
1532    /// A parked draft is home news on its own: the seat's row moves into the
1533    /// needs-you group while the draft is held and back out when it resolves,
1534    /// so a home-ONLY subscriber that was never sent the pair reads the fleet
1535    /// as it stood before the draft - exactly the symptom #1758 was filed
1536    /// for, with no session page open to forward it.
1537    #[test]
1538    fn the_slack_draft_pair_reaches_home_and_its_seat() {
1539        let seat = SessionSlot::lead("TestOrg", "proj");
1540        for update in [
1541            SessionUpdate::SlackPostPending {
1542                key: seat.clone(),
1543                draft: forge_primitives::slack::SlackDraft {
1544                    id: uuid::Uuid::new_v4(),
1545                    workspace: "acme".to_owned(),
1546                    conversation: "C1".to_owned(),
1547                    conversation_label: "acme".to_owned(),
1548                    thread_ts: None,
1549                    text: "hello".to_owned(),
1550                    operation: forge_primitives::slack::SlackDraftOperation::Post,
1551                    thread: None,
1552                    tagged: Vec::new(),
1553                },
1554            },
1555            SessionUpdate::SlackDraftResolved {
1556                key: seat.clone(),
1557                id: uuid::Uuid::new_v4(),
1558                ending: forge_primitives::slack::SlackDraftEnding::Abandoned,
1559            },
1560        ] {
1561            assert!(
1562                fleet_news(&update).any(),
1563                "admitted by the redraw arm, not by the wildcard: {update:?}",
1564            );
1565            assert!(Subject::Home.covers(&update), "so the home is sent it: {update:?}");
1566            assert!(
1567                Subject::Session(seat.clone()).covers(&update),
1568                "and the seat it routes on is sent it too: {update:?}",
1569            );
1570        }
1571    }
1572
1573    /// A document hand-off has to reach the client wherever the reader is:
1574    /// the ask is drawn on the raise, and the home subscription is the one
1575    /// every client holds. A reader sitting on the home never heard the
1576    /// seat-keyed raise until the redraw arm carried it (measured in the
1577    /// e2e, 2026-10-12), so this pins the arm by name - dropping it leaves
1578    /// the home silent and the ask undrawn.
1579    #[test]
1580    fn a_document_hand_off_reaches_home_and_its_seat() {
1581        let seat = SessionSlot::lead("TestOrg", "proj");
1582        for update in [
1583            SessionUpdate::DocumentHandOffPending {
1584                key: seat.clone(),
1585                id: uuid::Uuid::new_v4(),
1586                path: "/proj/docs/plan.md".to_owned(),
1587                reviewable: true,
1588            },
1589            SessionUpdate::DocumentHandOffResolved {
1590                key: seat.clone(),
1591                id: uuid::Uuid::new_v4(),
1592                ending: forge_primitives::document::DocumentHandOffEnding::NotNow,
1593            },
1594        ] {
1595            assert!(
1596                fleet_news(&update).any(),
1597                "admitted by the redraw arm, not by the wildcard: {update:?}",
1598            );
1599            assert!(Subject::Home.covers(&update), "so the home is sent it: {update:?}");
1600            assert!(
1601                Subject::Session(seat.clone()).covers(&update),
1602                "and the seat it routes on is sent it too: {update:?}",
1603            );
1604        }
1605    }
1606
1607    /// The section pushes reach the BOARD, and the task push reaches the home
1608    /// too (#2517).
1609    ///
1610    /// **The home keeps `TasksChanged` because its snapshot still carries it**:
1611    /// the fleet row's queue and waiting counts are derived from the same
1612    /// tasks. Crons and connector sets left the home's payload with the rows,
1613    /// so the home must not hear them (rule 25); the board snapshot carries
1614    /// all three.
1615    #[test]
1616    fn the_section_pushes_reach_the_board_and_tasks_also_home() {
1617        let tasks = SessionUpdate::TasksChanged {
1618            key: SessionSlot::lead("TestOrg", "proj"),
1619            tasks: Vec::new(),
1620        };
1621        assert!(
1622            fleet_news(&tasks).any(),
1623            "admitted by the redraw arm, not by the wildcard: {tasks:?}",
1624        );
1625        assert!(Subject::Board.covers(&tasks), "the board draws every row: {tasks:?}");
1626        assert!(
1627            Subject::Home.covers(&tasks),
1628            "and the home's fleet counts are derived from the same tasks: {tasks:?}",
1629        );
1630
1631        for update in [
1632            SessionUpdate::CronSchedulesChanged {
1633                key: SessionSlot::lead("TestOrg", "proj"),
1634                crons: Vec::new(),
1635            },
1636            SessionUpdate::ConnectorSubscriptionsChanged {
1637                key: SessionSlot::lead("TestOrg", "proj"),
1638                gotify: Vec::new(),
1639                slack: Vec::new(),
1640            },
1641        ] {
1642            assert!(Subject::Board.covers(&update), "the board draws both: {update:?}");
1643            assert!(
1644                !Subject::Home.covers(&update),
1645                "the home's snapshot carries neither, so it must not be asked to \
1646                 re-read for it: {update:?}",
1647            );
1648        }
1649
1650        assert!(
1651            !Subject::Board.covers(&accounts_changed()),
1652            "and nothing outside the board's own three reaches it",
1653        );
1654    }
1655
1656    /// **A board row's `stale` mark is read from seat liveness**, so the
1657    /// frames that move the registries behind `seat_is_live` reach the board -
1658    /// and a turn frame does not (#2517).
1659    #[test]
1660    fn the_seat_liveness_frames_reach_the_board_and_a_turn_frame_does_not() {
1661        let seat = SessionSlot::lead("TestOrg", "proj");
1662        let model = || forge_primitives::CurrentModel {
1663            resolved_id: "claude".to_owned(),
1664            display_name_short: "claude".to_owned(),
1665            display_name_long: "claude".to_owned(),
1666            requested_id: None,
1667            catalog_id: None,
1668            supports_effort: false,
1669            supported_effort_levels: Vec::new(),
1670            supports_auto_mode: None,
1671            supports_adaptive_thinking: None,
1672            is_authoritative: true,
1673        };
1674        for update in [
1675            SessionUpdate::Spawning {
1676                key: seat.clone(),
1677                project_name: "proj".to_owned(),
1678                cwd: "/tmp".to_owned(),
1679                display_name: "lead".to_owned(),
1680            },
1681            SessionUpdate::Connected {
1682                key: seat.clone(),
1683                session_id: forge_primitives::SessionId::new("s"),
1684                cwd: "/tmp".to_owned(),
1685                current_model: model(),
1686                available_models: Vec::new(),
1687                mode: None,
1688                history: Vec::new(),
1689                dropped: 0,
1690                compaction_count: 0,
1691            },
1692            SessionUpdate::SessionReplaced {
1693                key: seat.clone(),
1694                session_id: forge_primitives::SessionId::new("s"),
1695                cwd: "/tmp".to_owned(),
1696                current_model: model(),
1697                available_models: Vec::new(),
1698                mode: None,
1699                history: Vec::new(),
1700                dropped: 0,
1701                compaction_count: 0,
1702            },
1703            SessionUpdate::Releasing { key: seat.clone() },
1704            SessionUpdate::ConnectionFailed {
1705                key: seat.clone(),
1706                message: "gone".to_owned(),
1707                fatal: false,
1708            },
1709            SessionUpdate::WorkerStatusChanged {
1710                project_key: forge_workspace::ProjectKey::new("TestOrg-proj"),
1711                action: forge_workspace::protocol::WorkerStatusAction::Removed,
1712                status: forge_primitives::WorkerStatus {
1713                    label: "w1".to_owned(),
1714                    charter: "a worker".to_owned(),
1715                    status: forge_primitives::WorkerLiveness::Running,
1716                    session_id: "s".to_owned(),
1717                    slot: seat.clone(),
1718                    spawned_at: std::time::SystemTime::UNIX_EPOCH,
1719                    spawned_by: seat.clone(),
1720                    diagnostic: None,
1721                    heal_attempt: None,
1722                    heal_next_at: None,
1723                    activity: None,
1724                },
1725                worktree: forge_workspace::protocol::WorktreeDisposition::Absent,
1726            },
1727        ] {
1728            assert!(
1729                Subject::Board.covers(&update),
1730                "a row's owner-gone chip is read from these: {update:?}",
1731            );
1732        }
1733
1734        assert!(
1735            !Subject::Board
1736                .covers(&SessionUpdate::TurnComplete { key: seat, terminal_reason: None }),
1737            "a turn frame moves no board row's marks",
1738        );
1739    }
1740
1741    /// The client's settings come off the server's own config, so the
1742    /// client never reads `forge.toml` and the two cannot drift - the
1743    /// `[client]` keys off it directly, and the `[dictate]` axes as the
1744    /// workspace resolved them.
1745    #[test]
1746    fn the_client_settings_are_the_configs_view_settings() {
1747        let config = ClientConfig {
1748            mark: Some("strike".to_owned()),
1749            theme: Some("dark".to_owned()),
1750            font: Some("system".to_owned()),
1751        };
1752        let axes = DictateAxes {
1753            styling: forge_dictate::normalize::Styling::Formal,
1754            structure: forge_dictate::normalize::Structure::Lists,
1755            context: forge_dictate::normalize::Context::Email,
1756        };
1757
1758        let settings = ClientSettings::new(&config, axes);
1759
1760        assert_eq!(settings.mark.as_deref(), Some("strike"));
1761        assert_eq!(settings.theme.as_deref(), Some("dark"));
1762        assert_eq!(settings.font.as_deref(), Some("system"));
1763        assert_eq!(settings.dictate, axes, "and the axes a capturing client starts on");
1764    }
1765
1766    /// **The page's own JSON answers a hand-off.** The client sends its answer
1767    /// as `{ ending: { type }, source }`, and a shape serde accepts from Rust
1768    /// but not from the page would answer nothing on a live socket - which is
1769    /// a decline the person never gave, or worse, a park that never ends.
1770    #[test]
1771    fn a_hand_off_answer_decodes_from_the_clients_own_json() {
1772        let sent = serde_json::json!({
1773            "kind": "command",
1774            "command": {
1775                "respond_browser_hand_off": {
1776                    "key": { "org": "TestOrg", "project": "proj", "label": "lead" },
1777                    "id": "0192e1c0-0000-7000-8000-0000000000aa",
1778                    "answer": { "ending": { "type": "feedback" }, "source": "feedback" },
1779                },
1780            },
1781            "reply_to": null,
1782        });
1783
1784        let ClientMessage::Command { command, .. } =
1785            serde_json::from_value::<ClientMessage>(sent).expect("the client's own shape decodes")
1786        else {
1787            panic!("a command frame is a command");
1788        };
1789        let Command::RespondBrowserHandOff { answer, .. } = *command else {
1790            panic!("the answer is the hand-off's");
1791        };
1792        assert_eq!(
1793            answer.ending,
1794            forge_primitives::browser::HandOffEnding::Feedback,
1795            "the person spoke",
1796        );
1797        assert_eq!(
1798            answer.source,
1799            forge_primitives::browser::HandOffSource::Feedback,
1800            "and the door that sent it is named",
1801        );
1802        assert_eq!(answer.client, None, "a client cannot know its own connection");
1803    }
1804}