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}