Skip to main content

forge_server/transport/
wire.rs

1//! A subject's wire form, and the one place it is decided.
2//!
3//! The server hands a view the surface's own values, and a client reads a
4//! shape built for it here rather than the core's structs. A twin per
5//! subject keeps a wire change in one file instead of in a handler and a
6//! test, and the fixtures beside this module are what make a changed shape
7//! fail a test rather than a client.
8//!
9//! **The twins are complete.** A field is carried unless it is a Rust
10//! mechanism rather than data - a `oneshot`, an `Arc<Workspace>` - because
11//! the client is not today's pages: it is built against this wire, and a
12//! field missing here cannot be added later without a server change.
13
14use std::collections::{BTreeMap, HashMap};
15use std::path::Path;
16use std::sync::Arc;
17
18use anyhow::Result;
19use forge_primitives::git_diff::LayerState;
20use forge_primitives::plugins::{PluginUpdateRun, PluginsInventorySnapshot};
21use forge_primitives::review::{ReviewAnchor, ReviewSet, ReviewSide, ReviewThread};
22use forge_primitives::runtime::{AvailableAgent, AvailableCommand, MonitorRecord};
23use forge_primitives::slack::SlackSubscription;
24use forge_primitives::{ContentBlock, GotifySubscription, Message, SessionSlot, UserEnvelope};
25use forge_workspace::env::git_diff::content::{DiffContent, FileContent, scan as scan_content};
26use forge_workspace::env::git_diff::file::{merge_base, read_file_lines};
27use forge_workspace::env::git_diff::resolver::{
28    AnchorPlacement, OutdatedReason, place_anchor_in_content, place_anchor_in_lines_with,
29};
30use forge_workspace::env::processes::ProcessSnapshot;
31use forge_workspace::{McpServers, ProjectView};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35use crate::file_index::FileIndex;
36use crate::surface::{AgentRow, PendingAsk, ViewSurface};
37use crate::transcript::{Rendered, TaskEnding, TurnSpan};
38use crate::transport::TransportState;
39use crate::transport::conversation::{Conversation, Held};
40use crate::transport::envelope::Subject;
41use crate::work::{WorkState, git_work_view, work_from_scan};
42
43/// The server run's own identity, minted once.
44///
45/// **A client's ask marks outlive a forge restart while the counter does
46/// not**, so the roster carries this and every mark is scoped by it.
47fn boot_id() -> &'static str {
48    static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
49    BOOT.get_or_init(|| {
50        let nanos = std::time::SystemTime::now()
51            .duration_since(std::time::UNIX_EPOCH)
52            .map_or(0, |held| held.as_nanos());
53        format!("{}-{nanos}", std::process::id())
54    })
55}
56
57/// The seat the fixture surface is populated for.
58///
59/// One seat is enough: a fixture pins the SHAPE of a snapshot, and a second
60/// seat would pin the same shape twice.
61pub fn fixture_seat() -> SessionSlot {
62    SessionSlot::lead("TestOrg", "proj")
63}
64
65/// One of the surface's two independent review reads: the records, or why
66/// they could not be read. They fail apart, so neither is collapsed into
67/// the other, and a client can tell an empty branch from an unreadable one.
68#[derive(Serialize, Deserialize)]
69#[serde(tag = "outcome", content = "value", rename_all = "snake_case")]
70pub enum ReadWire<T> {
71    Read(T),
72    Failed(String),
73}
74
75impl<T> From<std::result::Result<T, String>> for ReadWire<T> {
76    fn from(read: std::result::Result<T, String>) -> Self {
77        match read {
78            Ok(records) => Self::Read(records),
79            Err(why) => Self::Failed(why),
80        }
81    }
82}
83
84/// The home, as a client sees it: every read a home-scoped view makes.
85///
86/// The last two are the App-level state that reaches a home subscriber as
87/// an update and nothing else - a subject's snapshot has to cover every
88/// update that subject receives, or a late subscriber sees a smaller world
89/// than one that attached at boot.
90#[derive(Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub struct HomeWire {
93    pub projects: Vec<ProjectWire>,
94    pub agents: Vec<AgentWire>,
95    /// One row per project for the fleet page: the counts and the named
96    /// misses (a stalled queue, a worker holding no row). A project's own
97    /// rows ride [`ProjectBoardWire::rows`]; this is the glance.
98    pub fleet: Vec<forge_workspace::board::FleetRow>,
99    /// The seats whose last turn finished while no client was showing them.
100    ///
101    /// A view draws a mark per row from this, and it is the fact a
102    /// viewer cannot reconstruct: a turn that ended before a client attached
103    /// leaves nothing in the transcript to say it went unwatched, so a client
104    /// reading only the records would draw every row as settled.
105    pub unseen: Vec<SessionSlot>,
106    /// The pool as both the snapshot and `AccountsChanged` state it; see
107    /// [`forge_workspace::AccountPoolView`].
108    pub accounts: forge_workspace::AccountPoolView,
109    pub connectors: ConnectorsWire,
110    pub dictate: DictateWire,
111    /// The speaking role's switch and model directory: the machine-wide
112    /// facts a voice control reads before it names a seat.
113    pub voice: VoiceWire,
114    /// The claude CLI versions the core holds, as the core's own snapshot
115    /// serialises. Its crate is one this one may not name.
116    pub cli_version: Option<Value>,
117    /// The forge build serving this socket: the full stamp and the short one.
118    ///
119    /// **A client draws the short one as its `server` build, beside its own
120    /// labelled `client` build.** The header states which forge commit is
121    /// RUNNING, and the client is a different program with a version of its
122    /// own, so the two are named apart rather than one standing for both.
123    pub forge_version: String,
124    pub forge_version_short: String,
125    /// The server run's own identity, minted once per process.
126    ///
127    /// **An ask's number restarts with the process**, so a client that kept
128    /// its marks across a forge restart would read a fresh ask as one it had
129    /// already announced. The run rides the roster and every mark is scoped
130    /// by it, so no two runs can be confused.
131    pub boot_id: String,
132    pub service_status: Option<Value>,
133    /// The last fatal error, held by the core: an App-level event with no
134    /// state behind it reaches only whoever was subscribed when it fired.
135    pub fatal_error: Option<Value>,
136    /// The device enrolments the settings page lists and the unlock card
137    /// reads. **No wrapped blob is here**: the list is read by every client,
138    /// and the bytes stay behind a release.
139    pub biometric: ReadWire<Vec<forge_primitives::biometric::BiometricEnrolment>>,
140    /// The shared vault session's lock state and idle bound, for the
141    /// settings vault card. **No token or key is here**: the card draws
142    /// whether the vault is open and when it locks itself.
143    pub vault: Option<VaultWire>,
144}
145
146/// The shared vault session as the settings card draws it.
147#[derive(Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub struct VaultWire {
150    /// Whether the session holds its keys open right now.
151    pub locked: bool,
152    /// The idle bound the config set, or `null` for no idle lock.
153    pub idle_minutes: Option<u64>,
154}
155
156/// The Extensions page's read: the plugin inventory, the PATH checks the
157/// scan cannot carry, and the last update run.
158///
159/// **`snapshot` and `update_run` are the same shapes the live frames
160/// carry.** `PluginsInventoryUpdated`, `PluginsUpdateRunProgress` and
161/// `PluginsUpdateRunFinished` each embed these objects, so a client that
162/// takes one off the stream and a client that read the snapshot are
163/// drawing the same bytes (rule 25: the read path and the live path are
164/// one obligation). `lsp` is read-only - no frame carries it, and a page
165/// re-reads to refresh it.
166#[derive(Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub struct ExtensionsWire {
169    /// `None` when no plugins root resolves (`CLAUDE_CONFIG_DIR` or
170    /// `HOME`): the inventory is unknowable, which is its own state rather
171    /// than an empty one.
172    pub snapshot: Option<PluginsInventorySnapshot>,
173    pub update_run: Option<PluginUpdateRun>,
174    pub lsp: Vec<LspCheckWire>,
175}
176
177/// One declared LSP server and whether its binary resolves on PATH.
178#[derive(Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub struct LspCheckWire {
181    pub plugin: String,
182    pub server: String,
183    pub command: String,
184    pub on_path: bool,
185}
186
187/// One project row: the project, and the per-row reads the home draws it from.
188///
189/// The cells beside the project are what the row states, and each is reached
190/// from the ROW rather than from a seat: a home row is a project, and a
191/// project is a row whether or not anything has started it. The board's own
192/// detail - rows, schedules and connector sets - is [`ProjectBoardWire`]'s
193/// (#2517).
194#[derive(Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub struct ProjectWire {
197    pub project: ProjectView,
198    /// The branch and the count for the project's own tree.
199    ///
200    /// **Keyed by the project's LEAD slot, which exists for every declared
201    /// project** whether or not a session has opened it, and read at the
202    /// project's own path - so a row's cell is filled for a project nobody
203    /// has started, which is the whole point of the cell. That is also why it
204    /// is not the read a SEAT draws: a worker's own tree is `AgentWire::work`,
205    /// and this one is the project's own path whichever seat is asking.
206    ///
207    /// This is the shared cache the transport holds, so several clients
208    /// subscribing do not multiply git invocations: the cache is what makes
209    /// the per-row cost one scan's rather than N times it.
210    pub work: WorkState,
211    /// Whether a spawn in this project would find an account. Read beside
212    /// `project.has_model`, which is what tells the two reasons a spawn cannot
213    /// run apart.
214    pub would_bind: bool,
215    /// The account this project's row chips, and its state.
216    ///
217    /// A view draws the chip from `Roster::chip_for`, which derives the
218    /// state from the project's model and the account pool. `has_model` is not
219    /// enough to derive it from, so a client could draw the row and not the
220    /// chip it carries.
221    pub chip: Option<forge_workspace::SessionChipInfo>,
222}
223
224/// The board's own read: every project's live detail, for the page and the
225/// rail that draw it (#2517).
226///
227/// **Its own subject and its own snapshot.** The rows, schedules and
228/// connector sets are most of what a ~708 KB home read used to carry, and a
229/// home read happens on every update that covers the fleet; a page that draws
230/// none of them should not pay for them.
231#[derive(Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub struct BoardWire {
234    pub projects: Vec<ProjectBoardWire>,
235}
236
237/// One project's identity, which is all a board row needs to place it.
238///
239/// The rest of [`ProjectView`] - its sessions, its accounts, its paths - is
240/// the home's: the board neither hears nor draws it (#2517).
241#[derive(Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub struct ProjectBoardIdentity {
244    pub org: String,
245    pub name: String,
246}
247
248/// One project's board detail.
249#[derive(Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub struct ProjectBoardWire {
252    pub project: ProjectBoardIdentity,
253    /// The project's live rows, with what the board derives on each:
254    /// worked time, ages, and the marks. Computed once, in the workspace's
255    /// board module, and passed through - the client never re-derives a
256    /// mark differently.
257    pub rows: Vec<forge_workspace::board::BoardRow>,
258    /// The schedules this project holds.
259    ///
260    /// A view draws its SCHEDULES section from these, and nothing on
261    /// this wire carried them: a client could create a cron and never see it
262    /// again.
263    pub crons: Vec<forge_primitives::CronEntry>,
264    /// What this project's sessions are subscribed to, one list per connector.
265    ///
266    /// The subscriptions are per PROJECT and the section that draws them sits
267    /// on the row, so they ride here rather than on the home's
268    /// [`ConnectorsWire`] - which carries the liveness facts, which belong to
269    /// the server and the workspace rather than to a project.
270    pub connectors: ProjectConnectorsWire,
271}
272
273/// One seat's row: the surface's own [`AgentRow`], plus the read that is per
274/// SEAT rather than per project.
275///
276/// A home row is a seat, and its `where` cell is that seat's own working tree.
277/// `ProjectWire::work` cannot answer it - that read is the project's own path
278/// from the lead's seat - so a worker row drew the project's branch, and on a
279/// fleet with workers in worktrees that is eight rows reading `main`.
280///
281/// The fields are spelled out rather than the row being carried whole, so a
282/// field added to [`AgentRow`] has to be carried here deliberately rather than
283/// reaching a client by accident.
284#[derive(Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub struct AgentWire {
287    pub slot: SessionSlot,
288    pub label: String,
289    pub lifecycle: forge_primitives::SessionLifecycleState,
290    /// Which heal attempt is next while the seat is `Restarting`; `None`
291    /// for every other lifecycle.
292    pub heal_attempt: Option<u32>,
293    /// How many restarts a heal may make, so the row can say "1 of 3".
294    pub heal_attempts: Option<u32>,
295    pub has_background_work: bool,
296    pub pending: Option<forge_workspace::PendingInteractionKind>,
297    pub pending_depth: usize,
298    /// The ask's identity: how many asks have ever arrived for the seat.
299    /// A client announces a new ask by this number, so a re-read is not news
300    /// and an ask replacing another is.
301    pub pending_seq: u64,
302    pub last_activity: Option<std::time::SystemTime>,
303    pub reason: Option<String>,
304    /// When the seat's newest turn ended in failure, filtered by what has
305    /// been shown: `None` once the seat has been shown since the failure,
306    /// or while it is being shown now.
307    pub failed_turn: Option<std::time::SystemTime>,
308    /// Whether a session on the seat ended and no reader has been shown it
309    /// since: the row's failure is the durable trace of a crash nobody
310    /// watched.
311    pub died: bool,
312    /// The seat's own tree as a NAME, home-shortened, when the worker runs
313    /// somewhere other than its project's directory: the fact the row's
314    /// where cell says. `None` for a seat in its project's own tree.
315    pub tree: Option<String>,
316    /// The same tree's STATE - branch, changed count, gate - `None` for a
317    /// seat forge holds no directory for.
318    ///
319    /// Read through the same shared cache the project rows use, so a lead's
320    /// tree is one read drawn twice rather than two reads.
321    pub work: Option<WorkState>,
322}
323
324impl From<&AgentRow> for AgentWire {
325    fn from(row: &AgentRow) -> Self {
326        Self {
327            slot: row.slot.clone(),
328            label: row.label.clone(),
329            lifecycle: row.lifecycle,
330            heal_attempt: row.heal_attempt,
331            heal_attempts: row.heal_attempts,
332            has_background_work: row.has_background_work,
333            pending: row.pending,
334            pending_depth: row.pending_depth,
335            pending_seq: row.pending_seq,
336            last_activity: row.last_activity,
337            reason: row.reason.clone(),
338            failed_turn: row.failed_turn,
339            died: row.died,
340            tree: row.tree.clone(),
341            work: None,
342        }
343    }
344}
345
346/// What the inbound connectors are watching, server-wide.
347///
348/// The liveness facts and nothing else: what each connector is subscribed to
349/// belongs to a project and rides [`ProjectBoardWire::connectors`], so a
350/// subscriptions field here would be one no read can fill - and a page drawing
351/// it shows the section empty however long it waits.
352#[derive(Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub struct ConnectorsWire {
355    pub gotify: GotifyWire,
356    pub slack: SlackWire,
357}
358
359#[derive(Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub struct GotifyWire {
362    pub connected: bool,
363}
364
365#[derive(Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367pub struct SlackWire {
368    /// Workspace label to whether that workspace's pump is live, as a list
369    /// of pairs: JSON objects with dynamic keys are awkward for a client to
370    /// iterate, and the order is the store's.
371    pub connected_workspaces: Vec<(String, bool)>,
372    pub load_failed: bool,
373}
374
375/// One project's connector subscriptions, as the row's section draws them.
376#[derive(Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub struct ProjectConnectorsWire {
379    pub gotify: Vec<GotifySubscription>,
380    pub slack: Vec<SlackSubscription>,
381}
382
383/// Dictation's preflight state.
384#[derive(Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub struct DictateWire {
387    /// Whether `[dictate] enabled` is set. **Carried rather than inferred
388    /// from an empty `models` list**: the list is empty both for a
389    /// switched-off section and for a default snapshot, so a reader asserting
390    /// the cause from the value tells a healthy configuration it is off, and
391    /// nothing in the payload says which.
392    pub enabled: bool,
393    pub snapshot: Value,
394    pub models_dir: Option<std::path::PathBuf>,
395}
396
397/// The voice switch and where its model lands, as the home carries them.
398#[derive(Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub struct VoiceWire {
401    pub enabled: bool,
402    pub models_dir: Option<std::path::PathBuf>,
403}
404
405/// One session, as a client sees it: every read a session-scoped view makes.
406#[derive(Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub struct SessionWire {
409    pub slot: SessionSlot,
410    pub state: SessionStateWire,
411    /// What this seat's composer is doing, which no record answered before.
412    pub composer: ComposerWire,
413    pub header: SessionHeaderWire,
414    pub mcp: Option<McpServers>,
415    pub processes: Option<ProcessSnapshot>,
416    /// The seat's live tasks, which the processes feed leads its rows with:
417    /// the CLI's background registry plus the long foreground calls it is
418    /// tracking while they run, each saying which it is (`is_backgrounded`).
419    ///
420    /// The tasks the OS walk cannot see for itself are the ones this exists
421    /// for: a backgrounded bash is `setsid`-detached, so it sits outside
422    /// claude's tree, and a view draws it from the registry - with the
423    /// command telling it whether the walk has already adopted the process
424    /// behind one. A foreground call is in the tree already; the row is what
425    /// names it while it runs.
426    pub background_tasks: Vec<forge_workspace::BackgroundTask>,
427    pub monitors: Vec<MonitorRecord>,
428    /// The seat's terminal, when it has one: the strip's row and the state
429    /// it draws. Absent until the terminal is opened; the strip draws its
430    /// row for every seat and reads this for the state, so a seat with none
431    /// draws **off** and **start**.
432    pub terminal: Option<forge_primitives::terminal::TerminalSummary>,
433    /// The prompt a one-ask dock draws: the front of `pending_asks`.
434    ///
435    /// **Derived from the same list at serialisation**, so the two cannot
436    /// drift. What a client reading only this draws is the front, which on a
437    /// mixed set is NOT what the single read answered before the list: that
438    /// preferred a question over a permission prompt and took same-kind asks
439    /// in map order, where the front is the oldest ask with a draft leading.
440    pub pending_ask: Option<PendingAskWire>,
441    /// Every prompt this seat is holding: **a draft leads, then arrival
442    /// order**. A parallel batch parks several at once, so a client that
443    /// attached mid-batch reads the ones behind the front rather than losing
444    /// all but one.
445    pub pending_asks: Vec<PendingAskWire>,
446    /// The newest turns, not the whole conversation. See [`SUBSCRIBE_TURNS`].
447    pub conversation: ConversationWire,
448    pub slash_commands: Vec<AvailableCommand>,
449    pub subagents: Vec<AvailableAgent>,
450    /// The session's sub-agent instances, as the core joined them: one card
451    /// per dispatch with its calls under it. The catalogue above names the
452    /// TYPES the CLI offers; this is what actually ran.
453    pub subagent_instances: Vec<forge_primitives::runtime::SubagentCard>,
454    /// The seat's own walk, from the store its loop keeps fresh, or a walk
455    /// taken for the read when the loop has not run. Shared rather than walked
456    /// per subscriber: the walk is a whole tree. Serialises as the index
457    /// itself.
458    pub file_index: Arc<FileIndex>,
459    /// The sessions this seat can jump back to with `/resume`, newest first.
460    ///
461    /// Carried on the record because the composer's other sources are: a
462    /// `/resume` list is only asked for while a draft's argument is being
463    /// typed, and a read on demand would need its own ask, its own answer and
464    /// a shell command to carry it - the record already reaches every reader
465    /// the composer draws on. Both a lead's and a worker's read is one scan of
466    /// the directory that seat's own resume runs in.
467    pub resume_sessions: Vec<ResumableSessionWire>,
468    pub reviews: ReviewsWire,
469    /// The seat-tree review half, beside the branch's: the files page's
470    /// comments, each thread placed against the seat's working files. Same
471    /// wire shape as `reviews` so a page parses one thing; the scope is
472    /// the seat, not a branch, which is what the files page keys to.
473    pub tree_reviews: ReviewsWire,
474    /// The working tree behind the git section: the branch, how much has
475    /// changed, and the gate. The changed files themselves ride `diff`
476    /// below.
477    pub work: WorkState,
478    /// The tree behind the git row's depth: the uncommitted files with
479    /// their marks and counts, the branch's chain ahead of its default
480    /// with the commits that produced it, and which branch that is.
481    ///
482    /// What the row above cannot say: a branch and a count tell a reader
483    /// nothing about WHAT is working, and this is what a hover over the
484    /// row draws. The content read below carries the same layers with
485    /// hunks, for the surface that reviews them.
486    pub git: forge_primitives::git_diff::GitWorkView,
487    /// The open pull request this seat's branch is on, and the issues it
488    /// closes.
489    ///
490    /// Beside `work` rather than inside it because the two come from
491    /// different reads - the branch and the count are the row's own, and this
492    /// is the heavier scan - and a client draws the inspector's GIT section
493    /// from both. Nothing carried them, so a client would draw the section
494    /// without the `PR #N -> closes #M` row a view leads it with.
495    pub pr: Option<forge_primitives::git::GitPrInfo>,
496    pub closes: Vec<forge_primitives::git::GitIssueRef>,
497    /// The changed files with their raw hunks, bounded and flagged: what a
498    /// review surface draws a branch from, as data and never as a
499    /// rendering.
500    ///
501    /// Read beside `work` rather than pushed with it: the row above moves
502    /// with the tree, while this is taken once per read that encodes a
503    /// record - a cold load, a reconnect, a seat swap - the way a view's
504    /// `/diff` scans once on open. Its caps and flags live in
505    /// forge-agent's `git_diff::content`, which is where the read's shape
506    /// is decided.
507    pub diff: DiffContent,
508}
509
510/// One session a seat can resume, as `/resume`'s list draws it: the id a pick
511/// writes into the draft, and the transcript's own title beside it.
512#[derive(Serialize, Deserialize)]
513#[serde(rename_all = "snake_case")]
514pub struct ResumableSessionWire {
515    pub session_id: String,
516    pub summary: String,
517}
518
519/// Where a session's reads find their own working tree.
520#[derive(Serialize, Deserialize)]
521#[serde(rename_all = "snake_case")]
522pub struct SessionStateWire {
523    pub slot: SessionSlot,
524    pub scan_cwd: std::path::PathBuf,
525    /// The prompts still waiting in the CLI's queue, oldest first.
526    ///
527    /// The read path for the pile: `prompt_queued` and `prompt_lifecycle`
528    /// speak only on a change, so a client attaching mid-queue - a fresh
529    /// load, a refresh, a seat switch - has nothing else to draw the waiting
530    /// prompts from, and two attached clients have to agree about them.
531    pub queue: Vec<forge_workspace::protocol::QueuedPrompt>,
532    /// Clients with voice on for this seat, this one included.
533    pub voice_listeners: u32,
534}
535
536/// What one seat's composer is doing.
537///
538/// A client attaching to a running session cannot reconstruct any of it: the
539/// stream announces a compaction and a sign-in once each and retains nothing.
540///
541/// **No take, and the notice only when it is unsent**: a take belongs to the
542/// connection that started it, whose own updates carry its meter, its phases
543/// and its words, so the shared record says nothing about one and no other
544/// subscriber draws it. A LANDED take's words, though, are the seat's until a
545/// prompt carries them away - a page that reloads between the landing and the
546/// send reads them back here (#1360) - and they are marked `carried` so a
547/// page reads a re-hand as the seat's words rather than a landing of its own.
548///
549/// The ASKS the composer is answering ride `pending_asks` on this same record
550/// rather than appearing here, because that is the same thing the session's own
551/// read answers and two copies of it would drift.
552#[derive(Serialize, Deserialize)]
553#[serde(rename_all = "snake_case")]
554pub struct ComposerWire {
555    /// The machine turn running on the seat, which takes the composer away:
556    /// absent for a seat taking input.
557    pub machine: Option<forge_primitives::runtime::MachineTurn>,
558    /// The push-to-talk key and how a press maps onto a take, in the
559    /// vocabulary `forge.toml` accepts.
560    ///
561    /// **Carried rather than left to each client's default.** What a
562    /// keyboard press means is the user's own configuration, and a client
563    /// that drew the affordance from a hardcoded key would honour a
564    /// different chord on every install that moved it.
565    pub bind: String,
566    pub mode: String,
567    /// The landed dictation nobody has sent yet, in the same shape the
568    /// `dictate_ended` update carries, plus `carried: true`.
569    pub notice: Option<serde_json::Value>,
570}
571
572/// The session's header facts, including the two a client cannot otherwise
573/// reach: the catalogue a picker draws its rows from, and whether a turn is
574/// in flight.
575#[derive(Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub struct SessionHeaderWire {
578    /// The occupant's id, or `None` when there is no occupant to name:
579    /// nothing started, nothing connected yet, or an id dropped since.
580    ///
581    /// **This is the epoch [`Self::seq`] belongs to.** Every frame the seat's
582    /// stream carries is numbered, and a change of occupant starts the count
583    /// over at 1 - so a client that reads this id with the number below knows
584    /// which frames it missed and which it never had.
585    pub session_id: Option<forge_primitives::SessionId>,
586    /// The number the seat's next frame will carry.
587    ///
588    /// A client that attaches mid-stream reads where the stream stands, so
589    /// the frames it hears from here continue the count it holds rather than
590    /// looking like a gap. It is the successor - never 0, never the last
591    /// number minted.
592    pub seq: u64,
593    pub model: Option<Value>,
594    pub effort: Value,
595    pub permission_mode: Option<Value>,
596    pub context: Value,
597    pub available_models: Vec<Value>,
598    pub turn_in_flight: bool,
599}
600
601/// What a seat is held on: every kind of parked interaction, not the two the
602/// dock happened to draw first. Each request crosses as the core's own shape,
603/// because a client draws it rather than re-deriving it.
604#[derive(Serialize, Deserialize)]
605#[serde(tag = "kind", content = "request", rename_all = "snake_case")]
606pub enum PendingAskWire {
607    Permission(Value),
608    Question(Value),
609    SlackDraft(Value),
610    BrowserHandOff(Value),
611    TerminalHandOff(Value),
612    DocumentHandOff(Value),
613    SettingsWrite(Value),
614    Vault(Value),
615}
616
617impl From<&PendingAsk> for PendingAskWire {
618    fn from(ask: &PendingAsk) -> Self {
619        let encode = |body: Result<Value, serde_json::Error>| body.unwrap_or(Value::Null);
620        match ask {
621            PendingAsk::Permission(request) => {
622                Self::Permission(encode(serde_json::to_value(request.as_ref())))
623            }
624            PendingAsk::Question(request) => {
625                Self::Question(encode(serde_json::to_value(request.as_ref())))
626            }
627            PendingAsk::SlackDraft(draft) => {
628                Self::SlackDraft(encode(serde_json::to_value(draft.as_ref())))
629            }
630            PendingAsk::BrowserHandOff(handoff) => {
631                Self::BrowserHandOff(encode(serde_json::to_value(handoff.as_ref())))
632            }
633            PendingAsk::TerminalHandOff(handoff) => {
634                Self::TerminalHandOff(encode(serde_json::to_value(handoff.as_ref())))
635            }
636            PendingAsk::DocumentHandOff(ask) => {
637                Self::DocumentHandOff(encode(serde_json::to_value(ask.as_ref())))
638            }
639            PendingAsk::SettingsWrite(ask) => {
640                Self::SettingsWrite(encode(serde_json::to_value(ask.as_ref())))
641            }
642            PendingAsk::Vault(draft) => Self::Vault(encode(serde_json::to_value(draft.as_ref()))),
643        }
644    }
645}
646
647/// The conversation a session's transcript replayed, and how many times it
648/// has compacted.
649///
650/// **Its turns, not a bare frame list.** A client folds turns, so a
651/// conversation handed over as frames with no boundaries is one it cannot fold
652/// at all; carrying the same turns a page carries is what makes the whole
653/// conversation and a window of it read the same way.
654#[derive(Serialize, Deserialize)]
655#[serde(rename_all = "snake_case")]
656pub struct ConversationWire {
657    pub turns: Vec<TurnWire>,
658    /// The handle above the newest page, the same one a `more` answers with.
659    ///
660    /// A snapshot carries the page a `more` would fetch, cursor included, so
661    /// a client seeds its conversation from this answer and pages above it
662    /// from here rather than asking for the same page twice (#2420). `None`
663    /// is the conversation's first page: there is nothing above it.
664    pub cursor: Option<String>,
665    pub compaction_count: u32,
666    /// The schedules the fired prompts in these turns came from, keyed by the
667    /// prompt's own id.
668    ///
669    /// **The read half of the pairing `CronPromptAppended` carries once.**
670    /// The frame titles the row while it lands, and the row replayed from the
671    /// transcript afterwards carries the prompt alone - so the pairing rides
672    /// the read too, for the rows this page holds and no others.
673    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
674    pub cron_names: BTreeMap<String, forge_primitives::cron::CronName>,
675}
676
677/// The schedules the fired prompts in `turns` came from, keyed by the
678/// prompt's own id.
679///
680/// Only a delivery row is looked up: its prompt id is the pairing's key, and
681/// the map holds nothing for a row no schedule produced.
682pub fn cron_names_of(
683    surface: &ViewSurface,
684    slot: &SessionSlot,
685    turns: &[TurnWire],
686) -> BTreeMap<String, forge_primitives::cron::CronName> {
687    let mut uuids: Vec<&str> = Vec::new();
688    for turn in turns {
689        for frame in &turn.messages {
690            if frame.get("type").and_then(Value::as_str) != Some("user") {
691                continue;
692            }
693            let Some(uuid) = frame.get("uuid").and_then(Value::as_str) else {
694                continue;
695            };
696            if !uuid.is_empty() && !uuids.contains(&uuid) {
697                uuids.push(uuid);
698            }
699        }
700    }
701    if uuids.is_empty() {
702        return BTreeMap::new();
703    }
704    surface.cron_names(slot, &uuids)
705}
706
707/// The review reads for one branch, each carrying its own answer.
708#[derive(Serialize, Deserialize)]
709#[serde(rename_all = "snake_case")]
710pub struct ReviewsWire {
711    pub threads: ReadWire<Vec<ReviewedThreadWire>>,
712    pub reviews: ReadWire<Vec<ReviewSet>>,
713}
714
715/// One file's content at a revision, as the `file_content` answer carries
716/// it: the lines a review page's between-hunk expansion draws.
717///
718/// A read that answered something is never folded into one that failed -
719/// the same distinction the diff reader keeps - so a refusal crosses as its
720/// own arm with its own words rather than as an empty read.
721#[derive(Debug, Serialize, Deserialize)]
722#[serde(tag = "state", rename_all = "snake_case")]
723pub enum FileContentWire {
724    /// The lines the read carried, and whether its ceiling cut them short.
725    Read { lines: Vec<String>, truncated: bool },
726    /// The read could not run, in its own sentence.
727    Failed { why: String },
728}
729
730/// One review thread as a record carries it: the stored thread, and where
731/// its anchor sits in the content the record ships beside it.
732///
733/// **The landing is resolved here because only the server can resolve
734/// it.** A client draws the bounded content it is sent and has no working
735/// tree to re-scan; the server's own resolver is the one answer to
736/// whether an anchor still lands, so the record carries that answer
737/// rather than a second implementation deriving one a language away.
738///
739/// **A thread written inside a commit lands too, when the content
740/// carries it.** Its own commit's view draws it at its recorded line
741/// without any of this; the landing is what lets the all-changes view
742/// draw it beside the same code in the branch's numbering - earned by
743/// content and neighbours, never by the commit-numbered line, which
744/// addresses unrelated code here.
745///
746/// `resolution` is `None` when this read has no landing to report: a
747/// thread whose branch layer the scan could not read, or one
748/// numbered against another base.
749#[derive(Debug, Serialize, Deserialize)]
750#[serde(rename_all = "snake_case")]
751pub struct ReviewedThreadWire {
752    #[serde(flatten)]
753    pub thread: ReviewThread,
754    /// Where the anchor sits in the content this answer carries, or `None`
755    /// when the read has no lines to place against - a document whose bytes
756    /// are not text.
757    pub resolution: Option<ThreadResolutionWire>,
758}
759
760/// Where an anchor sits, as a view draws it: the line in the anchor's own
761/// side's numbering, or why it could not be placed.
762#[derive(Debug, Serialize, Deserialize)]
763#[serde(tag = "state", rename_all = "snake_case")]
764pub enum ThreadResolutionWire {
765    /// Placed, at `line`. `moved_from` is the recorded line when the
766    /// anchor no longer sits where it was written.
767    Placed { line: u32, moved_from: Option<u32> },
768    /// Not placed, for `reason`.
769    Outdated { reason: OutdatedReasonWire },
770}
771
772/// The resolver's own reason, crossing as data rather than as prose.
773#[derive(Debug, Serialize, Deserialize)]
774#[serde(rename_all = "snake_case")]
775pub enum OutdatedReasonWire {
776    Gone,
777    Ambiguous { matches: usize },
778}
779
780/// Every thread with its landing against `content` - the same content the
781/// record ships, so a page draws a thread where this answer says.
782///
783/// **Only a thread numbered against this diff's own base is placed.** A
784/// whole-diff anchor records the base it was written against, and a
785/// view's overlay opened against another target writes line numbers that
786/// address unrelated code here (the placement refuses exactly that:
787/// "line numbers against another base would anchor onto unrelated code"). A
788/// thread from another base answers `None` - no landing - which the page
789/// draws as itself rather than parking it on a guess.
790fn reviewed_threads(
791    threads: Vec<ReviewThread>,
792    content: &DiffContent,
793    default_branch: Option<&str>,
794    whole: &WholeFiles,
795) -> Vec<ReviewedThreadWire> {
796    let files = match &content.branch_ahead {
797        LayerState::Populated(files) => Some(files.as_slice()),
798        LayerState::Clean | LayerState::ScanFailed => None,
799    };
800    threads
801        .into_iter()
802        .map(|thread| {
803            let home_base = default_branch == Some(thread.anchor.base_ref.as_str());
804            let resolution = match (&thread.commit, files) {
805                (_, Some(files)) if home_base => {
806                    // **A commit-scoped anchor's line counts in its own
807                    // commit's numbering**, so the in-place shortcut is off
808                    // for it: the landing has to be earned by content and
809                    // neighbours (the resolver's own `trust_line_number`),
810                    // which is what keeps the line from anchoring onto
811                    // unrelated code here.
812                    let trusted = thread.commit.is_none();
813                    resolution_wire(&thread.anchor, files, trusted, trusted.then_some(whole))
814                }
815                _ => None,
816            };
817            ReviewedThreadWire { thread, resolution }
818        })
819        .collect()
820}
821
822/// One whole file an anchor needed, and whether the read carried all of it.
823struct WholeFile {
824    lines: Vec<String>,
825    truncated: bool,
826}
827
828/// The whole files an anchor needed, one per `(path, side)`.
829type WholeFiles = std::collections::HashMap<(String, ReviewSide), WholeFile>;
830
831/// At most this many whole-file reads per record encode. An ordinary record
832/// reads none - every anchor sits in its hunks - and a review with more
833/// comments outside its hunks than this answers the rest from the bounded
834/// content rather than running an unbounded pile of git subprocesses on
835/// every seat encode.
836const WHOLE_FILE_READS_PER_RECORD: usize = 8;
837
838/// Whether any hunk of `files` carries `anchor.line` on the anchor's side.
839///
840/// **This is the rule that decides which authority answers**: a line the
841/// hunks carry is placed there, and a line they do not carry is answered by
842/// the whole file alone - never by a content match to some other hunk line
843/// that happens to read the same.
844fn line_carried(anchor: &ReviewAnchor, files: &[FileContent]) -> bool {
845    files.iter().filter(|file| file.path == anchor.path).any(|file| {
846        file.hunks.iter().any(|hunk| {
847            hunk.lines.iter().any(|line| match anchor.side {
848                ReviewSide::New => line.new_line == Some(anchor.line),
849                ReviewSide::Old => line.old_line == Some(anchor.line),
850            })
851        })
852    })
853}
854
855/// The whole files for the anchors no hunk carries a line for.
856///
857/// **A comment written on a revealed context line is outside the diff's
858/// hunks by construction**, and the file it was written on is the whole
859/// answer. The new side is numbered at the branch tip and the old side at
860/// the merge-base, because the branch-ahead diff is a three-dot diff; an old
861/// side read at the base's current tip would shift every line when the base
862/// has advanced. A renamed file's old side is read at its old path. Only a
863/// thread this record's own diff can draw is read for: another base's
864/// thread, or a commit-scoped one whose line counts in its own commit, keeps
865/// the resolver's answer. A read that fails leaves the anchor to the bounded
866/// content, as before.
867async fn whole_files_for(
868    cwd: &std::path::Path,
869    ceiling: u64,
870    threads: &[ReviewThread],
871    content: &DiffContent,
872    default_branch: Option<&str>,
873    // The revision the old side resolves against: the sha the diff was pinned
874    // to when the read refreshed its base, else the ref's name. A pinned diff
875    // read at the name's merge-base would place anchors against a base the
876    // diff does not describe.
877    base_rev: Option<&str>,
878) -> WholeFiles {
879    let files = match &content.branch_ahead {
880        LayerState::Populated(files) => files.as_slice(),
881        LayerState::Clean | LayerState::ScanFailed => return WholeFiles::new(),
882    };
883    let mut whole = WholeFiles::new();
884    // Resolved once per encode, and only if an old-side anchor asks for it.
885    // **The ATTEMPT is remembered, not only its success**: a base whose
886    // merge-base cannot be resolved must not cost one resolve per anchor,
887    // and the read bound below never sees those attempts.
888    let mut old_rev: Option<Option<String>> = None;
889    let mut reads = 0_usize;
890    for thread in threads {
891        let anchor = &thread.anchor;
892        if thread.commit.is_some() || default_branch != Some(anchor.base_ref.as_str()) {
893            continue;
894        }
895        let Some(file) = files.iter().find(|file| file.path == anchor.path) else {
896            continue;
897        };
898        if line_carried(anchor, files) {
899            continue;
900        }
901        let key = (anchor.path.clone(), anchor.side);
902        if whole.contains_key(&key) || reads >= WHOLE_FILE_READS_PER_RECORD {
903            continue;
904        }
905        let (rev, path) = match anchor.side {
906            ReviewSide::New => ("HEAD".to_owned(), file.path.clone()),
907            ReviewSide::Old => {
908                let Some(base) = base_rev.or(default_branch) else {
909                    continue;
910                };
911                if old_rev.is_none() {
912                    old_rev = Some(merge_base(cwd, base).await);
913                }
914                let Some(found) = old_rev.as_ref().and_then(|held| held.as_deref()) else {
915                    continue;
916                };
917                (found.to_owned(), file.old_path.clone().unwrap_or_else(|| file.path.clone()))
918            }
919        };
920        reads += 1;
921        if let Ok(read) = read_file_lines(cwd, &rev, &path, ceiling).await {
922            whole.insert(key, WholeFile { lines: read.lines, truncated: read.truncated });
923        }
924    }
925    whole
926}
927
928/// One anchor placed against the bounded content the record carries. With
929/// `trusted` the line number is evidence too, because the branch-ahead
930/// layer is the numbering the anchor was written in - the same trust a
931/// view's overlay extends to its own home scope. Untrusted, the placement
932/// is earned by content and neighbours alone.
933///
934/// `whole` is the file held for a line the hunks do not carry, and `None`
935/// for a commit-scoped thread, whose numbering belongs to its own commit and
936/// must not borrow the branch's read. A read cut before the anchored line
937/// cannot answer, and answers no landing rather than `Gone`.
938fn resolution_wire(
939    anchor: &ReviewAnchor,
940    files: &[FileContent],
941    trusted: bool,
942    whole: Option<&WholeFiles>,
943) -> Option<ThreadResolutionWire> {
944    let held = whole.and_then(|map| map.get(&(anchor.path.clone(), anchor.side)));
945    let placed = if line_carried(anchor, files) {
946        place_anchor_in_content(anchor, files, trusted)
947    } else {
948        match held {
949            Some(file) => {
950                let past_the_cut =
951                    usize::try_from(anchor.line).is_ok_and(|line| line > file.lines.len());
952                if file.truncated && past_the_cut {
953                    return None;
954                }
955                place_anchor_in_lines_with(anchor, &file.lines, trusted)
956            }
957            // The whole file is the authority for a line the hunks do not
958            // carry; without the read, the bounded content is the fallback.
959            None => place_anchor_in_content(anchor, files, trusted),
960        }
961    };
962    Some(match placed {
963        AnchorPlacement::Placed { line, moved_from } => ThreadResolutionWire::Placed {
964            line,
965            // **A cross-scope landing names no `moved_from`**: the recorded
966            // number counts in another scope's numbering, and naming it
967            // beside a line of this one would be a number the reader cannot
968            // place.
969            moved_from: if trusted { moved_from } else { None },
970        },
971        AnchorPlacement::Outdated(reason) => ThreadResolutionWire::Outdated {
972            reason: match reason {
973                OutdatedReason::Gone => OutdatedReasonWire::Gone,
974                OutdatedReason::Ambiguous { matches } => OutdatedReasonWire::Ambiguous { matches },
975            },
976        },
977    })
978}
979
980/// [`resolution_wire`]'s twin for the seat tree: the placement the surface
981/// already resolved against the seat's own working file crosses as-is -
982/// the numbering is the anchor's home, so `moved_from` is always named
983/// when there is one.
984pub(crate) fn placement_wire(placement: AnchorPlacement) -> ThreadResolutionWire {
985    match placement {
986        AnchorPlacement::Placed { line, moved_from } => {
987            ThreadResolutionWire::Placed { line, moved_from }
988        }
989        AnchorPlacement::Outdated(reason) => ThreadResolutionWire::Outdated {
990            reason: match reason {
991                OutdatedReason::Gone => OutdatedReasonWire::Gone,
992                OutdatedReason::Ambiguous { matches } => OutdatedReasonWire::Ambiguous { matches },
993            },
994        },
995    }
996}
997
998/// One turn, as a page carries it: the key the server named it by, and the
999/// messages it ran as.
1000///
1001/// **The messages, not a drawing of them.** How a run of tool calls groups
1002/// inside a turn is a drawing decision, so it belongs to whoever draws; what
1003/// crosses is the turn's own boundary, and that stays here because the paging
1004/// contract is built on it - `more` asks for turns, and a page above the
1005/// window's floor
1006/// cuts on them rather than leaving a client to stitch half a turn to half.
1007///
1008/// **Below that floor a page can carry a stretch inside a turn**, which is
1009/// what a running turn's older rows are: a session whose life is one unended
1010/// turn has no boundary to cut on, and its rows reach a late-attaching view
1011/// only in runs. What a client is owed there is the pair: a page never opens
1012/// on a tool result without the call that produced it.
1013#[derive(Debug, Clone, Serialize, Deserialize)]
1014#[serde(rename_all = "snake_case")]
1015pub struct TurnWire {
1016    /// `None` when nothing named the turn: the key comes from a `Result`
1017    /// frame, and a conversation read from a transcript carries none.
1018    pub key: Option<String>,
1019    /// The frames this turn ran as, which is the same shape the session
1020    /// record's `conversation` carries.
1021    pub messages: Vec<Value>,
1022}
1023
1024/// One page of history: the newest turns, in conversation order, and the
1025/// handle that asks for the ones above them.
1026pub struct Page {
1027    pub turns: Vec<TurnWire>,
1028    /// `None` means there is nothing above this page, which is the one case
1029    /// a client stops asking.
1030    pub cursor: Option<String>,
1031}
1032
1033/// Where each turn opens, one entry per turn, and what names it.
1034///
1035/// **One message opens one turn**, so a user row carrying two blocks does not
1036/// put two boundaries at one index. A cursor names a message and resolves to
1037/// the first match, so two entries sharing an index would leave a walk a page
1038/// of its own to hand back before it moved - an empty one, since both
1039/// boundaries are that message.
1040fn opens_of(spans: &[TurnSpan]) -> Vec<(usize, Option<&str>)> {
1041    let mut opens: Vec<(usize, Option<&str>)> = Vec::with_capacity(spans.len());
1042    for span in spans {
1043        match opens.last_mut() {
1044            Some((at, key)) if *at == span.opens_at => {
1045                if key.is_none() {
1046                    *key = span.key.as_deref();
1047                }
1048            }
1049            _ => opens.push((span.opens_at, span.key.as_deref())),
1050        }
1051    }
1052    opens
1053}
1054
1055/// The messages `from..to` as one turn, which is the shape both the session
1056/// record and a page carry.
1057///
1058/// **A task ending a call in this turn is owed rides along.** A client folds
1059/// one turn at a time, so an ending the CLI persisted in another turn is one
1060/// it cannot reach - and the notice opens a turn of its own, which puts it in
1061/// another turn whenever anything that does open one sits between. The row
1062/// the CLI wrote stays where it is and draws nothing; what is added is a frame
1063/// of its own, which is content of the wire's own type rather than a second
1064/// copy of a drawing.
1065fn turn_wire(
1066    messages: &[Message],
1067    from: usize,
1068    to: usize,
1069    key: Option<&str>,
1070    endings: &HashMap<String, TaskEnding>,
1071) -> TurnWire {
1072    let mut frames: Vec<Value> =
1073        messages[from..to].iter().map(|message| frame_of(message, message.session_id())).collect();
1074    for ending in owed_endings(messages, from, to, endings) {
1075        let forged = forged_ending(ending);
1076        frames.push(frame_of(&forged, forged.session_id()));
1077    }
1078    TurnWire { key: key.map(str::to_owned), messages: frames }
1079}
1080
1081/// One message as the page carries it, or the notice that says it will not.
1082///
1083/// **A failed encode used to cross as `Value::Null`**, and the fold reads
1084/// `type` off every frame - so the page threw on exactly the frame it could
1085/// not describe. The notice names the failure and draws in its place: nothing
1086/// is dropped, and nothing arrives as a value no reader can read. The warning
1087/// names the session the frame belongs to, which is the identity a reader of
1088/// the log can act on.
1089fn frame_of<T: Serialize>(value: &T, session: Option<&str>) -> Value {
1090    match serde_json::to_value(value) {
1091        Ok(frame) => frame,
1092        Err(error) => {
1093            tracing::warn!(
1094                event_name = "conversation_frame_unsent",
1095                session = session.unwrap_or("unknown"),
1096                error = %error,
1097                "a conversation frame could not be serialised; drawn as a notice instead",
1098            );
1099            serde_json::json!({
1100                "type": "system",
1101                "subtype": "forge_notice",
1102                "severity": "warning",
1103                "text": format!("a conversation frame could not be serialised: {error}"),
1104            })
1105        }
1106    }
1107}
1108
1109/// Fold each run of consecutive thinking-token counters in a turn into the one
1110/// frame it draws as.
1111///
1112/// The fold reads a counter for two things only, its growth and its stamp,
1113/// and draws no row for it - so a turn that thinks for an hour is thousands of
1114/// frames that all say the same running figure. One frame per run carries it
1115/// exactly: the summed `estimated_tokens_delta`, the newest frame's own id and
1116/// stamp, and the stamps the run covers (`folded_from` through its own
1117/// `timestamp`) with `folded_frames` saying how many the one stands for. **The
1118/// live stream already serves a flush's run this way** (`batch::merge`); this
1119/// is that shape at the page's own cut.
1120///
1121/// **The range is what a landing reads, not a single instant.** A page can
1122/// open on a turn's SUFFIX - the held window's front cuts inside a running
1123/// turn when no turn opener sits above the line - so a client holding the
1124/// counters above the page's first one must keep them, and a run that recorded
1125/// only where it ends would drop them.
1126///
1127/// **A run that may start mid-turn is never folded.** When a turn's first
1128/// carried frame is itself a counter, the window's front cut inside that
1129/// running turn, and the run's true start is not in the page - so its leading
1130/// counters are served as themselves. Every fold a page carries therefore
1131/// starts at the first counter after a frame that is no counter, which is a
1132/// boundary any earlier page of the same run shares; a later page's fold then
1133/// nests inside or around an earlier one instead of crossing it.
1134///
1135/// **A run is one dispatch's, and the dispatch lives in the frame's extras
1136/// rather than in its typed fields.** No captured baseline carries
1137/// `parent_tool_use_id` on a counter, so the grouping is a guard rather than a
1138/// live path: if the CLI ever stamps one, a sub-agent's counters are the
1139/// subagents surface's and the chat fold skips them whole, and folding them
1140/// into the parent's run would move its figure and its clock. **A folded run
1141/// is stamped throughout**: a frame with no stamp breaks the run rather than
1142/// being folded under a bound it cannot be placed by.
1143///
1144/// It runs after the page is cut, so the budget still counts the bytes a page
1145/// used to carry and keeps the same turns it always did.
1146fn compact_counters(frames: &mut Vec<Value>) {
1147    let held = std::mem::take(frames);
1148    let mut out: Vec<Value> = Vec::with_capacity(held.len());
1149    // The turn's first frame opens it unless the page cut the turn, and only
1150    // a proven opening lets the leading run fold.
1151    let mut leading = held.first().is_some_and(is_counter);
1152    for frame in held {
1153        if !is_counter(&frame) {
1154            leading = false;
1155        }
1156        if let Some(last) = out.last_mut()
1157            && !leading
1158            && is_counter(last)
1159            && is_counter(&frame)
1160            && same_run(last, &frame)
1161            && counter_stamp(last).is_some()
1162            && counter_stamp(&frame).is_some()
1163        {
1164            // The newest frame survives with its own id, session and extras,
1165            // the same exchange `batch::merge` makes; only the growth, the
1166            // count and the range are the run's. The frame being replaced
1167            // carries the range's start once it is itself a fold, and before
1168            // that it is the run's opening frame.
1169            let previous = std::mem::replace(last, frame);
1170            let delta = grown(last).saturating_add(grown(&previous));
1171            let count = counted(&previous).saturating_add(1);
1172            let from = previous.get("folded_from").or_else(|| counter_stamp(&previous)).cloned();
1173            if let Some(object) = last.as_object_mut() {
1174                if let Some(from) = from {
1175                    object.insert("folded_from".to_owned(), from);
1176                }
1177                object.insert("estimated_tokens_delta".to_owned(), Value::from(delta));
1178                object.insert("folded_frames".to_owned(), Value::from(count));
1179            }
1180            continue;
1181        }
1182        out.push(frame);
1183    }
1184    *frames = out;
1185}
1186
1187/// Whether the frame is a thinking-token counter.
1188fn is_counter(frame: &Value) -> bool {
1189    frame["type"] == "system" && frame["subtype"] == "thinking_tokens"
1190}
1191
1192/// The dispatch a counter ran under, empty when it is the turn's own.
1193fn counter_dispatch(frame: &Value) -> Option<&str> {
1194    frame.get("parent_tool_use_id").and_then(Value::as_str).filter(|id| !id.trim().is_empty())
1195}
1196
1197/// Whether `next` continues the counter run `last` is part of.
1198fn same_run(last: &Value, next: &Value) -> bool {
1199    counter_dispatch(last) == counter_dispatch(next)
1200}
1201
1202/// The stamp a counter frame carries, when it carries one.
1203fn counter_stamp(frame: &Value) -> Option<&Value> {
1204    frame.get("timestamp").filter(|stamp| stamp.is_string())
1205}
1206
1207/// The growth one counter frame stands for: one frame's delta, or a run's sum.
1208fn grown(frame: &Value) -> i64 {
1209    frame["estimated_tokens_delta"].as_i64().unwrap_or(0)
1210}
1211
1212/// How many frames a counter stands for, itself included.
1213fn counted(frame: &Value) -> u64 {
1214    frame["folded_frames"].as_u64().unwrap_or(1)
1215}
1216
1217/// The keys a client actually draws out of a structured record: a mutation's
1218/// patch and its outside-edit mark, a question's answers and the notes typed
1219/// beside them.
1220const DRAWN_RECORD_KEYS: [&str; 4] = ["structuredPatch", "userModified", "answers", "annotations"];
1221
1222/// What the cap did to one served record, for the log line beside it.
1223#[derive(Clone)]
1224pub(crate) struct RecordCap {
1225    /// The record's size before the cap saw it.
1226    pub bytes: usize,
1227    /// The keys the served record kept, empty when the marker replaced it.
1228    pub kept: String,
1229}
1230
1231/// The record reduced to `keys`, or `None` when it holds none of them.
1232fn reduced_to(record: &Value, keys: &[&str]) -> Option<Value> {
1233    let held = record.as_object()?;
1234    let mut kept = serde_json::Map::new();
1235    for key in keys {
1236        if let Some(value) = held.get(*key) {
1237            kept.insert((*key).to_owned(), value.clone());
1238        }
1239    }
1240    (!kept.is_empty()).then_some(Value::Object(kept))
1241}
1242
1243/// Whether a value serialises into the cap.
1244fn fits(record: &Value, cap: usize) -> bool {
1245    serde_json::to_string(record).is_ok_and(|text| text.len() <= cap)
1246}
1247
1248/// Bound one served `tool_use_result` to `cap` bytes (#2374).
1249///
1250/// **The record is REDUCED before it is dropped, and the reduction is
1251/// staged**: the drawn keys together first, then the one key that carries the
1252/// row's own drawing - a question's `answers`, an edit's `structuredPatch`.
1253/// A question whose typed notes are what pushed it over keeps its answer and
1254/// loses only the notes, so it draws as the card it was, exactly as the live
1255/// frame does. The bare marker is the last resort; `userModified` rides it
1256/// because the row's outside-edit mark reads it.
1257///
1258/// `Some` says the record was over the cap, and what it kept, so the caller
1259/// can log what the served page left out.
1260pub(crate) fn cap_tool_record(record: &mut Value, cap: usize) -> Option<RecordCap> {
1261    if cap == usize::MAX {
1262        return None;
1263    }
1264    let Ok(text) = serde_json::to_string(&*record) else {
1265        return None;
1266    };
1267    if text.len() <= cap {
1268        return None;
1269    }
1270    let bytes = text.len();
1271    let stages: [&[&str]; 3] = [&DRAWN_RECORD_KEYS, &["answers"], &["structuredPatch"]];
1272    for keys in stages {
1273        let Some(reduced) = reduced_to(record, keys) else {
1274            continue;
1275        };
1276        if !fits(&reduced, cap) {
1277            continue;
1278        }
1279        let kept = reduced
1280            .as_object()
1281            .map(|held| held.keys().cloned().collect::<Vec<_>>().join(","))
1282            .unwrap_or_default();
1283        *record = reduced;
1284        return Some(RecordCap { bytes, kept });
1285    }
1286    let mut marker = serde_json::json!({ "omitted": true, "bytes": bytes });
1287    if record.get("userModified").and_then(Value::as_bool) == Some(true) {
1288        marker["userModified"] = Value::Bool(true);
1289    }
1290    *record = marker;
1291    Some(RecordCap { bytes, kept: String::new() })
1292}
1293
1294/// Say what the cap did to an executed call's record (#2374).
1295///
1296/// Debug, because a bounded page is forge working as intended rather than a
1297/// problem of its own - and named by seat and call, so "where did this payload
1298/// go" is answered by one line rather than by a reproduction.
1299fn log_record_cap(seat: &SessionSlot, id: Option<&str>, cap: usize, report: &RecordCap) {
1300    tracing::debug!(
1301        event_name = "tool_record_capped",
1302        slot = %seat.display(),
1303        tool_use_id = id.unwrap_or(""),
1304        bytes = report.bytes,
1305        cap,
1306        kept = %report.kept,
1307        disposition = if report.kept.is_empty() { "omitted" } else { "reduced" },
1308        "a served page bounded an executed tool call's record; the call ran and the transcript \
1309         keeps the whole record, and [server] tool_record_cap_kb = 0 serves it whole",
1310    );
1311}
1312
1313/// Bound every frame's `tool_use_result` in a page about to be SERVED (#2374).
1314///
1315/// **Only what a client is served is bounded; the transcript and the visible
1316/// `tool_result` block are untouched.** The structured record is drawn only
1317/// for mutations and questions - everything else is parsed and kept for
1318/// nothing - and one seat's mailbox records made a single page 70 MB, so the
1319/// page caps the record by `[server] tool_record_cap_kb` and leaves a marker
1320/// the client draws where it would have read the record. `usize::MAX` (the
1321/// `0` key) serves records whole.
1322pub(crate) fn cap_tool_records(turns: &mut [TurnWire], cap: usize, seat: &SessionSlot) {
1323    for turn in turns {
1324        for (id, report) in cap_turn(turn, cap) {
1325            log_record_cap(seat, id.as_deref(), cap, &report);
1326        }
1327    }
1328}
1329
1330/// [`cap_tool_records`] for one turn, answering what it bounded and the call
1331/// each belongs to rather than logging it here.
1332///
1333/// **A caller that may not serve the turn must be able to drop the report**:
1334/// a first page cut to a byte budget bounds the records it MEASURES, and only
1335/// the turns it keeps are served, so only those are logged.
1336fn cap_turn(turn: &mut TurnWire, cap: usize) -> Vec<(Option<String>, RecordCap)> {
1337    if cap == usize::MAX {
1338        return Vec::new();
1339    }
1340    let mut reports = Vec::new();
1341    for frame in &mut turn.messages {
1342        // The call's own id, read off the result block that answers it, so
1343        // the line names the exact call a reader can find in the transcript.
1344        let id = frame
1345            .get("message")
1346            .and_then(|message| message.get("content"))
1347            .and_then(Value::as_array)
1348            .and_then(|blocks| {
1349                blocks.iter().find_map(|block| {
1350                    block.get("tool_use_id").and_then(Value::as_str).map(str::to_owned)
1351                })
1352            });
1353        if let Some(result) = frame.get_mut("tool_use_result")
1354            && let Some(report) = cap_tool_record(result, cap)
1355        {
1356            reports.push((id, report));
1357        }
1358    }
1359    reports
1360}
1361
1362/// Hand the window's floor back as a page's cursor when the page opens on the
1363/// window's front (#2420).
1364///
1365/// **The window's own start is not the history's**: a page cut there carries
1366/// no handle of its own while the history above it begins at the floor. Both
1367/// a `more` answer and the session record serve that page, and both must hand
1368/// the same handle back or a client reads the history above as ended.
1369pub(crate) fn floor_cursor(page: &mut Page, dropped: usize) {
1370    if page.cursor.is_none() && dropped > 0 {
1371        page.cursor = Some(dropped.to_string());
1372    }
1373}
1374
1375/// The same bound on a live frame's record, before it goes to a client
1376/// (#2374): a reload and a live frame draw the same record or the marker.
1377pub(crate) fn cap_message_record(msg: &mut Message, cap: usize, seat: &SessionSlot) {
1378    if let Message::User { tool_use_result: Some(record), message, .. } = msg {
1379        let id = message.content.iter().find_map(|block| match block {
1380            ContentBlock::ToolResult { tool_use_id, .. }
1381            | ContentBlock::ServerToolResult { tool_use_id, .. } => Some(tool_use_id.to_owned()),
1382            _ => None,
1383        });
1384        if let Some(report) = cap_tool_record(record, cap) {
1385            log_record_cap(seat, id.as_deref(), cap, &report);
1386        }
1387    }
1388}
1389
1390/// The endings that name a call this turn holds and that the turn does not
1391/// already carry.
1392fn owed_endings<'a>(
1393    messages: &[Message],
1394    from: usize,
1395    to: usize,
1396    endings: &'a HashMap<String, TaskEnding>,
1397) -> Vec<&'a TaskEnding> {
1398    endings
1399        .values()
1400        .filter(|ending| !(from..to).contains(&ending.at))
1401        .filter(|ending| turn_holds_call(&messages[from..to], &ending.call))
1402        .collect()
1403}
1404
1405/// Whether these frames make the call `id`.
1406fn turn_holds_call(messages: &[Message], id: &str) -> bool {
1407    messages.iter().any(|message| {
1408        let Message::Assistant { message: envelope, .. } = message else {
1409            return false;
1410        };
1411        envelope.content.iter().any(|block| {
1412            matches!(
1413                block,
1414                ContentBlock::ToolUse { id: call, .. }
1415                    | ContentBlock::ServerToolUse { id: call, .. }
1416                    if call == id
1417            )
1418        })
1419    })
1420}
1421
1422/// The notice as a frame of its own, for the turn that holds the call it ends.
1423///
1424/// Forged rather than read off the wire, like the turn a delivery draws as:
1425/// the CLI wrote this ending into a row of its own, and this is that ending
1426/// delivered where a per-turn fold can see it. It carries no `uuid`, because
1427/// the CLI mints the transcript's id for its own row and nothing here may
1428/// invent one that disagrees with it.
1429fn forged_ending(ending: &TaskEnding) -> Message {
1430    Message::User {
1431        message: UserEnvelope {
1432            role: "user".to_owned(),
1433            content: vec![crate::transcript::notice_block(&ending.text)],
1434            extras: serde_json::Map::new(),
1435        },
1436        session_id: String::new(),
1437        parent_tool_use_id: None,
1438        uuid: None,
1439        tool_use_result: None,
1440        timestamp: None,
1441        synthetic: false,
1442        extras: serde_json::Map::new(),
1443    }
1444}
1445
1446/// The message range of every turn `spans` names, in conversation order, each
1447/// with what names it.
1448///
1449/// **The one place a turn's boundaries are computed.** The session record
1450/// carries every turn and a page carries a window of them, and a second
1451/// computation is how the two would come to disagree about what a turn is.
1452/// Ranges rather than turns, so a page renders the window it keeps instead of
1453/// every turn the conversation holds.
1454fn turn_ranges<'a>(
1455    messages: &[Message],
1456    spans: &'a [TurnSpan],
1457) -> Vec<(usize, usize, Option<&'a str>)> {
1458    let opens = opens_of(spans);
1459    // **A conversation can hold no turn at all**, and a client folds turns, so
1460    // it rides one instead of none. A session whose only rows are shapes the
1461    // fold opens nothing at - an interrupt marker, a task notice, a body no
1462    // call holds - is the case; handing a client no turns would leave the
1463    // whole of it unreachable, which is exactly what a page's `None` cursor
1464    // stops it asking for.
1465    if opens.is_empty() {
1466        return if messages.is_empty() { Vec::new() } else { vec![(0, messages.len(), None)] };
1467    }
1468    // A frame above the first turn - an assistant or system row, a delivery
1469    // the CLI pressed into a running turn - comes before it, so the first turn
1470    // carries it. Leaving it above would put it where no walk reaches.
1471    (0..opens.len())
1472        .map(|at| {
1473            let from = if at == 0 { 0 } else { opens[at].0 };
1474            let to = opens.get(at + 1).map_or(messages.len(), |&(open, _)| open);
1475            (from, to, opens[at].1)
1476        })
1477        .collect()
1478}
1479
1480/// `messages` as the whole turns `rendered` names, in conversation order.
1481///
1482/// The fold's own answer is what a caller passes, so the endings it read ride
1483/// along rather than being searched for again here.
1484pub fn all_turns(messages: &[Message], rendered: &Rendered) -> Vec<TurnWire> {
1485    turn_ranges(messages, &rendered.turns)
1486        .into_iter()
1487        .map(|(from, to, key)| {
1488            let mut turn = turn_wire(messages, from, to, key, &rendered.endings);
1489            compact_counters(&mut turn.messages);
1490            turn
1491        })
1492        .collect()
1493}
1494
1495/// Slice a conversation into whole turns, newest first.
1496///
1497/// **The fold is what says where a turn begins** - the span `render` reports,
1498/// in message terms. Slicing on a count of messages instead would cut a turn
1499/// in half, which is the breakage the design exists to avoid.
1500///
1501/// **The cursor errs toward OVERLAP, never toward a gap.** A client asking
1502/// for more may be handed a turn it already has - it keys its turns and drops
1503/// the repeats - but never a HOLE, which is history it has no way to ask for
1504/// again.
1505///
1506/// **The cursor is a POSITION, not a name, and the reason is a fact about the
1507/// read rather than a preference.** A turn's name is its key, and the fold
1508/// builds one only from a `Message::Result` frame - while a conversation read
1509/// from a transcript carries none: `SessionMessageKind` is `User | Assistant |
1510/// System` with no Result kind, and the replay synthesizer never emits one. So
1511/// a keyed cursor is `None` on every page of every transcript-derived
1512/// conversation, and a client reading `None` as "nothing above" stops after
1513/// the first page. The message the page's first turn opens at is the one thing
1514/// that always names it.
1515///
1516/// **`dropped` is how many messages the held conversation has lost off the
1517/// front**, and it is what makes that position survive a drop: the held list
1518/// is renumbered by one and the conversation is not, so a page carries the
1519/// conversation's numbering and this is the offset between the two.
1520pub fn page(
1521    messages: &[Message],
1522    rendered: &Rendered,
1523    dropped: usize,
1524    dropped_thinking: i64,
1525    before: Option<&str>,
1526    turns: u32,
1527) -> Page {
1528    page_within(
1529        messages,
1530        rendered,
1531        dropped,
1532        dropped_thinking,
1533        before,
1534        turns,
1535        usize::MAX,
1536        "page",
1537        None,
1538    )
1539}
1540
1541/// [`page`], with the whole turns it carries held to `budget` bytes.
1542///
1543/// **A page of twenty turns is unbounded in bytes**, and the first page is
1544/// what a seat switch waits on: a few large turns made one 5 MB, which is
1545/// seconds over a network and milliseconds locally. A `more` answer is held
1546/// the same way (`[server] more_page_cap_kb`): its page contract is whole
1547/// turns too, so one large turn crossed whole and a single page could be tens
1548/// of megabytes. So the newest page is cut where the budget runs out, at a
1549/// turn boundary - a client draws turns, and a turn cut in half is one it
1550/// cannot place. The handle it carries is the first kept turn's, so `more`
1551/// walks the history above it as it always did.
1552///
1553/// **The bytes counted are the bytes SERVED**: each candidate is bounded by
1554/// `cap` first, exactly as the page would have been after the cut. Measuring
1555/// the raw records would make the page's size follow a payload the client
1556/// never receives, and a seat with a few multi-MB records would keep one
1557/// turn where the capped page fits many.
1558///
1559/// **The newest turn is served even when it alone is over the budget**: a
1560/// budget that answered with no page would draw the seat empty, and one that
1561/// split the turn would hand a client half of it. `usize::MAX` is no budget.
1562///
1563/// `budget_key` is the `forge.toml` key the budget came from, named on a cut:
1564/// two caps share the default, and a line that did not say which one moved
1565/// would mislead.
1566pub fn page_within(
1567    messages: &[Message],
1568    rendered: &Rendered,
1569    dropped: usize,
1570    dropped_thinking: i64,
1571    before: Option<&str>,
1572    turns: u32,
1573    budget: usize,
1574    budget_key: &'static str,
1575    cap: Option<(usize, &SessionSlot)>,
1576) -> Page {
1577    // A page of no turns ends where it began: its cursor would name the turn it
1578    // already opened at, so a client walking back would ask for the same page
1579    // forever.
1580    let turns = turns.max(1) as usize;
1581    let ranges = turn_ranges(messages, &rendered.turns);
1582    let opens = opens_of(&rendered.turns);
1583
1584    // A cursor names the message the previous page BEGAN at, so the page above
1585    // ends where that one started: the two meet exactly.
1586    //
1587    // **In the conversation's numbering rather than the held list's.**
1588    // `dropped` is what the cap has taken off the front: a cursor written
1589    // before a drop still names the message it meant. A cursor below the
1590    // floor never arrives here - the `more` walk answers those from the
1591    // transcript - and one landing under it is the empty page above rather
1592    // than a window the client did not ask for. A cursor no turn opens at, or
1593    // one this server did not write, keeps the fallback it always had.
1594    let ends_at = match before.and_then(|cursor| cursor.parse::<usize>().ok()) {
1595        None => ranges.len(),
1596        Some(named) => match named.checked_sub(dropped) {
1597            None => 0,
1598            Some(started) => {
1599                opens.iter().position(|&(open, _)| open == started).unwrap_or(ranges.len())
1600            }
1601        },
1602    };
1603
1604    // Only the window is rendered: building every turn to keep a few of them
1605    // would walk and encode the whole conversation on a path a reader hits
1606    // while scrolling.
1607    let window_first = ends_at.saturating_sub(turns);
1608    let (first, mut page_turns) = if budget == usize::MAX {
1609        let page_turns: Vec<TurnWire> = ranges[window_first..ends_at]
1610            .iter()
1611            .map(|&(from, to, key)| turn_wire(messages, from, to, key, &rendered.endings))
1612            .collect();
1613        (window_first, page_turns)
1614    } else {
1615        // From the newest turn back, while the page still fits. The newest
1616        // turn is kept regardless, which is what makes a budget under one
1617        // turn answer with one rather than with none.
1618        let mut kept: Vec<TurnWire> = Vec::new();
1619        let mut total = 0usize;
1620        let mut first = ends_at;
1621        while first > window_first {
1622            let (from, to, key) = ranges[first - 1];
1623            let mut turn = turn_wire(messages, from, to, key, &rendered.endings);
1624            // The page is served bounded, so it is measured bounded: the cap
1625            // is what the wire would carry, and the log below names only the
1626            // turns the cut keeps.
1627            let reports = cap.map_or_else(Vec::new, |(cap, _)| cap_turn(&mut turn, cap));
1628            let bytes = serde_json::to_string(&turn).map_or(0, |text| text.len() + 1);
1629            if !kept.is_empty() && total.saturating_add(bytes) > budget {
1630                break;
1631            }
1632            if let Some((cap, seat)) = cap {
1633                for (id, report) in reports {
1634                    log_record_cap(seat, id.as_deref(), cap, &report);
1635                }
1636            }
1637            total = total.saturating_add(bytes);
1638            kept.push(turn);
1639            first -= 1;
1640        }
1641        // **The cut is named where it happens.** A page that stopped at its
1642        // budget is not a page that ran out of history: its cursor is what
1643        // says the turns above it are still askable, and a reader of the log
1644        // should not have to tell a short page from a finished walk.
1645        if let Some((_, seat)) = cap.filter(|_| first > window_first) {
1646            tracing::debug!(
1647                event_name = "page_cut_by_budget",
1648                slot = %seat.display(),
1649                budget_key,
1650                budget,
1651                turns_kept = kept.len(),
1652                turns_available = ends_at - window_first,
1653                "the page stopped at its byte budget; its cursor names the turns above it",
1654            );
1655        }
1656        kept.reverse();
1657        (first, kept)
1658    };
1659    // After the cut, so the budget still counts the bytes a page used to carry
1660    // and keeps the same whole turns it always did.
1661    for turn in &mut page_turns {
1662        compact_counters(&mut turn.messages);
1663    }
1664    // The debt belongs to the turn open at the window's end, which only a page
1665    // that serves that turn carries. Fold it into the first counter the turn
1666    // still has, so a client's own fold sums the whole turn.
1667    if dropped_thinking != 0
1668        && ends_at == ranges.len()
1669        && let Some(turn) = page_turns.last_mut()
1670        && let Some(counter) = turn.messages.iter_mut().find(|frame| is_counter(frame))
1671    {
1672        let total = grown(counter).saturating_add(dropped_thinking);
1673        if let Some(object) = counter.as_object_mut() {
1674            object.insert("estimated_tokens_delta".to_owned(), Value::from(total));
1675        }
1676    }
1677
1678    // `None` is the real "nothing above this page": a page already opening on
1679    // the conversation's first turn has nothing to walk back to, and that is
1680    // the one case a client stops asking.
1681    let cursor = if first == 0 {
1682        None
1683    } else {
1684        // Written back into the conversation's numbering, which is the
1685        // numbering the client hands it back in.
1686        opens.get(first).map(|&(open, _)| (open + dropped).to_string())
1687    };
1688
1689    Page { turns: page_turns, cursor }
1690}
1691
1692/// How long a request waits for a live seat's first `Connected` before
1693/// answering without a conversation.
1694///
1695/// The fold applies the seal within a frame of it arriving, so this is a
1696/// failure case rather than a pacing mechanism. **What it decides is the
1697/// same for both readers and they answer differently**: a snapshot carries an
1698/// empty conversation, which a client draws as a seat with nothing to show,
1699/// while a page is REFUSED - an empty page carries `cursor: null`, and a
1700/// client reads that as the end of the history rather than as a delay.
1701const SEED_WAIT: std::time::Duration = std::time::Duration::from_millis(500);
1702
1703/// The seat's conversation, answering from its own transcript when the
1704/// stream holds none.
1705///
1706/// **A seat has two sources of history and each answers what it alone can.**
1707/// The transport's held copy is the live one: the fold applies every
1708/// `ChatAppended` the session emits, so a seat that has run in this process
1709/// already has its conversation here. The transcript on disk is what a seat
1710/// LEFT: a sleeping seat has no copy, and reading its file costs no wait
1711/// (measured live 2026-10-12: ~2.6s for a history on disk).
1712///
1713/// So a copy is returned as it stands, and a seat with none is seeded from
1714/// the file. **A copy of another occupant is waited for, never replaced and
1715/// never answered over**: the seal the header moved on from is the fold's to
1716/// apply, and a transcript seed here would be a row subset - the `Result`
1717/// frames a transcript never carries would go with the copy.
1718///
1719/// **The two paths do not cost the same on failure.** A held copy that never
1720/// turns current spends the wait and is then answered as no conversation -
1721/// refused rather than filled from a subset. A seat with no copy pays the
1722/// file read and answers at once with what the read inserted, or with
1723/// nothing when the read's own occupant is stale; a file that holds nothing
1724/// yet falls through to the same bounded wait for its first `Connected`.
1725///
1726/// No lock is held across the wait, and two callers on two cold seats do not
1727/// serialise: each waits on its own seat's arrival, and the notice only says
1728/// that some seat's copy moved.
1729pub async fn conversation_for(
1730    state: &TransportState,
1731    slot: &SessionSlot,
1732    timings: &mut OpenTimings,
1733) -> Option<Arc<Held>> {
1734    // **A copy of another occupant is no copy.** Answering with it would pair
1735    // the new epoch's count with rows it never wrote.
1736    let occupant = state.surface.header(slot).session_id;
1737    let current = |held: &Arc<Held>| held.holds_occupant(occupant.as_ref());
1738    if let Some(held) = state.conversations.get(slot) {
1739        if current(&held) {
1740            return Some(held);
1741        }
1742        // The seal is on its way; the fold's reseed lands on the copy the
1743        // seat already holds and wakes this wait.
1744        return await_current(state, slot, &current, SEED_WAIT).await;
1745    }
1746    // **The file seeds only a seat with NO copy.** `insert` keeps what is
1747    // there, so a copy that appeared while the file was read is the stream's
1748    // and must not be overwritten.
1749    if let Some(held) = seed_from_transcript(state, slot, timings).await {
1750        // **The seed must be the seat's copy too.** Its occupant is the
1751        // read's own resolution, which can lag a header that already moved
1752        // on: that copy would answer for the run it did not read.
1753        return current(&held).then_some(held);
1754    }
1755    // **The file holds nothing yet.** A live session's first `Connected` may
1756    // still be on its way; wait for the fold to apply it, bounded, so a page
1757    // opening on a just-spawned seat draws it rather than an empty
1758    // conversation.
1759    await_current(state, slot, &current, SEED_WAIT).await
1760}
1761
1762/// Wait, up to `within`, for `slot`'s held copy to satisfy `current`, and
1763/// answer it when it does.
1764///
1765/// **The notice is enabled before the first look**, so a copy that moves
1766/// between the look and the wait is still seen on this pass rather than
1767/// costing the caller its whole budget. `None` when no current copy arrives,
1768/// which the caller answers as a seat with no conversation.
1769async fn await_current(
1770    state: &TransportState,
1771    slot: &SessionSlot,
1772    current: &impl Fn(&Arc<Held>) -> bool,
1773    within: std::time::Duration,
1774) -> Option<Arc<Held>> {
1775    let deadline = std::time::Instant::now() + within;
1776    loop {
1777        // **A fresh notice every pass.** `Notified` is fused once it completes,
1778        // and this one fires for any seat: a reused future would return Ready
1779        // on every later poll (the timeout polls it first) and spin this loop
1780        // to its deadline instead of waiting. Enabled before the look, so a
1781        // copy that moves between the look and the wait is still seen.
1782        let seeded = state.conversations.seeded_notice();
1783        tokio::pin!(seeded);
1784        seeded.as_mut().enable();
1785        if let Some(held) = state.conversations.get(slot).filter(current) {
1786            return Some(held);
1787        }
1788        let left = deadline.saturating_duration_since(std::time::Instant::now());
1789        if left.is_zero() {
1790            break;
1791        }
1792        if tokio::time::timeout(left, seeded.as_mut()).await.is_err() {
1793            break;
1794        }
1795    }
1796    let held = state.conversations.get(slot).filter(current);
1797    if held.is_none() {
1798        tracing::debug!(
1799            event_name = "conversation_not_current",
1800            slot = %slot.display(),
1801            waited_ms = within.as_millis(),
1802            "no current conversation for this seat within the wait; the request is answered \
1803             without one, which a client draws the same way it draws a seat that is not \
1804             running",
1805        );
1806    }
1807    held
1808}
1809
1810/// The conversation the seat's transcript holds, for a seat this transport
1811/// holds nothing for.
1812///
1813/// **This is the read a resume performs** (`session_history`), so what it
1814/// seeds is what a `Connected` would bring - only sooner, because the file
1815/// is already written. `None` when the seat records no session at all, or
1816/// its file holds nothing yet: a seat that has never run has no history, and
1817/// an empty conversation would claim exactly that.
1818async fn seed_from_transcript(
1819    state: &TransportState,
1820    slot: &SessionSlot,
1821    timings: &mut OpenTimings,
1822) -> Option<Arc<Held>> {
1823    // A test can hold the seed here, between the cache check that found no
1824    // copy and the read: a `Connected` that lands while this is held is the
1825    // race the insert below is written to lose.
1826    #[cfg(any(test, feature = "testing"))]
1827    state.conversations.pass_the_seed_hold().await;
1828    let at = std::time::Instant::now();
1829    let surface = Arc::clone(&state.surface);
1830    let seat = slot.clone();
1831    let read =
1832        tokio::task::spawn_blocking(move || surface.conversation(&seat, Path::new(""))).await;
1833    let read = read.ok()?;
1834    if read.messages.is_empty() {
1835        return None;
1836    }
1837    let mut conversation =
1838        // A file read, whole: the cap's own drop is what the floor counts.
1839        Conversation::new(read.messages, read.compaction_count, 0);
1840    // The read is the cold open's cost and nothing else's, so it is timed
1841    // where it runs rather than inferred from the conversation's total.
1842    timings.seed_ms = at.elapsed().as_secs_f64() * 1000.0;
1843    // **The copy says which run it holds** (#2256). A transcript seeded while
1844    // the seat ran one occupant must not answer under another: the seal the
1845    // fold has not applied yet leaves this copy standing while the header
1846    // already names the new run.
1847    let occupant = read.session_id.map(forge_primitives::SessionId::new);
1848    conversation.set_occupant(occupant.clone());
1849    // **A copy the stream inserted while the file was read is the live one,
1850    // and it stays.** The seat held none when this read started, so a copy
1851    // that appeared since came from the stream, which carries frames a
1852    // transcript does not.
1853    Some(state.conversations.insert(slot, conversation))
1854}
1855
1856/// How many of a conversation's newest turns a subscribe carries.
1857///
1858/// **A subscribe is not a request for a whole transcript.** A client draws a
1859/// window and asks `more` for what is above it, so handing it the whole
1860/// conversation is a transcript's worth of bytes written per client per
1861/// connect and per refresh - measured at 111.7 MB of socket for four
1862/// refreshes of a 68.9 MB transcript, which is 99% of that connection's
1863/// traffic - for turns it will not draw.
1864///
1865/// The window matches the client's own `SUBSCRIBE_TURNS` (20), which its seat
1866/// record trims to, and the `MORE_TURNS` page a client asks for: the newest
1867/// page a subscribe carries and the newest page a client asks for are the same
1868/// page, so a client that asks anyway is handed what it already holds, which it
1869/// drops by turn key.
1870///
1871/// **It is a ceiling, not the page.** `[server] first_frame_cap_kb` holds the
1872/// same page to its bytes (see [`page_within`]), so a conversation whose
1873/// turns are large carries fewer than this.
1874pub const SUBSCRIBE_TURNS: u32 = 20;
1875
1876/// The slices of one session open, in milliseconds, gathered as its read
1877/// runs (#2374).
1878///
1879/// **What splits a slow switch between this server and the client.** A
1880/// client's own marks say when its click happened and when the first row
1881/// drew; these are what the server was doing in between, so a gap the two
1882/// artefacts have to explain is placed by them rather than guessed at.
1883///
1884/// **The slices are disjoint.** Each is taken around one read on the path,
1885/// in the order the path runs them, so their sum against `open_ms` is the
1886/// work that did not name itself; the seed is the transcript read only a
1887/// cold seat pays, and the conversation total excludes it. The frame's own
1888/// record, `frame_write`, carries `prep_ms` (the record serialised and
1889/// compressed into the bytes) and `write_ms` (the socket's share) - both
1890/// inside `open_ms`, and split out of what the record's own reads would
1891/// otherwise have to explain.
1892#[derive(Debug, Default)]
1893pub struct OpenTimings {
1894    pub seed_ms: f64,
1895    pub conversation_ms: f64,
1896    pub page_ms: f64,
1897    pub encode_ms: f64,
1898    pub prologue_ms: f64,
1899    pub header_ms: f64,
1900    pub catch_up_ms: f64,
1901    pub cards_ms: f64,
1902    pub work_ms: f64,
1903    /// The review read's base refresh: the fetch and the tip it is checked
1904    /// against, waited on with no cap and served off the connection's loop.
1905    pub base_ms: f64,
1906    /// The scan pinned to the sha that refresh resolved.
1907    pub rescan_ms: f64,
1908    pub content_ms: f64,
1909    pub reviews_ms: f64,
1910    pub tree_reviews_ms: f64,
1911    pub state_ms: f64,
1912    pub index_ms: f64,
1913    pub resume_ms: f64,
1914    pub tail_ms: f64,
1915    /// How old the PR attempt behind this record is, in seconds; absent when
1916    /// the scan has never attempted one. The record can carry a PR past its
1917    /// refetch window while the watch refreshes it, and this is what says how
1918    /// stale what a reader drew was.
1919    pub pr_age_s: Option<f64>,
1920}
1921
1922/// One read's share of the open, in milliseconds.
1923fn slice_ms(at: std::time::Instant) -> f64 {
1924    at.elapsed().as_secs_f64() * 1000.0
1925}
1926
1927/// A subject's wire form. The ONE place it is produced.
1928///
1929/// Async because a session's git section is a filesystem read, so the
1930/// working tree's state is awaited rather than guessed at.
1931///
1932/// A seat naming no project forge has loaded is an error rather than an empty
1933/// snapshot, because a client drawing an empty page would read that as a
1934/// broken one rather than as a seat that does not exist.
1935///
1936/// **A project that has simply not started is not that case**: its directory
1937/// resolves from the declaration, so it is answered - with the session's own
1938/// fields empty and a working tree read per read, since no held seat has a
1939/// store to keep one in.
1940pub async fn encode_subject(state: &TransportState, subject: &Subject) -> Result<Value> {
1941    encode_subject_timed(state, subject, false).await.map(|(value, _)| value)
1942}
1943
1944/// [`encode_subject`], carrying the session's own open timings.
1945///
1946/// **Split from the plain encode so the thirty readers that want only the
1947/// value keep their call**: a session's timeline is the open path's own
1948/// business, and it is the only arm that fills one.
1949///
1950/// `review` is the review page's own read: it refreshes the branch diff's
1951/// base and pins the stats and content reads to the sha that refresh found.
1952/// Every other read serves what the stores hold, so a record read never waits
1953/// on the network.
1954pub(crate) async fn encode_subject_timed(
1955    state: &TransportState,
1956    subject: &Subject,
1957    review: bool,
1958) -> Result<(Value, OpenTimings)> {
1959    let surface = &state.surface;
1960    let mut timings = OpenTimings::default();
1961    match subject {
1962        Subject::Home => Ok((serde_json::to_value(home(state, surface).await)?, timings)),
1963        Subject::Board => Ok((serde_json::to_value(board(surface))?, timings)),
1964        Subject::Session(slot) => {
1965            let at = std::time::Instant::now();
1966            let roster = surface.roster();
1967            let Some(cwd) = roster.cwd_for(slot) else {
1968                anyhow::bail!("forge holds no session for {}", slot.display());
1969            };
1970            // The record's reads ARE the seat's first read: the connection
1971            // holds the seat before this encode, so its snapshot takes the
1972            // stores under the seat's gate and the watch's first pass starts
1973            // from what this record carries (#2506).
1974            request_context_usage_if_unreported(surface, slot);
1975            request_mcp_snapshot_if_unreported(surface, slot);
1976            timings.prologue_ms = slice_ms(at);
1977            let wire = session(state, surface, slot, &cwd, &mut timings, review).await?;
1978            // The record's own serialisation, and nothing else: what the
1979            // measurement pass timed as the encode, so the numbers line up.
1980            let at = std::time::Instant::now();
1981            let value = serde_json::to_value(&wire)?;
1982            timings.encode_ms = at.elapsed().as_secs_f64() * 1000.0;
1983            Ok((value, timings))
1984        }
1985        // The pool's own report, scanned here rather than carried in another
1986        // snapshot: it belongs to no seat, and a home snapshot that scanned
1987        // the pool would pay for the walk on every subscribe.
1988        Subject::Usage => Ok((serde_json::to_value(surface.usage().await?)?, timings)),
1989        // The models page's read, off the workspace's own state: no walk,
1990        // no lock held across a scan - the fetch that changes it runs
1991        // elsewhere and lands as its own update.
1992        Subject::DictateModels => Ok((serde_json::to_value(surface.dictate_models())?, timings)),
1993        // The Extensions page's read: one disk scan of the plugin
1994        // inventory per read, plus the PATH checks and the last run.
1995        Subject::Extensions => Ok((serde_json::to_value(extensions(state).await)?, timings)),
1996    }
1997}
1998
1999/// The Extensions page's read, as the wire carries it.
2000async fn extensions(state: &TransportState) -> ExtensionsWire {
2001    let view = state.surface.extensions().await;
2002    ExtensionsWire {
2003        snapshot: view.snapshot,
2004        update_run: view.update_run,
2005        lsp: view
2006            .lsp
2007            .into_iter()
2008            .map(|check| LspCheckWire {
2009                plugin: check.plugin,
2010                server: check.server,
2011                command: check.command,
2012                on_path: check.on_path,
2013            })
2014            .collect(),
2015    }
2016}
2017
2018/// Ask the core for a context reading on `slot` when the seat reports none.
2019///
2020/// The reading exists only once the CLI has computed it, and a view asks
2021/// for the seat it is addressing. A client subscribing to a seat is that same
2022/// act, so the ask belongs on the read that encodes the subject: a seat only a
2023/// client watches would otherwise report nothing for the life of its session,
2024/// and the header would draw the dash an unasked seat draws rather than a bar.
2025///
2026/// Guarded on the reading rather than on the ask having happened, so a seat
2027/// that already reports one is not probed again by every subscribe, reconnect
2028/// and second tab. The answer arrives as
2029/// [`SessionUpdate::ContextUsageSnapshot`](crate::SessionUpdate::ContextUsageSnapshot)
2030/// on the stream the subscriber is already reading.
2031fn request_context_usage_if_unreported(surface: &ViewSurface, slot: &SessionSlot) {
2032    if surface.header(slot).context.percent.is_some() {
2033        return;
2034    }
2035    if let Err(error) = surface.refresh_context_usage(slot) {
2036        tracing::debug!(
2037            event_name = "context_usage_request_failed",
2038            %error,
2039            slot = %slot.display(),
2040            "a seat a client reads reports no context usage and its probe was not requested",
2041        );
2042    }
2043}
2044
2045/// Ask the core for an MCP snapshot on `slot` when the seat reports none.
2046///
2047/// The snapshot exists only once the bridge has answered one, and only a
2048/// view's connect, poll and `/mcp` paths ask for it. A client subscribing
2049/// to a seat is that same act, so the ask belongs on the read that encodes the
2050/// subject: a seat only a client watches would otherwise report `mcp: None`
2051/// for the life of its session, and the strip's MCP row would never draw.
2052///
2053/// Guarded on the reading rather than on the ask having happened, so a seat
2054/// that already reports one - an empty set included, which is a snapshot
2055/// carrying none rather than no snapshot - is not probed again by every
2056/// subscribe, reconnect and second tab. The answer arrives as
2057/// [`SessionUpdate::McpSnapshot`](crate::SessionUpdate::McpSnapshot) on the
2058/// stream the subscriber is already reading. A seat replacement clears the
2059/// snapshot, which is the one re-ask this guard makes on its own: the field
2060/// reads `None` again and the next read asks.
2061fn request_mcp_snapshot_if_unreported(surface: &ViewSurface, slot: &SessionSlot) {
2062    if surface.mcp_servers(slot).is_some() {
2063        return;
2064    }
2065    if let Err(error) = surface.refresh_mcp_snapshot(slot) {
2066        tracing::debug!(
2067            event_name = "mcp_snapshot_request_failed",
2068            %error,
2069            slot = %slot.display(),
2070            "a seat a client reads reports no MCP snapshot and its ask was not requested",
2071        );
2072    }
2073}
2074
2075/// The home's record, from the reads a home-scoped view makes.
2076///
2077/// Async because a row's work state is a filesystem read, cached by the
2078/// shared `WorkCache` the transport holds.
2079async fn home(state: &TransportState, surface: &ViewSurface) -> HomeWire {
2080    let roster = surface.roster();
2081    let accounts = surface.accounts();
2082    let agents = surface.agents();
2083    let connectors = surface.connectors(None);
2084    let dictate = surface.dictate();
2085    let encode = |value: Option<Value>| value;
2086
2087    let mut projects = Vec::with_capacity(roster.projects.len());
2088    for project in &roster.projects {
2089        let seat = SessionSlot::lead(project.org.clone(), project.name.clone());
2090        projects.push(ProjectWire {
2091            work: state.work.snapshot(&seat, &project.path).await,
2092            would_bind: roster.would_bind(&project.key),
2093            chip: roster.chip_for(&project.key),
2094            project: project.clone(),
2095        });
2096    }
2097
2098    // Each seat's own tree, read at the directory `cwd_for` resolves FOR IT:
2099    // the project's path for a lead, the worktree for a git worker. A seat
2100    // forge holds no directory for keeps `None` rather than borrowing the
2101    // project's read, which is what the cell's blank has to mean.
2102    //
2103    // The live snapshot filters the failure mark by what has been shown:
2104    // the same facts the diamond rides, read once for both.
2105    let live = crate::live::Live::lock(&state.live).snapshot();
2106    let mut agent_rows = Vec::with_capacity(agents.all().len());
2107    for agent in agents.all() {
2108        let work = match roster.cwd_for(&agent.slot) {
2109            Some(cwd) => Some(state.work.snapshot(&agent.slot, cwd.as_path()).await),
2110            None => None,
2111        };
2112        let mut row = AgentWire { work, ..AgentWire::from(agent) };
2113        row.failed_turn = row.failed_turn.and_then(|at| live.failed_mark(&agent.slot, at));
2114        agent_rows.push(row);
2115    }
2116
2117    HomeWire {
2118        fleet: surface.fleet(),
2119        accounts,
2120        connectors: ConnectorsWire {
2121            gotify: GotifyWire { connected: connectors.gotify.connected },
2122            slack: SlackWire {
2123                connected_workspaces: connectors.slack.connected_workspaces.into_iter().collect(),
2124                load_failed: connectors.slack.load_failed,
2125            },
2126        },
2127        dictate: DictateWire {
2128            enabled: dictate.enabled,
2129            snapshot: serde_json::to_value(dictate.snapshot).unwrap_or(Value::Null),
2130            models_dir: dictate.models_dir,
2131        },
2132        voice: {
2133            let voice = surface.voices();
2134            VoiceWire { enabled: voice.enabled, models_dir: voice.models_dir }
2135        },
2136        cli_version: surface.cli_version().and_then(|version| serde_json::to_value(version).ok()),
2137        forge_version: crate::FORGE_VERSION.to_owned(),
2138        forge_version_short: crate::FORGE_VERSION_SHORT.to_owned(),
2139        boot_id: boot_id().to_owned(),
2140        service_status: surface.service_status().and_then(|issue| serde_json::to_value(issue).ok()),
2141        // The words the server prints on exit, which is what a client draws:
2142        // the serde tag alone is for matching, and nothing matches on this.
2143        fatal_error: encode(
2144            surface.fatal_error().map(|error| Value::String(error.user_message().to_owned())),
2145        ),
2146        biometric: surface.biometric_enrolments().into(),
2147        vault: surface
2148            .vault_state()
2149            .map(|state| VaultWire { locked: state.locked, idle_minutes: state.idle_minutes }),
2150        agents: agent_rows,
2151        unseen: agents
2152            .all()
2153            .iter()
2154            .map(|row| row.slot.clone())
2155            .filter(|slot| live.unseen.is_unseen(slot))
2156            .collect(),
2157        projects,
2158    }
2159}
2160
2161/// The board's record, from the reads a board-scoped view makes.
2162///
2163/// No filesystem read and no seat hold: the rows, schedules and connector
2164/// sets are the workspace's own state, which is what makes a board read
2165/// cheap next to the home read it left (#2517).
2166fn board(surface: &ViewSurface) -> BoardWire {
2167    let roster = surface.roster();
2168    let mut projects = Vec::with_capacity(roster.projects.len());
2169    for project in &roster.projects {
2170        let subscribed = surface.connectors(Some(&project.name));
2171        projects.push(ProjectBoardWire {
2172            project: ProjectBoardIdentity { org: project.org.clone(), name: project.name.clone() },
2173            rows: surface.board_rows(&project.name),
2174            crons: roster.crons_for_project(&project.name),
2175            connectors: ProjectConnectorsWire {
2176                gotify: subscribed.gotify.subscriptions,
2177                slack: subscribed.slack.subscriptions,
2178            },
2179        });
2180    }
2181    BoardWire { projects }
2182}
2183
2184/// How long an encode waits for the fold loop to catch up before filling the
2185/// gap from the seat's own window.
2186///
2187/// **A scheduling hop, not a stall.** The common case is already caught up
2188/// (the wait is not entered) or one hop behind; the fallback below is what
2189/// keeps the bound from being a way to lose frames.
2190const CATCH_UP_WAIT: std::time::Duration = std::time::Duration::from_millis(250);
2191
2192/// How long a read whose copy needs an unapplied seal waits for the fold
2193/// before refusing.
2194///
2195/// **The window cannot stand in for the fold here.** A seal's history is not
2196/// ringed, so nothing the fill below can reach carries it; only the fold's own
2197/// apply does. That apply lands in milliseconds when the fold is running, so
2198/// this bound is reached only by a fold that is not - and a refusal costs the
2199/// client a whole re-ask (`client/src/session/live.ts`), so the wait is what
2200/// keeps an ordinary lag from paying it.
2201const SEAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
2202
2203/// Why a read could not bring its copy current, which the refusal's text says
2204/// apart: they are different losses and only one is about a number.
2205#[derive(Debug)]
2206enum NotReady {
2207    /// The seat's window no longer reaches back to the copy's front.
2208    Behind,
2209    /// The epoch's seal rows are unapplied, so its history cannot be reached.
2210    AwaitsSeal,
2211}
2212
2213/// Bring `slot`'s held conversation up to `want`, or report that it cannot.
2214///
2215/// **One requirement: the copy an answer carries must hold every frame
2216/// numbered at or below `want`.** The fold loop consumes the fanout in a task
2217/// of its own, so a frame minted before the counter was read can reach the
2218/// copy after it - waited on here, briefly. When the wait runs out the frames
2219/// come from the seat's own window instead, which the fanout feeds directly
2220/// (every frame, subscriber or not) under its two ceilings - a frame count and
2221/// a byte budget, so a heavy seat's window holds fewer than the cap. Only a
2222/// gap wider than that window is refused, loudly and by name: a stuck fold is
2223/// forge's problem, and an answer that pretended otherwise is a row lost in
2224/// silence.
2225async fn catch_up(state: &TransportState, slot: &SessionSlot, want: u64) -> Result<(), NotReady> {
2226    if want == 0 || state.conversations.consumed_for(slot) >= want {
2227        return Ok(());
2228    }
2229    if state.conversations.await_consumed(slot, want, CATCH_UP_WAIT).await {
2230        return Ok(());
2231    }
2232    // **A seal's rows the fold has not applied cannot be stood in for by the
2233    // ring.** They are what the epoch opened on, and no window holds them
2234    // (#2446), so the fill below would answer the new epoch's number without
2235    // them. Waited on here, before the fill lock, for the fold's own mark:
2236    // applying the seal resets it to zero and the frames after it climb back
2237    // to `want`, so reaching `want` means the reseed is in place. The wait is
2238    // what keeps a transient lag from failing the read.
2239    if awaits_seal_history(state, slot)
2240        && state.conversations.await_consumed(slot, want, SEAL_WAIT).await
2241    {
2242        return Ok(());
2243    }
2244    // **The fill runs under the seat's lock, and re-reads the watermark.**
2245    // The fold may have caught up while the wait ran, and it applies the same
2246    // frames if it did: the check, the applies and the mark are one step, so
2247    // a frame crosses exactly once whichever path got there first.
2248    let fill = state.conversations.fill_lock(slot);
2249    let _filling = fill.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2250    if state.conversations.consumed_for(slot) >= want {
2251        return Ok(());
2252    }
2253    let from = state.conversations.consumed_for(slot).saturating_add(1);
2254    let frames = state.surface.seat_frames_between(slot, from, want);
2255    // **A stretch holding only a seal is not a stretch the copy is behind on.**
2256    // The frames that close an epoch are numbered but never ringed, so no
2257    // window can serve them however far back it reaches - and counting them as
2258    // missing would refuse a seat whose newest frame is a release, which is
2259    // every seat a client reconnects to after it went to sleep.
2260    let needed = want
2261        .saturating_sub(from)
2262        .saturating_add(1)
2263        .saturating_sub(state.surface.seat_unringed_between(slot, from, want));
2264    if u64::try_from(frames.len()).unwrap_or(u64::MAX) < needed {
2265        tracing::error!(
2266            event_name = "seat_copy_behind",
2267            slot = %slot.display(),
2268            want,
2269            got = frames.len(),
2270            "the seat's copy is behind the number an answer would carry and its own window no \
2271             longer reaches back; the read is refused rather than answered with a hole",
2272        );
2273        return Err(NotReady::Behind);
2274    }
2275    // **A seat with no copy gets one from the frames themselves, and a copy of
2276    // another occupant is no copy.** The fold holds nothing for a seat nothing
2277    // has connected on, and a seal the fold has not applied yet leaves the
2278    // previous run's copy standing: neither may answer for the number this
2279    // call carries. The stretch materialises a copy - seeded EMPTY, built by
2280    // the applies below, one place a row may enter so nothing doubles - tagged
2281    // with the occupant it is for, and the fold skips these frames afterwards
2282    // by the watermark this marks last.
2283    //
2284    // **Except when the epoch opened on a seal's rows, which the ring cannot
2285    // carry.** A copy built from the frames below would answer the new
2286    // epoch's number without them, and the occupant check cannot catch it:
2287    // the synthetic copy carries the header's own occupant (#2446). Refused
2288    // rather than synthesised; the fold's apply of the seal is what makes the
2289    // read answer.
2290    let occupant = state.surface.header(slot).session_id;
2291    let matching =
2292        state.conversations.get(slot).is_some_and(|held| held.holds_occupant(occupant.as_ref()));
2293    if !matching {
2294        if state.surface.seat_epoch_opened_with_rows(slot) {
2295            tracing::error!(
2296                event_name = "seat_copy_awaits_seal",
2297                slot = %slot.display(),
2298                want,
2299                "the seat's copy is behind a seal the fold has not applied, and the ring cannot \
2300                 carry the history that seal opened on; the read is refused rather than answered \
2301                 with a hole",
2302            );
2303            return Err(NotReady::AwaitsSeal);
2304        }
2305        let mut conversation = crate::transport::conversation::Conversation::empty();
2306        conversation.set_occupant(occupant);
2307        state.conversations.replace(slot, conversation);
2308    }
2309    for frame in &frames {
2310        state.conversations.apply(&frame.update, frame.seq);
2311    }
2312    state.conversations.consumed(slot, want);
2313    Ok(())
2314}
2315
2316/// Whether `slot`'s copy is missing rows its epoch opened on.
2317///
2318/// The mark describes the LATEST seal, so between an occupant change and its
2319/// seal's mint the value is still the previous seal's - which is what notices
2320/// that gap whenever the previous epoch carried rows. A seat whose first
2321/// epoch is pending, or whose previous seal was empty, has no such mark, and a
2322/// read in that mint gap can still synthesise a copy.
2323fn awaits_seal_history(state: &TransportState, slot: &SessionSlot) -> bool {
2324    let occupant = state.surface.header(slot).session_id;
2325    let matching =
2326        state.conversations.get(slot).is_some_and(|held| held.holds_occupant(occupant.as_ref()));
2327    !matching && state.surface.seat_epoch_opened_with_rows(slot)
2328}
2329
2330/// The refresh a record may state as its own check: none when the run
2331/// answered nothing (its leader panicked or was cancelled, or a bound cut
2332/// it), because the words beside that state are forge's own rather than
2333/// git's.
2334fn checked_refresh(
2335    refresh: Option<&forge_workspace::env::git_diff::BaseRefresh>,
2336) -> Option<&forge_workspace::env::git_diff::BaseRefresh> {
2337    refresh.filter(|refresh| !refresh.unanswered && !refresh.cut)
2338}
2339
2340/// Why a record's `diff` could not check its base: the refresh's words when
2341/// it ran, the store's when the refresh answered nothing, and none when no
2342/// refresh ran - the field names git's words, never forge's own.
2343fn diff_base_error<'a>(
2344    refresh: Option<&'a forge_workspace::env::git_diff::BaseRefresh>,
2345    stored_error: Option<&'a str>,
2346) -> Option<&'a str> {
2347    match (refresh, checked_refresh(refresh)) {
2348        (_, Some(refresh)) => refresh.error.as_deref(),
2349        (Some(_), None) => stored_error,
2350        (None, None) => None,
2351    }
2352}
2353
2354/// The base a record's `git` row states. **When the read refreshed, the row
2355/// states the check its `diff` was read against**, never the store's newer
2356/// measurement (a bypass answered while a fetch landed); a read that did not
2357/// refresh, or one whose refresh answered nothing, states what the store
2358/// holds.
2359fn stated_base<'a>(
2360    refresh: Option<&'a forge_workspace::env::git_diff::BaseRefresh>,
2361    stored: Option<forge_primitives::git_diff::BaseState>,
2362    stored_error: Option<&'a str>,
2363) -> (Option<forge_primitives::git_diff::BaseState>, Option<&'a str>) {
2364    match checked_refresh(refresh) {
2365        Some(refresh) => (Some(refresh.state), refresh.error.as_deref()),
2366        None => (stored, stored_error),
2367    }
2368}
2369
2370/// One session's record, from the reads a session-scoped view makes.
2371async fn session(
2372    state: &TransportState,
2373    surface: &ViewSurface,
2374    slot: &SessionSlot,
2375    cwd: &Path,
2376    timings: &mut OpenTimings,
2377    review: bool,
2378) -> Result<SessionWire> {
2379    let at = std::time::Instant::now();
2380    let header = surface.header(slot);
2381    timings.header_ms = slice_ms(at);
2382    // **The number is read BEFORE the copy's frames are.** Every frame minted
2383    // before this read is one the read below must carry; anything minted after
2384    // it carries a number at or above `seq`, which a client applies live rather
2385    // than skipping as already drawn. Read after the read, a frame minted in
2386    // between is in neither - a silently lost row on the reconnect path.
2387    let seq = surface.seat_next_seq(slot);
2388    // **And the copy has to have caught up to it.** The copy is filled by the
2389    // fold loop consuming the same stream in another task, so a frame minted
2390    // before the read can land after it: waited on briefly, and filled from
2391    // the seat's own window when the wait runs out. The answer never names a
2392    // number its copy cannot cover - a refusal is visible, a lost row is not.
2393    let at = std::time::Instant::now();
2394    if let Err(reason) = catch_up(state, slot, seq.saturating_sub(1)).await {
2395        let why = match reason {
2396            NotReady::Behind => "its copy is behind the number an answer would carry",
2397            NotReady::AwaitsSeal => {
2398                "the fold has not applied its epoch's seal, whose history an answer would be \
2399                 missing"
2400            }
2401        };
2402        anyhow::bail!("the seat's stream has not settled for {}; {why}", slot.display());
2403    }
2404    timings.catch_up_ms = slice_ms(at);
2405    // The conversation's share of the record, taken through one blocking task
2406    // so the fold never runs on the reactor or under the lock. A seat with no
2407    // conversation is answered with the empty one.
2408    let at = std::time::Instant::now();
2409    let conversation = conversation_for(state, slot, timings).await;
2410    // **The slices are DISJOINT, and the seed is taken out of this total.**
2411    // The transcript read a cold seat pays is timed inside `conversation_for`,
2412    // so a total over the whole call would carry it twice: the sum the line's
2413    // remainder is read against would go short by the whole read - worst on
2414    // exactly the slow cold open the line exists to explain.
2415    timings.conversation_ms = at.elapsed().as_secs_f64() * 1000.0 - timings.seed_ms;
2416    // The served page's records are bounded here, off the lock like the page
2417    // itself: a mailbox seat's structured records made one page 70 MB (#2374).
2418    let cap = surface.tool_record_cap_bytes();
2419    let first_frame = surface.first_frame_cap_bytes();
2420    let read = match conversation {
2421        Some(held) => {
2422            let seat = slot.clone();
2423            let capped = seat.clone();
2424            let surface = Arc::clone(&state.surface);
2425            tokio::task::spawn_blocking(move || {
2426                let at = std::time::Instant::now();
2427                // **The record's conversation IS a page, cursor and all**
2428                // (#2420): a client seeds its conversation from this answer
2429                // instead of asking `more` for the same page, and the handle
2430                // is what names the history above it.
2431                let (mut page, count, covered) = held.read(|held| {
2432                    let mut page = page_within(
2433                        held.messages(),
2434                        held.rendered(),
2435                        held.dropped(),
2436                        held.dropped_thinking(),
2437                        None,
2438                        SUBSCRIBE_TURNS,
2439                        first_frame,
2440                        "first_frame_cap_kb",
2441                        Some((cap, &capped)),
2442                    );
2443                    floor_cursor(&mut page, held.dropped());
2444                    (page, held.compaction_count(), held.watermark())
2445                });
2446                // **A page cut to a byte budget was capped as it was
2447                // measured**, turn by turn, so this pass would walk every
2448                // record of it for nothing. Only the uncut page still needs
2449                // it.
2450                if first_frame == usize::MAX {
2451                    cap_tool_records(&mut page.turns, cap, &capped);
2452                }
2453                let names = cron_names_of(&surface, &capped, &page.turns);
2454                // **The page's cost is the whole subscribe's**, the cron read
2455                // included: the standing watch reads this number against the
2456                // record it explains, so a read it excludes reads as cheaper
2457                // than the record. The row is annotating the page, but it is
2458                // part of building the answer.
2459                let page_ms = at.elapsed().as_secs_f64() * 1000.0;
2460                (page.turns, page.cursor, count, page_ms, covered, names)
2461            })
2462            .await
2463            .unwrap_or_else(|error| {
2464                tracing::warn!(
2465                    event_name = "transcript_fold_failed",
2466                    %error,
2467                    slot = %seat.display(),
2468                    "the fold did not finish; the record is answered without it",
2469                );
2470                (Vec::new(), None, 0, 0.0, 0, BTreeMap::new())
2471            })
2472        }
2473        None => (Vec::new(), None, 0, 0.0, 0, BTreeMap::new()),
2474    };
2475    let (turns, cursor, compaction_count, page_ms, covered, cron_names) = read;
2476    timings.page_ms = page_ms;
2477    // **The answer's number is the copy's own**, not catch_up's pass: a seal
2478    // can reseed the copy after that check, and a pre-seal number would have
2479    // the client skip the new run's first frames as already drawn (#2443).
2480    let seq = seq.min(covered.saturating_add(1));
2481    // The instance list crosses from the fold that ANNOUNCES it rather than
2482    // from a second walk here: the session task folds a card per frame and
2483    // pushes `SubagentCardsChanged` off the same walk, so this is what that
2484    // fold holds. **It was folded here until the cap landed** - over the whole
2485    // held conversation on every read, which a drop would have shortened: a
2486    // record answering a window of cards over a window of messages is a
2487    // section that shrinks with the cap rather than with the conversation.
2488    let at = std::time::Instant::now();
2489    let instances = surface.subagent_cards(slot);
2490    timings.cards_ms = slice_ms(at);
2491    // The seat's scan, as the loop that owns it last answered it. Nothing is
2492    // read here: the tree is read for a seat somebody is showing, and this
2493    // answers what that read found - the tile's branch, count and PR are one
2494    // instant, so a branch switch cannot render a PR for a branch this record
2495    // does not name.
2496    let at = std::time::Instant::now();
2497    let held = surface.work(slot, cwd, review).await;
2498    timings.work_ms = slice_ms(at);
2499    // **Only the review page's own read checks the base.** An ordinary record
2500    // read serves the stores as they stand - a seat page never waits on the
2501    // network - and the review read answers the last completed check and kicks
2502    // a background refresh. A seat with no session is the exception: no watch
2503    // delivers a kicked check there, so that read waits for its own (#2818).
2504    let at = std::time::Instant::now();
2505    let refresh = if review { surface.refresh_diff_base(slot, &held).await } else { None };
2506    timings.base_ms = slice_ms(at);
2507    // The refresh and the scan taken after it are one read of one base: the
2508    // count, the chain and the files cannot describe two refs.
2509    let at = std::time::Instant::now();
2510    let pin = match &refresh {
2511        Some(refresh) => surface
2512            .diff_scan(slot, &held.cwd, refresh)
2513            .await
2514            .map(|scan| (scan, refresh.base_ref.as_deref())),
2515        None => None,
2516    };
2517    // **A refused pin is re-read locally, never served from the pre-refresh
2518    // store.** The refresh moved the base; the store's count predates it, and
2519    // that count beside content read at the ref the fetch left is the
2520    // two-commits answer this read exists to prevent. The re-read is pinned to
2521    // the sha the refresh resolved and carries its state, so the count, the
2522    // files and the row's `base` all describe that one base; only a refresh
2523    // that resolved no sha falls back to the ref's name for both.
2524    let (held, base_ref) = match pin {
2525        Some((scan, base_ref)) => (scan, base_ref),
2526        None => match &refresh {
2527            Some(refresh) => (
2528                surface
2529                    .work_pinned_local(slot, &held.cwd, refresh.base_ref.as_deref(), Some(refresh))
2530                    .await,
2531                refresh.base_ref.as_deref(),
2532            ),
2533            None => (held, None),
2534        },
2535    };
2536    timings.rescan_ms = slice_ms(at);
2537    // The content read, beside the stats one: bounded, and taken here
2538    // rather than stored with the row because a record read is a cold load,
2539    // a reconnect or a seat swap - not a frame a moving tree pushes.
2540    let base = match &refresh {
2541        // The drawn diff reads the ref the refresh left: the gate's pinned
2542        // scan, or the local re-read at the same sha; only a refresh that
2543        // resolved no sha reads by name. Either way the state the fetch found
2544        // is this diff's state.
2545        Some(refresh) => Some(refresh.state),
2546        // An ordinary read has not checked the base. Saying so is what keeps a
2547        // cached record from drawing its diff as current.
2548        None if held.diff.branch_ahead.is_populated() => {
2549            Some(forge_primitives::git_diff::BaseState::Unverified)
2550        }
2551        None => None,
2552    };
2553    // Why the refresh could not run, in git's own words: the page draws it
2554    // beside the base line rather than only saying the base could not be
2555    // checked. An ordinary read has no refresh and names no failure, and a
2556    // placeholder refresh names the store's own check rather than forge's
2557    // words for a run that never reported.
2558    let base_error = diff_base_error(refresh.as_ref(), held.base_error.as_deref());
2559    let at = std::time::Instant::now();
2560    let diff = scan_content(&held.cwd, &held.diff, base, base_ref, base_error).await;
2561    timings.content_ms = slice_ms(at);
2562    timings.pr_age_s = held
2563        .diff
2564        .pr_fetched_at
2565        .and_then(|at| std::time::SystemTime::now().duration_since(at).ok())
2566        .map(|age| age.as_secs_f64());
2567    let work = work_from_scan(&held.diff, &held.cwd);
2568    // The tree behind the row's depth, from the same held scan and through
2569    // the same constructor the pushed frame goes through - one derivation,
2570    // so a client that applies the update lands on the record's own answer.
2571    let (row_base, row_base_error) =
2572        stated_base(refresh.as_ref(), held.base, held.base_error.as_deref());
2573    let git = git_work_view(&held.diff, row_base, row_base_error);
2574    let branch = work.branch.clone().unwrap_or_default();
2575    let at = std::time::Instant::now();
2576    let reviews = surface.reviews(slot.project(), &branch);
2577    // The whole files for anchors the bounded content cannot place: read
2578    // only for a comment this record's own diff carries the file of, and
2579    // zero reads when every anchor sits inside its hunks.
2580    let whole = match &reviews.threads {
2581        Ok(threads) => {
2582            whole_files_for(
2583                &held.cwd,
2584                surface.document_max_bytes(),
2585                threads,
2586                &diff,
2587                held.diff.default_branch.as_deref(),
2588                // The sha this diff was pinned to, when the read refreshed and
2589                // pinned one; the name stands in when it did not.
2590                base_ref,
2591            )
2592            .await
2593        }
2594        Err(_) => WholeFiles::new(),
2595    };
2596    timings.reviews_ms = slice_ms(at);
2597    // The seat's tree half, placed against the working files: the reads
2598    // are files off disk, so they run off the reactor like the walk below.
2599    // Zero tree threads means zero reads. A panicked read answers Err, so
2600    // the page draws the failure as itself rather than as an empty review.
2601    let at = std::time::Instant::now();
2602    let tree_reviews = {
2603        let surface = std::sync::Arc::clone(&state.surface);
2604        let seat = slot.clone();
2605        tokio::task::spawn_blocking(move || surface.tree_reviews(&seat)).await.unwrap_or_else(
2606            |error| {
2607                tracing::warn!(
2608                    event_name = "tree_reviews_read_failed",
2609                    %error,
2610                    slot = %slot.display(),
2611                    "the seat-tree review read did not finish; the record answers the failure",
2612                );
2613                let why = "the seat tree's reviews could not be read".to_owned();
2614                crate::surface::reviews::TreeReviewsView {
2615                    scope: forge_primitives::review::tree_scope_ref(slot),
2616                    threads: Err(why.clone()),
2617                    reviews: Err(why),
2618                }
2619            },
2620        )
2621    };
2622    timings.tree_reviews_ms = slice_ms(at);
2623    let at = std::time::Instant::now();
2624    let state_at = surface.session(slot, cwd);
2625    timings.state_ms = slice_ms(at);
2626    // The seat's own loop walks the index and pushes the movement, so this
2627    // answers that store - and a seat nothing has walked yet takes the same
2628    // walk under the seat's gate, off the reactor: whichever of the record and
2629    // the first pass gets there first stores, and the other answers it rather
2630    // than walking the same tree again.
2631    let at = std::time::Instant::now();
2632    let file_index = surface.file_index_for_record(slot).await;
2633    timings.index_ms = slice_ms(at);
2634    // **The reads the watch seeds from are all taken, so the mark fires here**
2635    // - after the tree's scan, the index and the process walk, and before the
2636    // record's remaining reads and its serialisation. The first pass starts
2637    // from these stores while this frame is still being built; `processes` is
2638    // read here so the record carries the same walk the pass seeds from.
2639    let at = std::time::Instant::now();
2640    let processes = surface.processes(slot);
2641    surface.note_record_read(slot);
2642
2643    // One read for both halves of the record: the front is the first of the
2644    // list, so the two cannot disagree.
2645    let asks = surface.pending_asks(slot);
2646
2647    // `/resume`'s list, on the record with the composer's other sources: a
2648    // lead reads the boot catalog's group, keeping a row only where its
2649    // transcript is still on disk, and a worker scans its own directory -
2650    // filesystem work both ways, so it is timed like the other reads and kept
2651    // out of the tail slice.
2652    let resume_at = std::time::Instant::now();
2653    let resume_sessions = surface
2654        .resumable_sessions(slot)
2655        .await
2656        .into_iter()
2657        .map(|info| ResumableSessionWire { session_id: info.session_id, summary: info.summary })
2658        .collect();
2659    timings.resume_ms = slice_ms(resume_at);
2660
2661    let wire = SessionWire {
2662        slot: slot.clone(),
2663        file_index,
2664        resume_sessions,
2665        slash_commands: surface.slash_commands(slot),
2666        subagents: surface.subagents(slot),
2667        subagent_instances: instances,
2668        mcp: surface.mcp_servers(slot),
2669        processes,
2670        background_tasks: surface.background_tasks(slot),
2671        monitors: surface.monitors(slot),
2672        terminal: surface.terminal(slot),
2673        pending_ask: asks.first().map(PendingAskWire::from),
2674        pending_asks: asks.iter().map(PendingAskWire::from).collect(),
2675        conversation: ConversationWire { turns, cursor, compaction_count, cron_names },
2676        header: SessionHeaderWire {
2677            session_id: header.session_id.clone(),
2678            seq,
2679            model: header.model.as_ref().and_then(|model| serde_json::to_value(model).ok()),
2680            effort: serde_json::to_value(header.effort).unwrap_or(Value::Null),
2681            permission_mode: header
2682                .permission_mode
2683                .as_ref()
2684                .and_then(|mode| serde_json::to_value(mode).ok()),
2685            context: serde_json::to_value(header.context).unwrap_or(Value::Null),
2686            available_models: header
2687                .available_models
2688                .iter()
2689                .filter_map(|model| serde_json::to_value(model).ok())
2690                .collect(),
2691            turn_in_flight: header.turn_in_flight,
2692        },
2693        reviews: ReviewsWire {
2694            threads: ReadWire::from(reviews.threads.map(|threads| {
2695                reviewed_threads(threads, &diff, held.diff.default_branch.as_deref(), &whole)
2696            })),
2697            reviews: ReadWire::from(reviews.reviews),
2698        },
2699        tree_reviews: ReviewsWire {
2700            threads: ReadWire::from(tree_reviews.threads.map(|threads| {
2701                threads
2702                    .into_iter()
2703                    .map(|held| ReviewedThreadWire {
2704                        thread: held.thread,
2705                        resolution: held.placement.map(placement_wire),
2706                    })
2707                    .collect()
2708            })),
2709            reviews: ReadWire::from(tree_reviews.reviews),
2710        },
2711        state: SessionStateWire {
2712            slot: state_at.slot,
2713            scan_cwd: state_at.scan_cwd,
2714            queue: state_at.queue,
2715            voice_listeners: state_at.voice_listeners,
2716        },
2717        composer: {
2718            let live = crate::live::Live::lock(&state.live).snapshot();
2719            let composer = &live.composer;
2720            ComposerWire {
2721                machine: crate::surface::machine_turn(
2722                    surface.handoff_in_flight(slot),
2723                    composer.compacting(slot),
2724                ),
2725                bind: surface.dictate_bind().label().to_owned(),
2726                mode: surface.dictate_mode().label().to_owned(),
2727                notice: composer.landed(slot).map(|landed| {
2728                    serde_json::json!({
2729                        "kind": "landed",
2730                        "text": landed.text,
2731                        "truncated": landed.truncated,
2732                        "generation": landed.generation,
2733                        "carried": true,
2734                        "surface": landed.surface,
2735                    })
2736                }),
2737            }
2738        },
2739        work,
2740        git,
2741        pr: held.diff.pr,
2742        closes: held.diff.closes,
2743        diff,
2744    };
2745    // The tail clock opened before the process read and the resume read falls
2746    // inside its window, so its slice is the elapsed less that read: the
2747    // numbers a reader subtracts are disjoint, and the glue that remains is
2748    // the record's own assembly.
2749    timings.tail_ms = slice_ms(at) - timings.resume_ms;
2750    Ok(wire)
2751}
2752
2753#[cfg(test)]
2754mod tests {
2755    /// **The queue a view reads is three fields.** The attachments the task
2756    /// keeps for the started row are core-side; a base64 payload on every seat
2757    /// record read is the thing the row frame itself strips (#2440).
2758    #[test]
2759    fn the_served_queue_carries_no_attachment_payload() {
2760        let state = super::SessionStateWire {
2761            slot: forge_primitives::SessionSlot::lead("TestOrg", "proj"),
2762            scan_cwd: std::path::PathBuf::from("/tmp/proj"),
2763            queue: vec![forge_workspace::protocol::QueuedPrompt {
2764                uuid: "p1".to_owned(),
2765                source: forge_workspace::protocol::PromptSource::You,
2766                text: "hello".to_owned(),
2767                attachments: vec![forge_primitives::ImageAttachment {
2768                    data: "iVBORw0KGgo=".to_owned(),
2769                    mime_type: "image/png".to_owned(),
2770                }],
2771            }],
2772            voice_listeners: 0,
2773        };
2774        let encoded = serde_json::to_string(&state).expect("the state encodes");
2775        assert!(
2776            !encoded.contains("iVBORw0KGgo=") && !encoded.contains("\"attachments\""),
2777            "the queue a view reads carries no attachment payload: {encoded}",
2778        );
2779        assert!(
2780            encoded.contains("\"uuid\":\"p1\"") && encoded.contains("\"text\":\"hello\""),
2781            "and carries the row a view draws: {encoded}",
2782        );
2783    }
2784
2785    /// **The read half of the cron pairing rides the page.** A cron row drawn
2786    /// while its delivery frame lands is titled by the schedule; the same row
2787    /// replayed from a transcript after a reload has no frame to read. The
2788    /// conversation read therefore carries the pair for exactly the rows it
2789    /// serves, and this pins that a page of frames gets it (#1815).
2790    #[test]
2791    fn a_conversation_page_carries_the_schedule_each_fired_prompt_came_from() {
2792        let dir = tempfile::tempdir().expect("tempdir");
2793        let support = dir.path().join("app-support");
2794        std::fs::create_dir_all(&support).expect("app-support");
2795        {
2796            // The fleet opens this config dir's own store; seed the same path.
2797            let db_path = forge_workspace::store::path_for(&support, dir.path());
2798            std::fs::create_dir_all(db_path.parent().expect("stores/")).expect("stores/");
2799            let db = forge_workspace::store::Db::open(&db_path).expect("open db");
2800            forge_workspace::store::cron_names::put(
2801                &db,
2802                "p-reload",
2803                &forge_primitives::cron::CronName {
2804                    cron_id: "c1".to_owned(),
2805                    description: Some("Morning summary".to_owned()),
2806                },
2807                std::time::SystemTime::now(),
2808            )
2809            .expect("write the pairing");
2810            forge_workspace::store::cron_names::put(
2811                &db,
2812                "a-1",
2813                &forge_primitives::cron::CronName {
2814                    cron_id: "not-a-fire".to_owned(),
2815                    description: Some("an assistant row's id".to_owned()),
2816                },
2817                std::time::SystemTime::now(),
2818            )
2819            .expect("write a pairing under a non-prompt id too");
2820        }
2821        let fleet =
2822            crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])]).expect("a fleet");
2823        let page = vec![super::TurnWire {
2824            key: None,
2825            messages: vec![
2826                serde_json::json!({
2827                    "type": "user",
2828                    "uuid": "p-reload",
2829                    "message": {
2830                        "role": "user",
2831                        "content": [{ "type": "text", "text": "[Cron]\n\nsummarise overnight CI" }],
2832                    },
2833                }),
2834                serde_json::json!({
2835                    "type": "assistant",
2836                    "uuid": "a-1",
2837                    "message": { "role": "assistant", "content": [] },
2838                }),
2839                serde_json::json!({
2840                    "type": "user",
2841                    "uuid": "p-other",
2842                    "message": {
2843                        "role": "user",
2844                        "content": [{ "type": "text", "text": "my own words" }],
2845                    },
2846                }),
2847            ],
2848        }];
2849
2850        let seat = forge_primitives::SessionSlot::lead("TestOrg", "proj");
2851        let names = super::cron_names_of(&fleet.surface(), &seat, &page);
2852        assert_eq!(
2853            names.get("p-reload").and_then(|name| name.description.as_deref()),
2854            Some("Morning summary"),
2855            "the fired row's schedule rides the read, as its delivery frame once did",
2856        );
2857        assert!(
2858            !names.contains_key("p-other"),
2859            "a page carries the pairings its own fired rows need and no others",
2860        );
2861        assert!(
2862            !names.contains_key("a-1"),
2863            "a pairing stored under an id an ASSISTANT row carries is not a fired prompt, so the \
2864             type filter must keep it out of the map",
2865        );
2866    }
2867
2868    /// The transcript rows one turn leaves: what the user wrote, what the
2869    /// assistant said, and the result that closes it. The result carries a
2870    /// `uuid`, which is what the fold names the turn by - so a turn's report
2871    /// has a key, and a key is what a page's cursor is.
2872    fn a_turns_rows(turn: usize) -> String {
2873        format!(
2874            r#"{{"type":"user","uuid":"u{turn}","message":{{"role":"user","content":"turn {turn}"}}}}
2875{{"type":"assistant","uuid":"a{turn}","message":{{"id":"m{turn}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"reply {turn}"}}]}}}}
2876{{"type":"result","uuid":"r{turn}","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}}"#
2877        )
2878    }
2879
2880    /// **The row states the check its diff was read against.** The store can
2881    /// hold a newer measurement (a bypass answered while a fetch landed), and
2882    /// taking the row from that side would leave the record disagreeing with
2883    /// its own diff.
2884    #[test]
2885    fn a_record_states_one_base_across_its_diff_and_its_row() {
2886        use forge_primitives::git_diff::BaseState;
2887        use forge_workspace::env::git_diff::BaseRefresh;
2888
2889        let refresh = BaseRefresh {
2890            state: BaseState::Unverified,
2891            base_ref: None,
2892            error: None,
2893            cut: false,
2894            unanswered: false,
2895            at: std::time::Instant::now(),
2896        };
2897        assert_eq!(
2898            super::stated_base(Some(&refresh), Some(BaseState::Current), Some("stale words")),
2899            (Some(BaseState::Unverified), None),
2900            "the read's own check is the frame's base, not the store's newer measurement",
2901        );
2902        assert_eq!(
2903            super::stated_base(None, Some(BaseState::Current), Some("last check")),
2904            (Some(BaseState::Current), Some("last check")),
2905            "and a read that did not refresh states what the store holds",
2906        );
2907        assert!(super::checked_refresh(Some(&refresh)).is_some(), "an answered run is a check");
2908        let mut failed = refresh.clone();
2909        failed.error = Some("fatal: could not resolve host".to_owned());
2910        assert_eq!(
2911            super::diff_base_error(Some(&failed), Some("last check")),
2912            Some("fatal: could not resolve host"),
2913            "a run that ran and failed names its own words",
2914        );
2915
2916        for placeholder in [
2917            BaseRefresh {
2918                cut: true,
2919                error: Some("the fetch did not finish inside the 10s it was given".to_owned()),
2920                ..refresh.clone()
2921            },
2922            BaseRefresh {
2923                unanswered: true,
2924                error: Some("the base refresh ended without an answer".to_owned()),
2925                ..refresh.clone()
2926            },
2927        ] {
2928            assert!(
2929                super::checked_refresh(Some(&placeholder)).is_none(),
2930                "a placeholder run is not a check",
2931            );
2932            assert_eq!(
2933                super::stated_base(
2934                    Some(&placeholder),
2935                    Some(BaseState::Current),
2936                    Some("last check")
2937                ),
2938                (Some(BaseState::Current), Some("last check")),
2939                "so the row falls back to the store, never to forge's own words",
2940            );
2941            assert_eq!(
2942                super::diff_base_error(Some(&placeholder), Some("git's own failure")),
2943                Some("git's own failure"),
2944                "and the diff's reason is git's words, never forge's own",
2945            );
2946        }
2947        assert_eq!(
2948            super::diff_base_error(None, Some("last check")),
2949            None,
2950            "a read with no refresh names no failure of its own",
2951        );
2952    }
2953
2954    /// A value whose serialisation fails, which is the only case a message
2955    /// can reach the page as anything but itself.
2956    struct WillNotSerialise;
2957
2958    impl Serialize for WillNotSerialise {
2959        fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
2960            Err(serde::ser::Error::custom("this value cannot be a frame"))
2961        }
2962    }
2963
2964    /// **A null frame throws the fold that receives it.** The page reads
2965    /// `type` off every frame, so a message that will not serialise must
2966    /// arrive as a notice naming the failure rather than as `Value::Null`.
2967    #[test]
2968    fn a_frame_that_will_not_serialise_draws_a_notice_rather_than_null() {
2969        let frame = frame_of(&WillNotSerialise, Some("a-session"));
2970        assert!(
2971            !frame.is_null(),
2972            "the null this replaces is the value the fold throws on: {frame}",
2973        );
2974        assert_eq!(frame.get("type").and_then(Value::as_str), Some("system"), "{frame}");
2975        assert_eq!(
2976            frame.get("subtype").and_then(Value::as_str),
2977            Some("forge_notice"),
2978            "the frame is the notice the client already draws: {frame}",
2979        );
2980        let Some(text) = frame.get("text").and_then(Value::as_str) else {
2981            panic!("the notice says what failed: {frame}");
2982        };
2983        assert!(text.contains("could not be serialised"), "the failure is named: {frame}");
2984
2985        let held = frame_of(&serde_json::json!({ "type": "widget_state", "n": 1 }), None);
2986        assert_eq!(
2987            held,
2988            serde_json::json!({ "type": "widget_state", "n": 1 }),
2989            "a frame that serialises is carried unchanged",
2990        );
2991    }
2992
2993    /// The messages and the fold's own answer for them, over a transcript of
2994    /// `rows`, read the way the transport reads them.
2995    fn a_conversation(rows: &[&str]) -> (Vec<Message>, Rendered) {
2996        let fleet =
2997            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
2998        fleet.seed_transcript("TestOrg", "proj", "lead", rows).expect("the transcript seeds");
2999        let seat = fixture_seat();
3000        let surface = fleet.surface();
3001        let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
3002
3003        let messages = surface.conversation(&seat, &cwd).messages;
3004        let rendered = crate::transcript::render(&messages, false);
3005        (messages, rendered)
3006    }
3007
3008    /// `turns` finished turns, as the transcript rows they are.
3009    fn turns_of(turns: usize) -> Vec<String> {
3010        (0..turns).map(a_turns_rows).collect()
3011    }
3012
3013    /// The words a turn opened on, read off the messages the page carried.
3014    ///
3015    /// A turn's identity is its messages now, so this is what a reader checks
3016    /// one by: the evidence cannot be a position, because `page` hands back
3017    /// values and no value is ever `ptr::eq` to the original.
3018    fn opened_on(turn: &TurnWire) -> String {
3019        turn.messages
3020            .iter()
3021            .find_map(|frame| frame["message"]["content"][0]["text"].as_str().map(str::to_owned))
3022            .unwrap_or_default()
3023    }
3024
3025    /// The words every turn of a conversation opened on, oldest first.
3026    fn every_turn(messages: &[Message], rendered: &Rendered) -> Vec<String> {
3027        page(messages, rendered, 0, 0, None, u32::MAX).turns.iter().map(opened_on).collect()
3028    }
3029
3030    /// The bytes a page's turns occupy on the wire, which is what a budget
3031    /// counts.
3032    fn wire_bytes(turns: &[TurnWire]) -> usize {
3033        turns
3034            .iter()
3035            .map(|turn| serde_json::to_string(turn).expect("a turn serialises").len() + 1)
3036            .sum()
3037    }
3038
3039    /// **A first page held to a byte budget is the newest WHOLE turns that
3040    /// fit, and it is cut nowhere else.** No turn is split, the order is the
3041    /// conversation's, and the cursor still names the message the first kept
3042    /// turn opened at - so the page above it is the page above that turn.
3043    #[test]
3044    fn a_page_over_the_budget_is_the_newest_whole_turns_that_fit() {
3045        let rows = turns_of(8);
3046        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3047        let (messages, rendered) = a_conversation(&borrowed);
3048        let every = page(&messages, &rendered, 0, 0, None, u32::MAX);
3049        let newest_two = &every.turns[every.turns.len() - 2..];
3050        let budget = wire_bytes(newest_two);
3051
3052        let bounded =
3053            page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, budget, "test", None);
3054
3055        assert_eq!(
3056            bounded.turns.iter().map(opened_on).collect::<Vec<_>>(),
3057            newest_two.iter().map(opened_on).collect::<Vec<_>>(),
3058            "the budget keeps the newest whole turns and nothing below them",
3059        );
3060        assert_eq!(
3061            bounded.cursor,
3062            page(&messages, &rendered, 0, 0, None, 2).cursor,
3063            "the handle above the page names the message the first kept turn opened at",
3064        );
3065    }
3066
3067    /// **A newest turn larger than the whole budget is still served.** A
3068    /// budget that answered with no page would be a seat drawn empty, and one
3069    /// that split the turn would hand a client the tail of a turn with no way
3070    /// to tell that is what it has.
3071    #[test]
3072    fn a_page_keeps_the_newest_turn_when_it_alone_is_over_the_budget() {
3073        let rows = turns_of(3);
3074        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3075        let (messages, rendered) = a_conversation(&borrowed);
3076        let every = every_turn(&messages, &rendered);
3077
3078        let bounded =
3079            page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, 1, "test", None);
3080
3081        assert_eq!(bounded.turns.len(), 1, "a budget under one turn still answers with one");
3082        assert_eq!(opened_on(&bounded.turns[0]), every[every.len() - 1], "and it is the newest");
3083        assert!(bounded.cursor.is_some(), "with history above it to ask for");
3084    }
3085
3086    /// **A budget larger than the page changes nothing.** The count is what a
3087    /// client asked for, and the bytes only ever cut the page it fits in.
3088    #[test]
3089    fn a_page_under_the_budget_is_the_page_its_count_asks_for() {
3090        let rows = turns_of(30);
3091        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3092        let (messages, rendered) = a_conversation(&borrowed);
3093        let counted = page(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS);
3094        let budget = wire_bytes(&counted.turns);
3095
3096        let bounded =
3097            page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, budget, "test", None);
3098
3099        assert_eq!(
3100            bounded.turns.len(),
3101            counted.turns.len(),
3102            "a page that fits its budget is the page the count asked for",
3103        );
3104        assert_eq!(bounded.cursor, counted.cursor, "on the same handle");
3105    }
3106
3107    /// A wire frame as a page carries it.
3108    fn a_frame(text: &str) -> Message {
3109        serde_json::from_str(text).expect("a wire frame decodes")
3110    }
3111
3112    /// A prompt, which is what opens a turn.
3113    fn opens_turn() -> Message {
3114        opens_turn_at(1)
3115    }
3116
3117    /// The same, under an id and a clock of its own.
3118    fn opens_turn_at(seq: u32) -> Message {
3119        a_frame(&format!(
3120            r#"{{"type":"user","uuid":"u-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:{seq:02}.000000Z","message":{{"role":"user","content":[{{"type":"text","text":"turn {seq}"}}]}}}}"#
3121        ))
3122    }
3123
3124    /// The result that closes a turn.
3125    fn ends_turn(seq: u32) -> Message {
3126        a_frame(&format!(
3127            r#"{{"type":"result","uuid":"r-{seq}","session_id":"s","subtype":"success","is_error":false,"duration_ms":1,"duration_api_ms":1,"num_turns":1}}"#
3128        ))
3129    }
3130
3131    /// One thinking-token counter, as the CLI emits them.
3132    fn counter(seq: u32, delta: i64) -> Message {
3133        counter_of(seq, delta, None)
3134    }
3135
3136    /// The same, under a sub-agent's dispatch when `dispatch` names one.
3137    fn counter_of(seq: u32, delta: i64, dispatch: Option<&str>) -> Message {
3138        let parent =
3139            dispatch.map_or_else(String::new, |id| format!(r#","parent_tool_use_id":"{id}""#));
3140        a_frame(&format!(
3141            r#"{{"type":"system","subtype":"thinking_tokens","estimated_tokens":{delta},"estimated_tokens_delta":{delta},"uuid":"t-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:00.{seq:06}Z"{parent}}}"#
3142        ))
3143    }
3144
3145    /// An assistant message, which draws a row of its own between runs.
3146    fn says(seq: u32) -> Message {
3147        a_frame(&format!(
3148            r#"{{"type":"assistant","uuid":"a-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:00.{seq:06}Z","message":{{"id":"m-{seq}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"reply"}}]}}}}"#
3149        ))
3150    }
3151
3152    /// A tool result, which draws nothing and is no counter either.
3153    fn answers(seq: u32) -> Message {
3154        a_frame(&format!(
3155            r#"{{"type":"user","uuid":"r-{seq}","session_id":"s","timestamp":"2026-10-24T09:00:00.{seq:06}Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"call-1","content":"ok"}}]}}}}"#
3156        ))
3157    }
3158
3159    /// The counters a page's frames carry, as `(delta, folded_frames)`.
3160    fn counters_on(page: &Page) -> Vec<(i64, Option<u64>)> {
3161        page.turns
3162            .iter()
3163            .flat_map(|turn| turn.messages.iter())
3164            .filter(|frame| frame["subtype"] == "thinking_tokens")
3165            .map(|frame| {
3166                (
3167                    frame["estimated_tokens_delta"].as_i64().expect("a delta"),
3168                    frame["folded_frames"].as_u64(),
3169                )
3170            })
3171            .collect()
3172    }
3173
3174    /// **A turn's counter storm is one frame per run on a page.**
3175    ///
3176    /// The fold reads a counter for its growth and its stamp and draws no row
3177    /// for it, so the page carries the run's sum and newest stamp once - and
3178    /// the figure a reader sees is the same number either way. A run of one is
3179    /// left exactly as the CLI sent it.
3180    #[test]
3181    fn a_turns_counter_runs_are_one_frame_each_on_a_page() {
3182        let messages = vec![
3183            opens_turn(),
3184            counter(1, 10),
3185            counter(2, 20),
3186            counter(3, 5),
3187            says(4),
3188            counter(5, 7),
3189            counter(6, 3),
3190            answers(7),
3191            counter(8, 9),
3192        ];
3193        let rendered = crate::transcript::render(&messages, true);
3194
3195        let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3196
3197        assert_eq!(page.turns.len(), 1, "the frames are one live turn");
3198        let served = &page.turns[0].messages;
3199        assert_eq!(served.len(), 6, "nine frames, three runs folded: {served:?}");
3200        assert_eq!(
3201            counters_on(&page),
3202            vec![(35, Some(3)), (10, Some(2)), (9, None)],
3203            "each run is its summed delta and its count, and a run of one is untouched",
3204        );
3205        let first = served.iter().find(|frame| frame["folded_frames"] == 3).expect("the first run");
3206        assert_eq!(first["uuid"], "t-3", "the surviving frame is the newest of its run");
3207        assert_eq!(
3208            first["folded_from"], "2026-10-24T09:00:00.000001Z",
3209            "and it names where its run starts, so a landing knows which counters it covers",
3210        );
3211        assert_eq!(
3212            first["timestamp"], "2026-10-24T09:00:00.000003Z",
3213            "so the turn's clock reads the newest stamp, as it did over the raw run",
3214        );
3215        assert_eq!(
3216            first["estimated_tokens"], 5,
3217            "the running value is the newest frame's own, the same exchange the live fold makes",
3218        );
3219        assert_eq!(
3220            served.last().map(|frame| &frame["timestamp"]),
3221            Some(&serde_json::json!("2026-10-24T09:00:00.000008Z")),
3222            "the turn still ends on the instant its newest frame was stamped",
3223        );
3224    }
3225
3226    /// **A run is one dispatch's.** A sub-agent's counters are the subagents
3227    /// surface's, and the chat fold skips them whole - folding them into the
3228    /// turn's own run, or into another sub-agent's, would move the running
3229    /// figure and the clock.
3230    #[test]
3231    fn a_dispatched_counters_do_not_fold_into_the_turns_own() {
3232        let messages = vec![
3233            opens_turn(),
3234            counter_of(1, 10, Some("tool-1")),
3235            counter_of(2, 20, Some("tool-1")),
3236            counter_of(3, 7, Some("tool-2")),
3237            counter(4, 5),
3238        ];
3239        let rendered = crate::transcript::render(&messages, true);
3240
3241        let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3242
3243        assert_eq!(
3244            counters_on(&page),
3245            vec![(30, Some(2)), (7, None), (5, None)],
3246            "each dispatch keeps its own run, and the turn's own counter stays its own",
3247        );
3248        let folded = page.turns[0]
3249            .messages
3250            .iter()
3251            .find(|frame| frame["folded_frames"] == 2)
3252            .expect("the first dispatch's run");
3253        assert_eq!(folded["parent_tool_use_id"], "tool-1", "under the dispatch it ran as");
3254        assert_eq!(
3255            folded["folded_from"], "2026-10-24T09:00:00.000001Z",
3256            "and its range is its own run's, not the turn's",
3257        );
3258    }
3259
3260    /// **A page above the newest turn carries no cut debt.** The debt belongs
3261    /// to the turn open at the window's end, so a page that stops below it
3262    /// leaves every counter as it was.
3263    #[test]
3264    fn a_before_page_does_not_carry_the_cut_debt() {
3265        let messages =
3266            vec![opens_turn_at(1), counter(2, 10), ends_turn(3), opens_turn_at(4), counter(5, 20)];
3267        let rendered = crate::transcript::render(&messages, false);
3268        let newest = page_within(&messages, &rendered, 0, 1_000, None, 1, usize::MAX, "test", None);
3269        assert_eq!(counters_on(&newest), vec![(1_020, None)], "the newest page carries the debt");
3270        let cursor = newest.cursor.expect("a page above the newest turn");
3271        let above =
3272            page_within(&messages, &rendered, 0, 1_000, Some(&cursor), 2, usize::MAX, "test", None);
3273
3274        assert_eq!(
3275            counters_on(&above),
3276            vec![(10, None)],
3277            "the page above the newest turn serves its own counters, not the debt",
3278        );
3279    }
3280
3281    /// **The debt lands on the newest turn only.** A page carrying several
3282    /// turns folds the cut's growth into the first counter of the turn open at
3283    /// its end, and leaves every older turn's counters alone.
3284    #[test]
3285    fn the_cut_debt_lands_on_the_newest_turn_only() {
3286        let messages =
3287            vec![opens_turn_at(1), counter(2, 10), ends_turn(3), opens_turn_at(4), counter(5, 20)];
3288        let rendered = crate::transcript::render(&messages, false);
3289        let served =
3290            page_within(&messages, &rendered, 0, 1_000, None, u32::MAX, usize::MAX, "test", None);
3291
3292        assert_eq!(
3293            counters_on(&served),
3294            vec![(10, None), (1_020, None)],
3295            "the older turn keeps its own counter and the newest carries the debt once",
3296        );
3297    }
3298
3299    /// **A leading run with no proven start is never folded.** A turn whose
3300    /// first carried frame is a counter is the window's front cutting inside
3301    /// a running turn, so the run's true start is not in the page - its
3302    /// counters are served as themselves, and a later run still folds.
3303    #[test]
3304    fn a_leading_counter_run_of_a_cut_turn_is_not_folded() {
3305        let messages = vec![
3306            counter(1, 10),
3307            counter(2, 20),
3308            says(3),
3309            counter(4, 7),
3310            counter(5, 3),
3311            answers(6),
3312            counter(7, 5),
3313        ];
3314        let rendered = crate::transcript::render(&messages, true);
3315
3316        let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3317
3318        assert_eq!(
3319            counters_on(&page),
3320            vec![(10, None), (20, None), (10, Some(2)), (5, None)],
3321            "the leading pair stays raw and the run after the assistant folds",
3322        );
3323    }
3324
3325    /// **A frame with no stamp breaks a run.** A fold's coverage is a stamp
3326    /// range, so a frame that cannot be placed is served as itself and the
3327    /// stamped frames either side fold among themselves.
3328    #[test]
3329    fn a_counter_frame_with_no_stamp_breaks_the_run() {
3330        let unstamped = |seq: u32, delta: i64| {
3331            a_frame(&format!(
3332                r#"{{"type":"system","subtype":"thinking_tokens","estimated_tokens":{delta},"estimated_tokens_delta":{delta},"uuid":"t-{seq}","session_id":"s"}}"#
3333            ))
3334        };
3335        let messages = vec![opens_turn(), unstamped(1, 10), counter(2, 20), counter(3, 5)];
3336        let rendered = crate::transcript::render(&messages, true);
3337
3338        let page = page(&messages, &rendered, 0, 0, None, u32::MAX);
3339
3340        assert_eq!(
3341            counters_on(&page),
3342            vec![(10, None), (25, Some(2))],
3343            "the unstamped frame is served, and the stamped pair after it folds",
3344        );
3345        let folded = page.turns[0]
3346            .messages
3347            .iter()
3348            .find(|frame| frame["folded_frames"] == 2)
3349            .expect("the stamped pair");
3350        assert_eq!(
3351            folded["folded_from"], "2026-10-24T09:00:00.000002Z",
3352            "its range starts at the first frame it can place",
3353        );
3354    }
3355
3356    /// **The budget still counts the bytes a page carried before the fold.**
3357    /// Folding after the cut is what keeps a capped page's turns the same as
3358    /// they were; folding first would fit more of them and change what a page
3359    /// draws.
3360    #[test]
3361    fn the_budget_cuts_on_the_bytes_a_page_carried_before_the_fold() {
3362        let mut messages = Vec::new();
3363        let mut seq = 0;
3364        for turn in 1..=4 {
3365            messages.push(opens_turn_at(turn));
3366            for _ in 0..40 {
3367                seq += 1;
3368                messages.push(counter(seq, 10));
3369            }
3370            messages.push(ends_turn(turn));
3371        }
3372        let rendered = crate::transcript::render(&messages, false);
3373        let ranges = turn_ranges(&messages, &rendered.turns);
3374        let raw_turn = |&(from, to, key): &(usize, usize, Option<&str>)| {
3375            serde_json::to_string(&turn_wire(&messages, from, to, key, &rendered.endings))
3376                .expect("a turn serialises")
3377                .len()
3378                + 1
3379        };
3380        let newest_two: usize = ranges[ranges.len() - 2..].iter().map(raw_turn).sum();
3381
3382        let bounded = page_within(
3383            &messages,
3384            &rendered,
3385            0,
3386            0,
3387            None,
3388            SUBSCRIBE_TURNS,
3389            newest_two,
3390            "test",
3391            None,
3392        );
3393
3394        assert_eq!(bounded.turns.len(), 2, "the same two turns the raw budget kept");
3395        assert!(
3396            wire_bytes(&bounded.turns) < newest_two,
3397            "and the page is served folded: {} bytes under a {newest_two}-byte cut",
3398            wire_bytes(&bounded.turns),
3399        );
3400    }
3401
3402    /// A turn whose structured tool result is `pad` bytes of payload.
3403    fn a_big_result_rows(turn: usize, pad: usize) -> String {
3404        format!(
3405            r#"{{"type":"user","uuid":"u{turn}","message":{{"role":"user","content":"turn {turn}"}}}}
3406{{"type":"assistant","uuid":"a{turn}","message":{{"id":"m{turn}","role":"assistant","model":"claude-opus-5","content":[{{"type":"text","text":"reply {turn}"}}]}}}}
3407{{"type":"user","uuid":"t{turn}","session_id":"s","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"call-{turn}","content":"ok"}}]}},"toolUseResult":{{"payload":"{}"}}}}
3408{{"type":"result","uuid":"r{turn}","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}}"#,
3409            "x".repeat(pad),
3410        )
3411    }
3412
3413    /// **The budget counts what the cap serves, not the raw record.** A
3414    /// seat with a few multi-MB records would otherwise keep one turn where
3415    /// the capped page fits several, and the first frame's size would follow
3416    /// a payload the client never receives.
3417    #[test]
3418    fn the_budget_counts_the_capped_record_it_serves() {
3419        let rows: Vec<String> = (0..4).map(|turn| a_big_result_rows(turn, 20_000)).collect();
3420        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3421        let (messages, rendered) = a_conversation(&borrowed);
3422        let seat = fixture_seat();
3423
3424        let raw =
3425            page_within(&messages, &rendered, 0, 0, None, SUBSCRIBE_TURNS, 2_048, "test", None);
3426        assert_eq!(raw.turns.len(), 1, "a raw record of 20 KB leaves one turn in 2 KB");
3427
3428        let capped = page_within(
3429            &messages,
3430            &rendered,
3431            0,
3432            0,
3433            None,
3434            SUBSCRIBE_TURNS,
3435            2_048,
3436            "test",
3437            Some((1_024, &seat)),
3438        );
3439        assert!(
3440            capped.turns.len() > 1,
3441            "the capped records are small, so the same budget fits more: {} turns",
3442            capped.turns.len(),
3443        );
3444    }
3445
3446    /// How many task endings the frames name `call` in.
3447    fn endings_in(turn: &TurnWire, call: &str) -> usize {
3448        turn.messages
3449            .iter()
3450            .filter(|frame| {
3451                frame["message"]["content"].as_array().is_some_and(|blocks| {
3452                    blocks.iter().any(|block| {
3453                        block["type"] == "queued_command"
3454                            && block["commandMode"] == "task-notification"
3455                            && block["prompt"].as_str().is_some_and(|text| text.contains(call))
3456                    })
3457                })
3458            })
3459            .count()
3460    }
3461
3462    /// Whether a turn's frames make the call `id`.
3463    fn holds_call(turn: &TurnWire, id: &str) -> bool {
3464        turn.messages.iter().any(|frame| {
3465            frame["message"]["content"].as_array().is_some_and(|blocks| {
3466                blocks.iter().any(|block| {
3467                    matches!(block["type"].as_str(), Some("tool_use" | "server_tool_use"))
3468                        && block["id"] == id
3469                })
3470            })
3471        })
3472    }
3473
3474    /// A page's client folds one turn at a time, so an ending that sits in
3475    /// another turn is an ending it cannot reach - and about three notices in
3476    /// ten on this machine sit exactly there.
3477    ///
3478    /// Both carriers are carried into the call's turn: the copy is a frame of
3479    /// its own, and the row the CLI persisted stays where it is.
3480    #[test]
3481    fn the_ending_is_carried_into_the_call_turn() {
3482        for (rows, call, said) in [
3483            (
3484                crate::fixtures::SAME_TURN_USER_ROW,
3485                "call_da4c7ee117d14d48ba99e036",
3486                "Background command \"Watch the account-lifecycle CI run\" failed with exit code 1",
3487            ),
3488            (
3489                crate::fixtures::CROSS_TURN_ATTACHMENT,
3490                "call_17c05f64497746b0ac450728",
3491                "Background command \"Restart the harness with a long terminate window\" failed with exit code 100",
3492            ),
3493        ] {
3494            let (messages, rendered) = a_conversation(rows);
3495            let turns = all_turns(&messages, &rendered);
3496
3497            // **One turn holds the call, so one turn can end it.** A
3498            // cross-turn notice is carried as well as kept, so the
3499            // conversation holds the ending twice - the row the CLI wrote it
3500            // in and the copy - and only the copy is inside a turn that makes
3501            // the call.
3502            let holding: Vec<&TurnWire> =
3503                turns.iter().filter(|turn| holds_call(turn, call)).collect();
3504            assert_eq!(holding.len(), 1, "one turn holds {call}, so one can end it");
3505            let held = holding[0];
3506            assert!(
3507                endings_in(held, call) > 0,
3508                "and the ending rides that turn, where a per-turn fold reads it",
3509            );
3510            assert!(
3511                held.messages.iter().any(|frame| {
3512                    frame["message"]["content"].as_array().is_some_and(|blocks| {
3513                        blocks.iter().any(|block| {
3514                            block["type"] == "queued_command"
3515                                && block["prompt"].as_str().is_some_and(|text| text.contains(said))
3516                        })
3517                    })
3518                }),
3519                "reaching the wire as the block a view reads, not as the row's own text",
3520            );
3521            assert!(
3522                turns.iter().flat_map(|turn| turn.messages.iter()).all(|frame| {
3523                    frame["message"]["content"].as_array().is_none_or(|blocks| {
3524                        blocks.iter().all(|block| {
3525                            !(block["type"] == "text"
3526                                && block["text"]
3527                                    .as_str()
3528                                    .is_some_and(|text| text.contains("<task-notification>")))
3529                        })
3530                    })
3531                }),
3532                "and no turn carries the raw notice as the reader's own words",
3533            );
3534
3535            // **And the carry survives a page, which is the path a client
3536            // walking history actually reads.** `page` cuts its own window
3537            // from the same fold, and a carry that only `all_turns` made would
3538            // leave the ending unreachable where scrolling looks for it - a
3539            // hole no test above this line would show.
3540            let mut before: Option<String> = None;
3541            let mut held_on_a_page = 0_usize;
3542            let mut ended_where_held = 0_usize;
3543            loop {
3544                let page = page(&messages, &rendered, 0, 0, before.as_deref(), 1);
3545                for turn in &page.turns {
3546                    if holds_call(turn, call) {
3547                        held_on_a_page += 1;
3548                        if endings_in(turn, call) > 0 {
3549                            ended_where_held += 1;
3550                        }
3551                    }
3552                }
3553                match page.cursor {
3554                    Some(cursor) if Some(&cursor) != before.as_ref() => before = Some(cursor),
3555                    _ => break,
3556                }
3557            }
3558            assert!(held_on_a_page > 0, "the walk reaches the turn that holds the call");
3559            assert_eq!(
3560                ended_where_held, held_on_a_page,
3561                "and every copy of that turn a page carries ends it",
3562            );
3563        }
3564    }
3565
3566    /// **Every row a page serves carries the stamp of the file row it came
3567    /// from**, for every kind a turn holds: the prompt, a hoisted queued
3568    /// command, an assistant message, the hook summary and the compaction
3569    /// boundary.
3570    ///
3571    /// The stamp is what a page's clock is read from - an open page anchors a
3572    /// running turn on the first row carrying one - so a kind that reaches the
3573    /// wire without its own stamp is a turn that draws no age, or the wrong
3574    /// one, and nothing downstream can recover a fact the read dropped.
3575    #[test]
3576    fn every_kind_a_turn_holds_reaches_a_page_stamped() {
3577        let (messages, rendered) = a_conversation(&[
3578            r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"uuid":"u1","sessionId":"s","timestamp":"2026-10-15T05:18:05.217Z"}"#,
3579            r#"{"type":"attachment","attachment":{"type":"queued_command","prompt":"queued words","commandMode":"prompt"},"uuid":"q1","sessionId":"s","timestamp":"2026-10-15T05:19:00.100Z"}"#,
3580            r#"{"type":"queue-operation","operation":"remove","timestamp":"2026-10-15T05:19:03.000Z","sessionId":"s","content":"queued words"}"#,
3581            r#"{"type":"assistant","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"working"}]},"uuid":"a1","sessionId":"s","timestamp":"2026-10-15T05:19:05.544Z"}"#,
3582            r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[],"hookErrors":[],"hasOutput":false,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"tu1","uuid":"h1","sessionId":"s","timestamp":"2026-10-15T05:19:40.632Z"}"#,
3583            r#"{"type":"system","subtype":"compact_boundary","uuid":"b1","sessionId":"s","compactMetadata":{"trigger":"auto","preTokens":1018466,"postTokens":19526},"timestamp":"2026-10-15T05:20:11.004Z"}"#,
3584        ]);
3585        let served = page(&messages, &rendered, 0, 0, None, u32::MAX);
3586
3587        let frames: Vec<&Value> =
3588            served.turns.iter().flat_map(|turn| turn.messages.iter()).collect();
3589        let kinds: Vec<String> = frames
3590            .iter()
3591            .map(|frame| {
3592                let subtype = frame["subtype"].as_str().unwrap_or("");
3593                if subtype.is_empty() {
3594                    frame["type"].as_str().unwrap_or("?").to_owned()
3595                } else {
3596                    format!("{}/{subtype}", frame["type"].as_str().unwrap_or("?"))
3597                }
3598            })
3599            .collect();
3600        assert_eq!(
3601            kinds,
3602            vec![
3603                "user",
3604                "user",
3605                "assistant",
3606                "system/stop_hook_summary",
3607                "system/compact_boundary",
3608            ],
3609            "the page carries every kind the read kept - the denominator this test asserts over",
3610        );
3611
3612        let stamps: Vec<Option<&str>> =
3613            frames.iter().map(|frame| frame["timestamp"].as_str()).collect();
3614        assert_eq!(
3615            stamps,
3616            vec![
3617                Some("2026-10-15T05:18:05.217Z"),
3618                Some("2026-10-15T05:19:03.000Z"),
3619                Some("2026-10-15T05:19:05.544Z"),
3620                Some("2026-10-15T05:19:40.632Z"),
3621                Some("2026-10-15T05:20:11.004Z"),
3622            ],
3623            "each row carries the row's own stamp, in the row's own order",
3624        );
3625    }
3626
3627    /// A page of no turns ended where it began - an empty page whose cursor
3628    /// named the turn it had already opened at - so a client walking back
3629    /// asked for it forever.
3630    #[test]
3631    fn a_page_of_no_turns_still_walks_backwards() {
3632        let rows = turns_of(6);
3633        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3634        let (messages, rendered) = a_conversation(&borrowed);
3635
3636        // Reached the way a client reaches one, from the cursor below it.
3637        let lower = page(&messages, &rendered, 0, 0, None, 2);
3638        let cursor = lower.cursor.expect("there is a page above this one");
3639
3640        let above = page(&messages, &rendered, 0, 0, Some(&cursor), 0);
3641
3642        assert!(!above.turns.is_empty(), "a page carries turns rather than none at all");
3643        assert_ne!(
3644            above.cursor.as_deref(),
3645            Some(cursor.as_str()),
3646            "and it moves rather than naming the turn it already opened at",
3647        );
3648    }
3649
3650    /// The conversation's opening rows come before its first turn, so a page
3651    /// starting at that turn leaves them above every page, where no walk
3652    /// reaches them.
3653    #[test]
3654    fn the_rows_before_the_first_turn_ride_the_first_page() {
3655        let (messages, rendered) = a_conversation(&[
3656            r#"{"type":"assistant","uuid":"a0","message":{"id":"m0","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"a delivery arrived"}]}}"#,
3657            r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"hello"},"session_id":"s"}"#,
3658            r#"{"type":"result","uuid":"r1","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3659        ]);
3660
3661        let first = page(&messages, &rendered, 0, 0, None, 10);
3662
3663        assert_eq!(first.turns.len(), 1, "precondition: this conversation is one turn");
3664        assert_eq!(
3665            first.turns[0].messages.first().map(|frame| frame["uuid"].clone()),
3666            Some(Value::String("a0".to_owned())),
3667            "the row before the first turn rides that turn rather than sitting above every page: \
3668             {:?}",
3669            first.turns[0].messages,
3670        );
3671    }
3672
3673    /// A conversation with content and no turn boundary still pages.
3674    ///
3675    /// The interrupt marker is a user row the fold opens no turn at, so a
3676    /// session holding only its own interrupted exchange has no boundary at
3677    /// all. A page that listed no turns AND said `cursor: null` would tell a
3678    /// client there is nothing above a conversation it has never seen - and
3679    /// the walk would stop there, with the whole of it unreachable.
3680    #[test]
3681    fn a_conversation_with_no_turns_still_pages() {
3682        // The interrupt marker is a user row the fold opens no turn at, so
3683        // this conversation has content and no boundary: the shape the page's
3684        // fallback is for. A delivery's envelope row is a boundary, which is
3685        // why it cannot stand in for one here.
3686        let (messages, rendered) = a_conversation(&[
3687            r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"[Request interrupted by user]"},"session_id":"s"}"#,
3688            r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"morning"}]}}"#,
3689            r#"{"type":"result","uuid":"r1","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3690        ]);
3691
3692        assert!(
3693            rendered.turns.is_empty(),
3694            "precondition: nothing in this conversation opens a turn",
3695        );
3696        assert!(!messages.is_empty(), "precondition: and it has content");
3697
3698        let page = page(&messages, &rendered, 0, 0, None, 10);
3699
3700        assert_eq!(page.turns.len(), 1, "the conversation rides one turn rather than none");
3701        assert_eq!(page.turns[0].messages.len(), messages.len(), "and that turn carries all of it");
3702        assert_eq!(page.cursor, None, "with nothing above it, which is the one honest null");
3703    }
3704
3705    /// A user row carrying two blocks opens one turn, not two.
3706    ///
3707    /// Two blocks that each open a turn would put two spans at one message. A
3708    /// cursor names a message and resolves to the first match, so a second
3709    /// boundary at that index would hand a walker a page of its own before it
3710    /// moved - an empty one, since the two boundaries are the same message.
3711    #[test]
3712    fn a_user_row_with_two_blocks_opens_one_turn() {
3713        let (messages, rendered) = a_conversation(&[
3714            r#"{"type":"user","uuid":"u1","message":{"role":"user","content":[{"type":"text","text":"one"},{"type":"text","text":"two"}]},"session_id":"s"}"#,
3715            r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"reply"}]}}"#,
3716            r#"{"type":"result","uuid":"r1","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3717        ]);
3718
3719        assert_eq!(rendered.turns.len(), 2, "precondition: the fold opens a turn per block");
3720        assert_eq!(
3721            rendered.turns[0].opens_at, rendered.turns[1].opens_at,
3722            "precondition: and both open at the one message that carried them",
3723        );
3724
3725        let page = page(&messages, &rendered, 0, 0, None, 10);
3726
3727        assert_eq!(page.turns.len(), 1, "one message is one turn on the page");
3728        assert_eq!(
3729            page.turns[0].messages.len(),
3730            messages.len(),
3731            "carrying the whole conversation, since both blocks open at its first row",
3732        );
3733        assert_eq!(page.cursor, None, "and the walk is not sent after a page that cannot move");
3734    }
3735
3736    /// A page opens on a turn, and consecutive pages meet without a gap.
3737    #[test]
3738    fn a_page_opens_on_a_turn_and_the_pages_meet_exactly() {
3739        let rows = turns_of(50);
3740        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3741        let (messages, rendered) = a_conversation(&borrowed);
3742        let first = page(&messages, &rendered, 0, 0, None, 10);
3743
3744        // A page is the turns it was asked for, each opening where a turn
3745        // does. A page beginning anywhere else hands a client the tail of one
3746        // turn and no way to tell that is what it has.
3747        assert_eq!(first.turns.len(), 10, "a page is the turns it was asked for");
3748        // The page is the NEWEST ten, and it lists them in conversation order: a
3749        // client walking back gets each page in reading order and asks for the ones
3750        // above it.
3751        assert_eq!(
3752            opened_on(first.turns.first().expect("ten turns")),
3753            "turn 40",
3754            "the page reaches back exactly as far as it was asked for, no further",
3755        );
3756        assert_eq!(
3757            opened_on(first.turns.last().expect("ten turns")),
3758            "turn 49",
3759            "and it ends on the newest turn of the conversation",
3760        );
3761
3762        // And the next page must reach back to where this one began. It may
3763        // repeat turns - the client keys them and drops the repeats - but it
3764        // may never skip one, because a skipped turn is history the reader has
3765        // no way to ask for again.
3766        let all = every_turn(&messages, &rendered);
3767        let second = page(&messages, &rendered, 0, 0, first.cursor.as_deref(), 10);
3768        let second_turns: Vec<String> = second.turns.iter().map(opened_on).collect();
3769
3770        assert!(!second_turns.is_empty(), "asking for more turns returns some");
3771        let above = all
3772            .iter()
3773            .position(|held| *held == opened_on(&first.turns[0]))
3774            .and_then(|at| at.checked_sub(1))
3775            .map(|at| all[at].clone());
3776        assert_eq!(
3777            second_turns.last().cloned(),
3778            above,
3779            "the page above ends on the turn directly above this one, so no turn is skipped",
3780        );
3781    }
3782
3783    /// A client walking back through history reaches EVERY turn, and the walk
3784    /// TERMINATES.
3785    ///
3786    /// **This is the assertion the keyed cursor failed.** A cursor the fold
3787    /// could not supply is `None` on every page, and `None` is the one signal
3788    /// a client reads as "nothing above this page" - so the walk stopped after
3789    /// one page and every turn above it was unreachable. Not an error and not
3790    /// a gap a client can see: a conversation that appeared to begin where the
3791    /// page did. `pages > 1` is the half that catches it.
3792    #[test]
3793    fn walking_back_through_history_sees_every_turn_and_ends() {
3794        let rows = turns_of(25);
3795        let borrowed: Vec<&str> = rows.iter().map(String::as_str).collect();
3796        let (messages, rendered) = a_conversation(&borrowed);
3797        let every = every_turn(&messages, &rendered);
3798
3799        let mut seen: Vec<String> = Vec::new();
3800        let mut cursor: Option<String> = None;
3801        let mut pages = 0;
3802        loop {
3803            let asked = page(&messages, &rendered, 0, 0, cursor.as_deref(), 5);
3804            pages += 1;
3805            assert!(pages < every.len() + 2, "the walk terminates rather than cycling");
3806            seen.extend(asked.turns.iter().map(opened_on));
3807            match asked.cursor {
3808                Some(next) => cursor = Some(next),
3809                None => break,
3810            }
3811        }
3812
3813        assert!(pages > 1, "the walk goes past the first page, or nothing above it is reachable");
3814        for turn in &every {
3815            assert!(seen.contains(turn), "every turn is reached, and {turn} was not");
3816        }
3817        assert!(
3818            seen.contains(&every[0]),
3819            "and the walk ends only once the OLDEST turn has been seen: {pages} pages",
3820        );
3821    }
3822
3823    use std::collections::BTreeSet;
3824    use std::path::{Path, PathBuf};
3825    use std::sync::{Arc, Mutex};
3826
3827    use super::*;
3828    use crate::surface::PendingKind;
3829    use crate::testing::ViewFacts;
3830    use crate::transport::Heartbeat;
3831    use crate::work::WorkCache;
3832    use forge_primitives::git_diff::GitDiffSnapshot;
3833
3834    /// Where a run builds its fixture fleet: a directory of its own, so two
3835    /// runs overlapping on one machine share neither the store, the
3836    /// single-instance lock, nor the tree the record reads. Under `/tmp`
3837    /// rather than the ambient temp dir, which is the root the usage
3838    /// fixture's own label rule folds to (`/tmp` paths read as `scratch`) -
3839    /// a `$TMPDIR` root would pin a different label. Every form of the root
3840    /// stands in as `<fixture>` before a fixture is compared - see
3841    /// [`volatile`].
3842    ///
3843    /// The guard removes the directory when the test is done, and it does that
3844    /// only because of where it is declared: locals drop in reverse order, so a
3845    /// `dir` taken before `state` releases the tree after the store opened
3846    /// under it has closed.
3847    fn fixture_dir() -> tempfile::TempDir {
3848        tempfile::Builder::new().tempdir_in("/tmp").expect("the fixture's directory")
3849    }
3850
3851    /// The fixture's own repository at `path`: `main` with one commit, a
3852    /// `worktree-pr` branch one commit ahead of it, and uncommitted work -
3853    /// so the record's content read carries a worktree layer and a
3854    /// branch-ahead layer, both populated.
3855    ///
3856    /// Deterministic on purpose: the fixture pins the hunks this builds,
3857    /// word for word.
3858    fn fixture_repo(path: &Path) {
3859        let git = |args: &[&str]| {
3860            let out = std::process::Command::new("git")
3861                .env_remove("GIT_DIR")
3862                .env_remove("GIT_WORK_TREE")
3863                .env_remove("GIT_COMMON_DIR")
3864                // The dates are pinned because the record now carries the
3865                // branch's commit chain, and a sha covers the commit's
3866                // timestamps: unpinned, every run minted a different sha and
3867                // the fixture could not hold one.
3868                .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00+00:00")
3869                .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00+00:00")
3870                .arg("-C")
3871                .arg(path)
3872                .args(args)
3873                .output()
3874                .expect("run git");
3875            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
3876        };
3877        std::fs::create_dir_all(path).expect("the fixture's directory");
3878        git(&["init", "-q"]);
3879        git(&["symbolic-ref", "HEAD", "refs/heads/main"]);
3880        git(&["config", "user.email", "fixture@example.test"]);
3881        git(&["config", "user.name", "Fixture"]);
3882        git(&["config", "commit.gpgsign", "false"]);
3883        std::fs::write(path.join("kept.txt"), "one\ntwo\n").expect("write kept.txt");
3884        git(&["add", "-A"]);
3885        git(&["commit", "-qm", "base"]);
3886        git(&["checkout", "-q", "-b", "worktree-pr"]);
3887        std::fs::write(
3888            path.join("branch.txt"),
3889            "committed on the branch\nsecond line\nthird line\n",
3890        )
3891        .expect("write");
3892        git(&["add", "-A"]);
3893        git(&["commit", "-qm", "branch"]);
3894        // Uncommitted: a modification, and an addition staged so it is a
3895        // tracked change against HEAD.
3896        std::fs::write(path.join("kept.txt"), "one\ntwo\nthree\n").expect("write kept.txt");
3897        std::fs::write(path.join("added.txt"), "added one\n").expect("write added.txt");
3898        git(&["add", "added.txt"]);
3899    }
3900
3901    /// The surface the fixtures are produced from, and both halves of the
3902    /// reason are deliberate.
3903    ///
3904    /// DETERMINISTIC, because every form of `root` stands in as `<fixture>`
3905    /// before anything is compared against a committed fixture - see
3906    /// [`volatile`] - so which directory the fleet lives in does not pin the
3907    /// shape. POPULATED, because an empty fleet pins almost nothing: most of
3908    /// a session's fields would be `null`, and a field renamed to `null`
3909    /// would still pass.
3910    async fn fixture_state(root: &Path) -> TransportState {
3911        fixture_state_seeded(root, |_| {}).await
3912    }
3913
3914    /// [`fixture_state`] with one more seed of the test's own, laid down
3915    /// before the surface is built - for a test that pins a shape the default
3916    /// fixture deliberately leaves empty.
3917    async fn fixture_state_seeded(
3918        root: &Path,
3919        seed: impl FnOnce(&crate::testing::Fleet),
3920    ) -> TransportState {
3921        let fleet =
3922            crate::testing::Fleet::in_dir(root, &[("TestOrg", &["proj"])]).expect("the fleet");
3923        fleet.start("TestOrg", "proj").expect("the project starts");
3924        fleet.set_cli_version(Some("1.0.0"), Some("1.1.0"));
3925        fleet.set_user_preferences(serde_json::json!({}));
3926        fleet.add_worker("TestOrg", "proj", "w1").expect("the worker is added");
3927        fleet.install_agent("TestOrg", "proj", "lead");
3928        fleet
3929            .seed_transcript(
3930                "TestOrg",
3931                "proj",
3932                "lead",
3933                &[
3934                    r#"{"type":"user","message":{"role":"user","content":"hello"},"session_id":"s"}"#,
3935                    // A tool call, so the fixture pins a group's rows: the
3936                    // class it keys on, the word it draws and the tool's own
3937                    // name.
3938                    r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"tu1","name":"Bash","input":{"command":"ls","description":"List files"}}]}}"#,
3939                    r#"{"type":"user","uuid":"u2","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu1","content":"a\nb"}]}}"#,
3940                    // A counted turn, so the usage fixture carries real
3941                    // tokens rather than a pool that reads as empty. The
3942                    // stamp is fixed in the past: a "now" stamp would put
3943                    // the record in the rolling windows, and which of them
3944                    // hold it flips at midnight.
3945                    r#"{"type":"assistant","uuid":"a2","timestamp":"2025-01-02T03:04:05.000Z","message":{"id":"m-usage","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"counted"}],"usage":{"input_tokens":7,"output_tokens":11,"cache_read_input_tokens":13,"cache_creation_input_tokens":17}}}"#,
3946                    // A sub-agent dispatch, so the record carries a
3947                    // POPULATED card rather than an empty list a rename
3948                    // could cross unseen.
3949                    r#"{"type":"assistant","uuid":"a3","message":{"id":"m-sub","role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"tu-sub","name":"Task","input":{"description":"map the calls","subagent_type":"general-purpose","run_in_background":false,"prompt":"do the thing"}}]}}"#,
3950                    r#"{"type":"result","subtype":"success","duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"session_id":"s"}"#,
3951                ],
3952            )
3953            .expect("the transcript seeds");
3954        fleet.seed_test_pending_interaction(&fixture_seat(), PendingKind::Permission);
3955        // A rostered background task, so the fixture carries a populated
3956        // registry rather than an empty list a reader cannot tell from an
3957        // unwired field - and the two sub-agent facts, which the session
3958        // task's one fold raises and this fixture has no task to run: the
3959        // dispatch the transcript seeded, as the card list and the flag a
3960        // seat that ran it would hold.
3961        fleet.seed_view_facts(
3962            &fixture_seat(),
3963            ViewFacts {
3964                background_tasks: vec![forge_workspace::BackgroundTask {
3965                    task_id: "t-fixture".to_owned(),
3966                    is_backgrounded: true,
3967                    task_type: "local_bash".to_owned(),
3968                    description: "gh run watch".to_owned(),
3969                    command: Some("gh run watch 123 --exit-status".to_owned()),
3970                    tool_use_id: Some("tu-fixture".to_owned()),
3971                }],
3972                cards: vec![forge_primitives::runtime::SubagentCard {
3973                    name: "map the calls".to_owned(),
3974                    dispatch_id: "tu-sub".to_owned(),
3975                    agent_type: Some("general-purpose".to_owned()),
3976                    running: false,
3977                    failed: false,
3978                    backgrounded: false,
3979                    ended_at_ms: Some(1_750_000_000_000),
3980                    calls: 1,
3981                    tail: vec![forge_primitives::runtime::SubagentCall {
3982                        name: "Read".to_owned(),
3983                        title: "Read src/lib.rs".to_owned(),
3984                        status: forge_primitives::runtime::SubagentCallStatus::Completed,
3985                    }],
3986                    usage: Some(forge_primitives::messages::TaskUsage {
3987                        total_tokens: 9_714,
3988                        tool_uses: 1,
3989                        duration_ms: 2_716,
3990                        extras: serde_json::Map::new(),
3991                    }),
3992                }],
3993                ..ViewFacts::default()
3994            },
3995        );
3996        seed(&fleet);
3997        // A scan the fixture pins the working tree, the PR row and the
3998        // diff content from. It is taken of a repository of its own beside
3999        // the fleet rather than of the seat's project directory: building
4000        // one AT that path would also make the path exist, and the home
4001        // fixture's rows - pinned with that directory gone - would move
4002        // with it. The scan is real, so the record's content read has both
4003        // layers to carry rather than pinning a failed layer; the PR row
4004        // is stamped on top, since a temp repo has no remote for `gh` to
4005        // find one through.
4006        let surface = fleet.surface();
4007        let work = Arc::new(WorkCache::new());
4008        let repo = root.join("diff-repo");
4009        fixture_repo(&repo);
4010        let mut diff =
4011            forge_workspace::env::git_diff::scan(&repo, &std::collections::HashMap::new(), None)
4012                .await;
4013        diff.pr = Some(forge_primitives::git::GitPrInfo {
4014            number: 1249,
4015            url: "https://example.test/pull/1249".to_owned(),
4016            draft: false,
4017        });
4018        diff.closes = vec![forge_primitives::git::GitIssueRef {
4019            number: 1215,
4020            url: "https://example.test/issues/1215".to_owned(),
4021        }];
4022        diff.pushed_sha = Some("abc123".to_owned());
4023        diff.pr_fetched_at = None;
4024        surface.store_work_snapshot(
4025            &fixture_seat(),
4026            forge_workspace::work::WorkSnapshot {
4027                diff,
4028                cwd: repo,
4029                read_at: std::time::Instant::now(),
4030                base: None,
4031                base_at: None,
4032                base_error: None,
4033            },
4034        );
4035
4036        let state = TransportState {
4037            surface,
4038            work,
4039            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4040            live: Mutex::new(crate::live::Live::new()),
4041            settings: crate::transport::settings_channel(),
4042            browser_host: None,
4043            heartbeat: Heartbeat::default(),
4044        };
4045        // The transport does not read a transcript, so the seat's
4046        // conversation is put where a `Connected` would have left it.
4047        fleet
4048            .hold_conversation(&state, "TestOrg", "proj", "lead")
4049            .expect("the fixture's conversation is held");
4050        state
4051    }
4052
4053    /// The substrings a fixture must not pin: the directory the fleet
4054    /// happens to live in, in every form the wire spells it.
4055    ///
4056    /// Three forms, because the wire carries all three. A path arrives raw
4057    /// (`ProjectView::path`), canonically (anything that resolved a symlink)
4058    /// and SLUGGED (`ProjectKey`, every non-alphanumeric turned into `-`).
4059    /// The slug form is why replacing the plain path alone is not enough:
4060    /// the key is a temp directory spelled with dashes, and it differs per
4061    /// machine just as much as the path does.
4062    fn volatile(root: &Path) -> Vec<(String, &'static str)> {
4063        let raw = root.to_string_lossy().into_owned();
4064        let canonical = root
4065            .canonicalize()
4066            .unwrap_or_else(|_| root.to_path_buf())
4067            .to_string_lossy()
4068            .into_owned();
4069        let mut forms = vec![
4070            (raw.clone(), "<fixture>"),
4071            (canonical.clone(), "<fixture>"),
4072            (slug(&raw), "<fixture>"),
4073            (slug(&canonical), "<fixture>"),
4074            // The build stamp carries the commit the binary was built from, so
4075            // it moves on every commit and a fixture pinning it would fail on
4076            // the next one. Each gets its OWN stand-in: collapsing them to one
4077            // placeholder would leave a client unable to tell which field is
4078            // which.
4079            (crate::FORGE_VERSION.to_owned(), "<forge-version>"),
4080            (crate::FORGE_VERSION_SHORT.to_owned(), "<forge-version-short>"),
4081            // The run's own identity differs per process, so a fixture that
4082            // pinned it would fail on the next run.
4083            (boot_id().to_owned(), "<boot-id>"),
4084        ];
4085        forms.sort_by_key(|(form, _)| std::cmp::Reverse(form.len()));
4086        forms.dedup_by(|left, right| left.0 == right.0);
4087        forms
4088    }
4089
4090    /// The slug `ProjectKey` builds: every non-alphanumeric becomes `-`.
4091    fn slug(path: &str) -> String {
4092        path.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
4093    }
4094
4095    fn normalise(value: &mut Value, forms: &[(String, &'static str)]) {
4096        match value {
4097            Value::String(text) => {
4098                for (form, stands_in) in forms {
4099                    if let Some(rest) = text.strip_prefix(form.as_str()) {
4100                        *text = format!("{stands_in}{rest}");
4101                        break;
4102                    }
4103                }
4104            }
4105            Value::Array(items) => items.iter_mut().for_each(|item| normalise(item, forms)),
4106            Value::Object(fields) => fields.values_mut().for_each(|field| normalise(field, forms)),
4107            _ => {}
4108        }
4109    }
4110
4111    /// **The home's attention item is the whole ask.** The default fixture
4112    /// pins an empty list, so a shape no test ever draws could be renamed
4113    /// unseen; this seeds one waiting row and reads the item the fleet row
4114    /// encodes - the task, the seat, the subject, the ask's words and the
4115    /// moment the wait began.
4116    #[tokio::test]
4117    async fn a_decision_wait_crosses_with_its_ask() {
4118        let dir = fixture_dir();
4119        let state = fixture_state_seeded(dir.path(), |fleet| {
4120            fleet.seed_task(forge_primitives::tasks::Task {
4121                id: forge_primitives::tasks::TaskId::from("t-wait"),
4122                project_name: "proj".to_owned(),
4123                subject: "ship the parser".to_owned(),
4124                active_form: None,
4125                detail: None,
4126                status: forge_primitives::tasks::TaskStatus::Waiting,
4127                owner: Some(forge_primitives::SessionSlot::worker("TestOrg", "proj", "w1")),
4128                parent: None,
4129                waiting_on: Some(forge_primitives::tasks::Waiting {
4130                    kind: Some(forge_primitives::tasks::WaitingKind::Decision),
4131                    detail: Some("sign this off".to_owned()),
4132                    on: None,
4133                    verification: false,
4134                    parked: false,
4135                    since: Some(
4136                        std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(5),
4137                    ),
4138                }),
4139                estimate: None,
4140                rank: None,
4141                verify: None,
4142                attested: None,
4143                reserved: None,
4144                links: Vec::new(),
4145                attempt: 0,
4146                archived_at: None,
4147                created_at: std::time::SystemTime::UNIX_EPOCH,
4148                // An edit after the wait began must not move the item's date.
4149                updated_at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(9),
4150            });
4151        })
4152        .await;
4153
4154        let mut encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4155        normalise(&mut encoded, &volatile(dir.path()));
4156        let row = encoded["fleet"]
4157            .as_array()
4158            .expect("the fleet")
4159            .iter()
4160            .find(|row| row["project"] == "proj")
4161            .expect("the project's row");
4162        assert_eq!(row["waiting_on_user"], 1, "the wait counts");
4163        let item = &row["attention"][0];
4164        assert_eq!(item["task"], "t-wait", "the row's id");
4165        assert_eq!(item["owner"], "w1", "the seat that raised it");
4166        assert_eq!(item["subject"], "ship the parser", "the row's subject");
4167        assert_eq!(item["words"], "sign this off", "the ask's own words");
4168        assert_eq!(
4169            item["at"],
4170            serde_json::json!({ "secs_since_epoch": 5, "nanos_since_epoch": 0 }),
4171            "the moment the wait began",
4172        );
4173    }
4174
4175    /// Every subject a client can watch, with the fixture that pins its wire
4176    /// shape.
4177    fn fixtures() -> Vec<(Subject, PathBuf)> {
4178        let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/wire_fixtures");
4179        vec![
4180            (Subject::Home, dir.join("home.json")),
4181            (Subject::Board, dir.join("board.json")),
4182            (Subject::Session(fixture_seat()), dir.join("session.json")),
4183            (Subject::Usage, dir.join("usage.json")),
4184        ]
4185    }
4186
4187    /// Writes each subject's fixture from the encoder. Run deliberately:
4188    /// `cargo nextest run -p forge-server --run-ignored ignored-only write_the_fixtures`.
4189    ///
4190    /// **What this writes is only what the encoder currently emits**, so a
4191    /// fixture produced here pins the present shape and a wrong one equally.
4192    /// Each has to be READ against the intended shape before it is
4193    /// committed; generating one is not the work.
4194    #[tokio::test]
4195    #[ignore = "writes the fixtures; run deliberately"]
4196    async fn write_the_fixtures() {
4197        let dir = fixture_dir();
4198        let state = fixture_state(dir.path()).await;
4199        let forms = volatile(dir.path());
4200        std::fs::create_dir_all(fixtures()[0].1.parent().expect("a directory")).expect("mkdir");
4201        for (subject, fixture) in fixtures() {
4202            let mut encoded = encode_subject(&state, &subject).await.expect("encode");
4203            normalise(&mut encoded, &forms);
4204            std::fs::write(&fixture, serde_json::to_string_pretty(&encoded).expect("render"))
4205                .expect("write");
4206        }
4207    }
4208
4209    /// A seat's own working tree crosses on its row, which is what lets a
4210    /// worker's row draw its own branch rather than its project's.
4211    ///
4212    /// Read through the same shared cache the project rows use, so this is one
4213    /// read drawn twice rather than two reads - and it is a read a page cannot
4214    /// derive from the project's, because the two name different directories.
4215    #[tokio::test]
4216    async fn a_home_agents_row_carries_its_own_tree() {
4217        let fleet =
4218            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4219        fleet.start("TestOrg", "proj").expect("the project starts");
4220        let state = TransportState {
4221            surface: fleet.surface(),
4222            work: Arc::new(WorkCache::new()),
4223            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4224            live: Mutex::new(crate::live::Live::new()),
4225            settings: crate::transport::settings_channel(),
4226            browser_host: None,
4227            heartbeat: Heartbeat::default(),
4228        };
4229
4230        let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4231        let row = encoded["agents"]
4232            .as_array()
4233            .expect("agents is a list")
4234            .iter()
4235            .find(|row| row["slot"]["label"] == "lead")
4236            .expect("the started project's lead is a row");
4237
4238        assert!(
4239            row.get("work").is_some(),
4240            "the seat's own tree crosses on the row, not the project's read: {row}"
4241        );
4242    }
4243
4244    /// A seat between heal attempts crosses the home with its state and its
4245    /// attempt, which is what a row draws `restarting (1 of 3)` from.
4246    /// Catches a wire that spells `AgentRow`'s fields out and drops the two
4247    /// new ones, which would draw the seat as something it is not.
4248    #[tokio::test]
4249    async fn a_home_agents_row_carries_the_heal_attempt() {
4250        let fleet =
4251            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4252        fleet.start("TestOrg", "proj").expect("the project starts");
4253        let lead = forge_primitives::SessionSlot::lead("TestOrg", "proj");
4254        let next_at =
4255            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_790_000_000);
4256        fleet.workspace().set_heal_progress(&lead, 1, next_at);
4257        let state = TransportState {
4258            surface: fleet.surface(),
4259            work: Arc::new(WorkCache::new()),
4260            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4261            live: Mutex::new(crate::live::Live::new()),
4262            settings: crate::transport::settings_channel(),
4263            browser_host: None,
4264            heartbeat: Heartbeat::default(),
4265        };
4266
4267        let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4268        let row = encoded["agents"]
4269            .as_array()
4270            .expect("agents is a list")
4271            .iter()
4272            .find(|row| row["slot"]["label"] == "lead")
4273            .expect("the started project's lead is a row");
4274
4275        assert_eq!(row["lifecycle"], serde_json::json!("Restarting"), "the state the row draws");
4276        assert_eq!(row["heal_attempt"], serde_json::json!(1), "the attempt it names");
4277        assert_eq!(row["heal_attempts"], serde_json::json!(3), "the cap the row's 'of 3' reads");
4278    }
4279
4280    /// **The home carries the fleet glance and the board carries the board**
4281    /// (#2517): the three fields that were most of the home's ~708 KB payload
4282    /// moved to their own subject, and the board's answer carries every one.
4283    #[tokio::test]
4284    async fn the_home_carries_no_board_and_the_board_carries_all_of_it() {
4285        let fleet =
4286            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4287        fleet.start("TestOrg", "proj").expect("the project starts");
4288        fleet.add_task("TestOrg", "proj", "a counted row", "w1", None).expect("the task is seeded");
4289        let state = TransportState {
4290            surface: fleet.surface(),
4291            work: Arc::new(WorkCache::new()),
4292            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4293            live: Mutex::new(crate::live::Live::new()),
4294            settings: crate::transport::settings_channel(),
4295            browser_host: None,
4296            heartbeat: Heartbeat::default(),
4297        };
4298
4299        let home = encode_subject(&state, &Subject::Home).await.expect("the home encodes");
4300        let home_project = &home["projects"][0];
4301        for field in ["rows", "crons", "connectors"] {
4302            assert!(
4303                home_project.get(field).is_none(),
4304                "the home still carries the board's `{field}`: {home_project}",
4305            );
4306        }
4307
4308        let board = encode_subject(&state, &Subject::Board).await.expect("the board encodes");
4309        let board_project = &board["projects"][0];
4310        for field in ["rows", "crons", "connectors"] {
4311            assert!(
4312                board_project.get(field).is_some(),
4313                "the board's snapshot does not carry `{field}`, which its updates move: \
4314                 {board_project}",
4315            );
4316        }
4317
4318        // **The header's total is the board's own row count**, on a project
4319        // that holds one: the fleet row must count it, not count it minus one.
4320        let board_rows = board_project["rows"].as_array().map(Vec::len);
4321        assert_eq!(board_rows, Some(1), "the seeded row did not reach the board: {board}");
4322        assert_eq!(
4323            home["fleet"][0]["rows"].as_u64(),
4324            Some(1),
4325            "the fleet row's total does not match the board's rows: {home}",
4326        );
4327    }
4328
4329    /// **A board row's owner-gone chip is read from the seat registry on the
4330    /// encode**, not from the task: an owner nothing holds is `stale`, and a
4331    /// seat that comes up clears it on the next read (#2517).
4332    #[tokio::test]
4333    async fn a_board_row_marks_an_owner_whose_seat_is_not_live() {
4334        let fleet =
4335            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4336        fleet.add_task("TestOrg", "proj", "an owned row", "w1", None).expect("the task is seeded");
4337        let state = TransportState {
4338            surface: fleet.surface(),
4339            work: Arc::new(WorkCache::new()),
4340            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4341            live: Mutex::new(crate::live::Live::new()),
4342            settings: crate::transport::settings_channel(),
4343            browser_host: None,
4344            heartbeat: Heartbeat::default(),
4345        };
4346
4347        let board = encode_subject(&state, &Subject::Board).await.expect("encode");
4348        let gone_chip = |board: &Value| board["projects"][0]["rows"][0]["marks"]["stale"].clone();
4349        assert_eq!(
4350            gone_chip(&board),
4351            Value::Bool(true),
4352            "an owner no seat holds must draw the gone chip: {board}",
4353        );
4354
4355        // The seat comes up: the next read clears the chip.
4356        let _commands = fleet.install_agent("TestOrg", "proj", "w1");
4357        let board = encode_subject(&state, &Subject::Board).await.expect("encode");
4358        assert_eq!(
4359            gone_chip(&board),
4360            Value::Bool(false),
4361            "a live owner must not draw the gone chip: {board}",
4362        );
4363    }
4364
4365    /// The home's fatal carries the words the server prints, not the serde
4366    /// tag: a view drawing the line words it from the field (#1638).
4367    #[tokio::test]
4368    async fn a_home_fatal_carries_the_terminals_words() {
4369        let fleet =
4370            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4371        fleet.start("TestOrg", "proj").expect("the project starts");
4372        fleet.set_fatal_error(forge_primitives::error::AppError::ConnectionFailed);
4373        let state = TransportState {
4374            surface: fleet.surface(),
4375            work: Arc::new(WorkCache::new()),
4376            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4377            live: Mutex::new(crate::live::Live::new()),
4378            settings: crate::transport::settings_channel(),
4379            browser_host: None,
4380            heartbeat: Heartbeat::default(),
4381        };
4382
4383        let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
4384        assert_eq!(
4385            encoded["fatal_error"],
4386            serde_json::json!("Failed to establish or maintain the Agent SDK bridge connection."),
4387            "the home's fatal is the terminal's own words"
4388        );
4389    }
4390
4391    /// The rows the processes feed leads with: the CLI's background-task
4392    /// registry, which no read answered.
4393    ///
4394    /// Without it a client drew the OS walk and nothing else, so a
4395    /// `setsid`-detached bash - the case the registry exists for - was a
4396    /// missing row rather than an empty field.
4397    #[tokio::test]
4398    async fn a_session_snapshot_carries_the_background_registry() {
4399        let fleet =
4400            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4401        fleet.start("TestOrg", "proj").expect("the project starts");
4402        fleet.seed_view_facts(
4403            &fixture_seat(),
4404            ViewFacts {
4405                background_tasks: vec![forge_workspace::BackgroundTask {
4406                    task_id: "t1".to_owned(),
4407                    is_backgrounded: true,
4408                    task_type: "local_bash".to_owned(),
4409                    description: "gh run watch".to_owned(),
4410                    command: Some("gh run watch 123 --exit-status".to_owned()),
4411                    tool_use_id: Some("tu-1".to_owned()),
4412                }],
4413                ..ViewFacts::default()
4414            },
4415        );
4416        let state = TransportState {
4417            surface: fleet.surface(),
4418            work: Arc::new(WorkCache::new()),
4419            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4420            live: Mutex::new(crate::live::Live::new()),
4421            settings: crate::transport::settings_channel(),
4422            browser_host: None,
4423            heartbeat: Heartbeat::default(),
4424        };
4425
4426        let encoded =
4427            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4428
4429        assert_eq!(
4430            encoded["background_tasks"][0]["task_id"], "t1",
4431            "the seat's registry crosses: {encoded}"
4432        );
4433        assert_eq!(
4434            encoded["background_tasks"][0]["description"], "gh run watch",
4435            "with the line the row leads with: {encoded}"
4436        );
4437        assert_eq!(
4438            encoded["background_tasks"][0]["command"], "gh run watch 123 --exit-status",
4439            "and the command the scan adopts by: {encoded}"
4440        );
4441    }
4442
4443    /// The occupant's id crosses on the header: the `Connected` that named it
4444    /// reaches nobody who was not attached when it fired, so a page opened on
4445    /// a running seat has this rather than the frame. The worker registry
4446    /// carries the same id for a live dynamic worker, and nothing else does.
4447    #[tokio::test]
4448    async fn a_session_snapshot_carries_the_occupants_id() {
4449        let fleet =
4450            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4451        fleet.start("TestOrg", "proj").expect("the project starts");
4452        fleet.seed_view_facts(
4453            &fixture_seat(),
4454            ViewFacts {
4455                session_id: Some(forge_primitives::SessionId::new("d4f70669-1f2a")),
4456                ..ViewFacts::default()
4457            },
4458        );
4459        let state = TransportState {
4460            surface: fleet.surface(),
4461            work: Arc::new(WorkCache::new()),
4462            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4463            live: Mutex::new(crate::live::Live::new()),
4464            settings: crate::transport::settings_channel(),
4465            browser_host: None,
4466            heartbeat: Heartbeat::default(),
4467        };
4468
4469        let encoded =
4470            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4471
4472        assert_eq!(
4473            encoded["header"]["session_id"], "d4f70669-1f2a",
4474            "the occupant the core named crosses on the header: {encoded}"
4475        );
4476    }
4477
4478    /// `/resume`'s list rides the record, because the composer's other sources
4479    /// do: a field that never reached the JSON would leave the popover with
4480    /// nothing to offer and nothing on screen saying why.
4481    #[tokio::test]
4482    async fn a_session_snapshot_carries_the_sessions_the_seat_can_resume() {
4483        let dir = tempfile::tempdir().expect("fleet dir");
4484        let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
4485            .expect("the fleet builds");
4486        fleet.start("TestOrg", "proj").expect("the project starts");
4487        let cwd =
4488            fleet.workspace().cwd_for_session(&fixture_seat()).expect("the project's directory");
4489        let key = forge_workspace::userdata::catalog::scan::project_key_for_directory(Some(&cwd));
4490        let project_dir = dir.path().join("projects").join(key);
4491        std::fs::create_dir_all(&project_dir).expect("the project's transcripts");
4492        for (id, summary, secs) in [
4493            ("aaaa-0000", "the older session", 1_700_000_100_u64),
4494            ("bbbb-1111", "the newer session", 1_700_000_200),
4495        ] {
4496            let path = project_dir.join(format!("{id}.jsonl"));
4497            std::fs::write(
4498                &path,
4499                format!(
4500                    "{{\"type\":\"user\",\"cwd\":\"{cwd}\",\"message\":{{\"content\":\"{summary}\"}}}}\n"
4501                ),
4502            )
4503            .expect("write a transcript");
4504            let file = std::fs::File::options().write(true).open(&path).expect("open it");
4505            file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
4506                .expect("pin the transcript's mtime");
4507        }
4508        let state = TransportState {
4509            surface: fleet.surface(),
4510            work: Arc::new(WorkCache::new()),
4511            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4512            live: Mutex::new(crate::live::Live::new()),
4513            settings: crate::transport::settings_channel(),
4514            browser_host: None,
4515            heartbeat: Heartbeat::default(),
4516        };
4517
4518        let encoded =
4519            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4520
4521        assert_eq!(
4522            encoded["resume_sessions"][0]["session_id"], "bbbb-1111",
4523            "the newest session leads, by the id a pick writes: {encoded}"
4524        );
4525        assert_eq!(
4526            encoded["resume_sessions"][0]["summary"], "the newer session",
4527            "with the transcript's own title beside it: {encoded}"
4528        );
4529        assert_eq!(
4530            encoded["resume_sessions"][1]["session_id"], "aaaa-0000",
4531            "and the older one follows: {encoded}"
4532        );
4533    }
4534
4535    /// **The instance list the record carries is the workspace's**, not a
4536    /// fold the record takes over the conversation.
4537    ///
4538    /// It was folded here until the cap landed - over the whole held
4539    /// conversation, on every read - and the two answers part company the
4540    /// moment the conversation a record can see is shorter than the one the
4541    /// session has run: a section that shrank with the cap would be one no
4542    /// viewer of the session's own frames agrees with. The seat below
4543    /// dispatches in its conversation, so a fold here would answer a card,
4544    /// and what the record carries has to be the workspace's rather than it.
4545    #[tokio::test]
4546    async fn the_record_reads_the_instance_list_from_the_workspace() {
4547        let fleet =
4548            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4549        fleet.start("TestOrg", "proj").expect("the project starts");
4550        fleet
4551            .seed_transcript(
4552                "TestOrg",
4553                "proj",
4554                "lead",
4555                &[
4556                    r#"{"type":"user","message":{"role":"user","content":"map it"},"session_id":"s"}"#,
4557                    r#"{"type":"assistant","uuid":"a1","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"tu-sub","name":"Task","input":{"description":"from the conversation","subagent_type":"Explore"}}]}}"#,
4558                ],
4559            )
4560            .expect("the transcript seeds");
4561        let state = TransportState {
4562            surface: fleet.surface(),
4563            work: Arc::new(WorkCache::new()),
4564            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4565            live: Mutex::new(crate::live::Live::new()),
4566            settings: crate::transport::settings_channel(),
4567            browser_host: None,
4568            heartbeat: Heartbeat::default(),
4569        };
4570        fleet
4571            .hold_conversation(&state, "TestOrg", "proj", "lead")
4572            .expect("the fixture's conversation is held");
4573
4574        let before =
4575            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4576        assert_eq!(
4577            before["subagent_instances"].as_array().expect("a list").len(),
4578            0,
4579            "the conversation's own dispatch is not folded by the read: {before}",
4580        );
4581
4582        fleet.seed_view_facts(
4583            &fixture_seat(),
4584            ViewFacts {
4585                cards: vec![forge_primitives::runtime::SubagentCard {
4586                    name: "from the workspace".to_owned(),
4587                    dispatch_id: "tu-sub".to_owned(),
4588                    agent_type: Some("Explore".to_owned()),
4589                    running: true,
4590                    failed: false,
4591                    backgrounded: false,
4592                    ended_at_ms: None,
4593                    calls: 0,
4594                    tail: Vec::new(),
4595                    usage: None,
4596                }],
4597                ..ViewFacts::default()
4598            },
4599        );
4600
4601        let after =
4602            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4603        let cards = after["subagent_instances"].as_array().expect("a list");
4604        assert_eq!(
4605            cards.len(),
4606            1,
4607            "and the list the workspace holds is the one that crosses: {after}"
4608        );
4609        assert_eq!(
4610            cards[0]["name"], "from the workspace",
4611            "named by the fold that announces it rather than by a second one here: {after}",
4612        );
4613    }
4614
4615    /// **A seat whose loop never ran is walked at the read**, the same
4616    /// fallback the work row takes. A fixture holds no seat, so no loop runs
4617    /// for one: the record's index is the tree's rather than an empty list
4618    /// standing in for a tree nobody looked at.
4619    #[tokio::test]
4620    async fn the_record_walks_the_index_when_the_store_holds_none() {
4621        let dir = tempfile::tempdir().expect("tempdir");
4622        let root = dir.path().join("proj");
4623        std::fs::create_dir_all(&root).expect("mkdir");
4624        std::fs::write(root.join("found.rs"), "").expect("write");
4625        let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
4626            .expect("the fleet builds");
4627        fleet.start("TestOrg", "proj").expect("the project starts");
4628        let state = TransportState {
4629            surface: fleet.surface(),
4630            work: Arc::new(WorkCache::new()),
4631            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4632            live: Mutex::new(crate::live::Live::new()),
4633            settings: crate::transport::settings_channel(),
4634            browser_host: None,
4635            heartbeat: Heartbeat::default(),
4636        };
4637
4638        let encoded =
4639            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4640
4641        let entries = encoded["file_index"]["entries"].as_object().expect("an index crosses");
4642        assert!(
4643            entries.contains_key("found.rs"),
4644            "a seat nothing has walked is walked at the read: {encoded}",
4645        );
4646    }
4647
4648    /// The PR row the inspector leads its GIT section with: the open pull
4649    /// request, and the issues it closes.
4650    ///
4651    /// The keys are asserted PRESENT rather than populated, because a
4652    /// populated one needs `gh`, a pushed branch and an open PR - which this
4653    /// fixture cannot produce. The key's presence is the property that matters
4654    /// here: a client drawing the section reads a field that is there and
4655    /// null, rather than finding no field at all.
4656    ///
4657    /// The POPULATED shape is pinned next door, from a seeded scan, and in
4658    /// the committed fixture; the live walk that reached a real PR is in the
4659    /// plan's ledger at
4660    /// `docs/superpowers/ledgers/2026-09-28-forge-server-socket/` (git-excluded,
4661    /// so no repo-visible artifact carries it).
4662    #[tokio::test]
4663    async fn a_session_snapshot_carries_the_pr_row() {
4664        let fleet =
4665            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4666        fleet.start("TestOrg", "proj").expect("the project starts");
4667        let state = TransportState {
4668            surface: fleet.surface(),
4669            work: Arc::new(WorkCache::new()),
4670            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4671            live: Mutex::new(crate::live::Live::new()),
4672            settings: crate::transport::settings_channel(),
4673            browser_host: None,
4674            heartbeat: Heartbeat::default(),
4675        };
4676
4677        let encoded =
4678            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
4679
4680        assert!(encoded.get("pr").is_some(), "the session carries a PR field: {encoded}");
4681        assert_eq!(
4682            encoded["closes"],
4683            serde_json::json!([]),
4684            "and the issues it closes, empty when there is no PR: {encoded}"
4685        );
4686    }
4687
4688    /// **The record answers the machine turn a page attaching mid-handoff
4689    /// needs.** The live page learns it from the update; this is what a
4690    /// second client reads while the judgment turn runs.
4691    #[tokio::test]
4692    async fn a_record_attached_mid_handoff_carries_the_machine_turn() {
4693        let fleet =
4694            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4695        fleet.start("TestOrg", "proj").expect("the project starts");
4696        let workspace = fleet.workspace();
4697        let seat = fixture_seat();
4698        let domain = match workspace.domain_session_for(&seat) {
4699            Some(domain) => domain,
4700            None => workspace.register_domain_session(seat.clone(), None),
4701        };
4702        let state = TransportState {
4703            surface: fleet.surface(),
4704            work: Arc::new(WorkCache::new()),
4705            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4706            live: Mutex::new(crate::live::Live::new()),
4707            settings: crate::transport::settings_channel(),
4708            browser_host: None,
4709            heartbeat: Heartbeat::default(),
4710        };
4711        let subject = Subject::Session(seat);
4712
4713        let idle = encode_subject(&state, &subject).await.expect("the record encodes");
4714        assert_eq!(idle["composer"]["machine"], Value::Null, "an idle seat takes input");
4715
4716        domain.lock().handoff.awaiting = true;
4717        let mid = encode_subject(&state, &subject).await.expect("the record encodes");
4718        assert_eq!(
4719            mid["composer"]["machine"],
4720            serde_json::json!("handoff"),
4721            "a page attaching mid-handoff reads the machine turn",
4722        );
4723    }
4724
4725    /// The record carries the changed files' hunks, bounded and flagged: a
4726    /// review surface has no other read of them.
4727    ///
4728    /// Its own fleet and its own repository rather than `fixture_state`'s: what
4729    /// this pins is the hunk shape of a three-file diff, and the shared
4730    /// fixture's record is a tree of a different shape.
4731    #[tokio::test]
4732    async fn the_record_carries_the_changed_files_with_their_hunks() {
4733        let fleet =
4734            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4735        fleet.start("TestOrg", "proj").expect("the project starts");
4736        let seat = fixture_seat();
4737        let surface = fleet.surface();
4738        let repo = tempfile::tempdir().expect("a repository directory");
4739        fixture_repo(repo.path());
4740        let diff = forge_workspace::env::git_diff::scan(
4741            repo.path(),
4742            &std::collections::HashMap::new(),
4743            None,
4744        )
4745        .await;
4746        surface.store_work_snapshot(
4747            &seat,
4748            forge_workspace::work::WorkSnapshot {
4749                diff,
4750                cwd: repo.path().to_owned(),
4751                read_at: std::time::Instant::now(),
4752                base: None,
4753                base_at: None,
4754                base_error: None,
4755            },
4756        );
4757        let state = TransportState {
4758            surface,
4759            work: Arc::new(WorkCache::new()),
4760            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4761            live: Mutex::new(crate::live::Live::new()),
4762            settings: crate::transport::settings_channel(),
4763            browser_host: None,
4764            heartbeat: Heartbeat::default(),
4765        };
4766
4767        let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
4768
4769        let files = encoded["diff"]["worktree"]["populated"]
4770            .as_array()
4771            .expect("the worktree layer is populated");
4772        let added = files.iter().find(|file| file["path"] == "added.txt").expect("added.txt");
4773        assert_eq!(added["status"], "added", "the status crosses: {encoded}");
4774        let lines = added["hunks"][0]["lines"].as_array().expect("hunks carry their lines");
4775        assert!(
4776            lines.iter().any(|line| line["kind"] == "added" && line["text"] == "added one"),
4777            "the hunk's own lines cross: {encoded}",
4778        );
4779        assert_eq!(encoded["diff"]["truncated"], false, "nothing was withheld");
4780        let ahead = encoded["diff"]["branch_ahead"]["populated"]
4781            .as_array()
4782            .expect("the branch layer is populated too");
4783        assert!(
4784            ahead.iter().any(|file| file["path"] == "branch.txt"),
4785            "and the branch's own commit is there: {encoded}",
4786        );
4787    }
4788
4789    /// The record resolves each all-changes thread against the content it
4790    /// ships, and leaves a commit-scoped thread unresolved.
4791    ///
4792    /// The landing rides the read because a client has no working tree to
4793    /// re-scan.
4794    ///
4795    /// **A commit-scoped thread is placed here too, when the branch's
4796    /// content still carries it** - the all-changes view draws it beside
4797    /// the same code, and the recorded line is never the evidence: it
4798    /// counts in the commit's numbering. A thread the branch no longer
4799    /// carries answers outdated, and a cross-scope landing carries no
4800    /// `moved_from`, whose number belongs to the other scope.
4801    ///
4802    /// **A thread written against another base is refused the landing too**:
4803    /// its line numbers belong to a diff this record does not carry, and a
4804    /// placement would be a guess (the placement refuses exactly that).
4805    #[tokio::test]
4806    async fn the_record_carries_each_threads_landing() {
4807        let fleet =
4808            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4809        fleet.start("TestOrg", "proj").expect("the project starts");
4810        let seat = fixture_seat();
4811        let surface = fleet.surface();
4812        let repo = tempfile::tempdir().expect("a repository directory");
4813        fixture_repo(repo.path());
4814        let diff = forge_workspace::env::git_diff::scan(
4815            repo.path(),
4816            &std::collections::HashMap::new(),
4817            None,
4818        )
4819        .await;
4820        surface.store_work_snapshot(
4821            &seat,
4822            forge_workspace::work::WorkSnapshot {
4823                diff,
4824                cwd: repo.path().to_owned(),
4825                read_at: std::time::Instant::now(),
4826                base: None,
4827                base_at: None,
4828                base_error: None,
4829            },
4830        );
4831        let thread = |id: &str,
4832                      path: &str,
4833                      text: &str,
4834                      line: u32,
4835                      context: &[&str],
4836                      commit: Option<&str>,
4837                      base: &str|
4838         -> forge_primitives::review::ReviewThread {
4839            forge_primitives::review::ReviewThread {
4840                id: id.to_owned(),
4841                anchor: forge_primitives::review::ReviewAnchor {
4842                    path: path.to_owned(),
4843                    side: forge_primitives::review::ReviewSide::New,
4844                    line,
4845                    content_hash: forge_workspace::env::git_diff::resolver::anchor_hash(text),
4846                    context: context.iter().map(|held| (*held).to_owned()).collect(),
4847                    base_ref: base.to_owned(),
4848                },
4849                comments: vec![forge_primitives::review::ReviewComment {
4850                    author: forge_primitives::review::ReviewAuthor::User,
4851                    text: "c".to_owned(),
4852                    at: "2026-01-01T00:00:00Z".to_owned(),
4853                    review_id: None,
4854                }],
4855                status: forge_primitives::review::ReviewStatus::Open,
4856                created_at: "2026-01-01T00:00:00Z".to_owned(),
4857                updated_at: "2026-01-01T00:00:00Z".to_owned(),
4858                commit: commit.map(str::to_owned),
4859            }
4860        };
4861        for (id, text, line, context, commit, base) in [
4862            ("placed", "committed on the branch", 1, &[][..], None, "main"),
4863            ("gone", "a line the branch does not carry", 1, &[][..], None, "main"),
4864            (
4865                "scoped",
4866                "second line",
4867                2,
4868                &["committed on the branch", "third line"][..],
4869                Some("abc123"),
4870                "main",
4871            ),
4872            ("scoped-moved", "third line", 1, &["second line"][..], Some("abc123"), "main"),
4873            ("scoped-gone", "not on the branch at all", 1, &[][..], Some("abc123"), "main"),
4874            ("other-base", "committed on the branch", 1, &[][..], None, "HEAD"),
4875        ] {
4876            assert!(
4877                fleet.workspace().upsert_review_thread(
4878                    "proj",
4879                    "worktree-pr",
4880                    thread(id, "branch.txt", text, line, context, commit, base),
4881                ),
4882                "the {id} thread stores",
4883            );
4884        }
4885        let state = TransportState {
4886            surface,
4887            work: Arc::new(WorkCache::new()),
4888            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
4889            live: Mutex::new(crate::live::Live::new()),
4890            settings: crate::transport::settings_channel(),
4891            browser_host: None,
4892            heartbeat: Heartbeat::default(),
4893        };
4894
4895        let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
4896
4897        let threads =
4898            encoded["reviews"]["threads"]["value"].as_array().expect("the threads read answered");
4899        let of = |id: &str| threads.iter().find(|held| held["id"] == id).expect(id);
4900        assert_eq!(
4901            of("placed")["resolution"],
4902            serde_json::json!({"state": "placed", "line": 1, "moved_from": null}),
4903            "an anchored line still carrying its text lands where it was written: {encoded}",
4904        );
4905        assert_eq!(
4906            of("gone")["resolution"],
4907            serde_json::json!({"state": "outdated", "reason": "gone"}),
4908            "code the branch no longer carries is outdated, in the resolver's own words",
4909        );
4910        assert_eq!(
4911            of("scoped")["resolution"],
4912            serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
4913            "a commit-scoped thread the branch still carries lands there, by content: {encoded}",
4914        );
4915        assert_eq!(
4916            of("scoped-moved")["resolution"],
4917            serde_json::json!({"state": "placed", "line": 3, "moved_from": null}),
4918            "and the recorded line is not named beside this scope's: it counts in the commit's",
4919        );
4920        assert_eq!(
4921            of("scoped-gone")["resolution"],
4922            serde_json::json!({"state": "outdated", "reason": "gone"}),
4923            "a commit-scoped thread the branch no longer carries is outdated there",
4924        );
4925        assert_eq!(
4926            of("other-base")["resolution"],
4927            serde_json::Value::Null,
4928            "nor has one numbered against another base, whose lines address other code here",
4929        );
4930    }
4931
4932    /// A branch and its base sharing a wide file, so a change in its middle
4933    /// leaves the ends outside the branch-ahead diff's hunks.
4934    fn wide_repo(path: &Path) {
4935        let git = |args: &[&str]| {
4936            let out = std::process::Command::new("git")
4937                .env_remove("GIT_DIR")
4938                .env_remove("GIT_WORK_TREE")
4939                .env_remove("GIT_COMMON_DIR")
4940                .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00+00:00")
4941                .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00+00:00")
4942                .arg("-C")
4943                .arg(path)
4944                .args(args)
4945                .output()
4946                .expect("run git");
4947            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
4948        };
4949        let wide = |ten: &str| -> String {
4950            (1..=20)
4951                .map(|n| if n == 10 { format!("{ten}\n") } else { format!("wide {n}\n") })
4952                .collect()
4953        };
4954        std::fs::create_dir_all(path).expect("the fixture's directory");
4955        git(&["init", "-q"]);
4956        git(&["symbolic-ref", "HEAD", "refs/heads/main"]);
4957        git(&["config", "user.email", "fixture@example.test"]);
4958        git(&["config", "user.name", "Fixture"]);
4959        git(&["config", "commit.gpgsign", "false"]);
4960        std::fs::write(path.join("wide.txt"), wide("wide ten")).expect("write wide.txt");
4961        git(&["add", "-A"]);
4962        git(&["commit", "-qm", "base"]);
4963        git(&["checkout", "-q", "-b", "worktree-pr"]);
4964        std::fs::write(path.join("wide.txt"), wide("wide ten changed")).expect("write wide.txt");
4965        git(&["add", "-A"]);
4966        git(&["commit", "-qm", "branch"]);
4967    }
4968
4969    /// **A comment written on a revealed line lands in the whole file.**
4970    /// The line exists in the branch, but outside the branch-ahead diff's
4971    /// bounded hunk; the record holds the whole file for exactly that case,
4972    /// at the revision the anchor's side is numbered in. Text the file does
4973    /// not carry is still outdated.
4974    #[tokio::test]
4975    async fn the_record_places_a_thread_outside_the_hunks_in_the_whole_file() {
4976        let fleet =
4977            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
4978        fleet.start("TestOrg", "proj").expect("the project starts");
4979        let seat = fixture_seat();
4980        let surface = fleet.surface();
4981        let repo = tempfile::tempdir().expect("a repository directory");
4982        wide_repo(repo.path());
4983        let diff = forge_workspace::env::git_diff::scan(
4984            repo.path(),
4985            &std::collections::HashMap::new(),
4986            None,
4987        )
4988        .await;
4989        surface.store_work_snapshot(
4990            &seat,
4991            forge_workspace::work::WorkSnapshot {
4992                diff,
4993                cwd: repo.path().to_owned(),
4994                read_at: std::time::Instant::now(),
4995                base: None,
4996                base_at: None,
4997                base_error: None,
4998            },
4999        );
5000        let thread = |id: &str, side, text: &str, context: &[&str], commit: Option<&str>| {
5001            forge_primitives::review::ReviewThread {
5002                id: id.to_owned(),
5003                anchor: forge_primitives::review::ReviewAnchor {
5004                    path: "wide.txt".to_owned(),
5005                    side,
5006                    line: 2,
5007                    content_hash: forge_workspace::env::git_diff::resolver::anchor_hash(text),
5008                    context: context.iter().map(|held| (*held).to_owned()).collect(),
5009                    base_ref: "main".to_owned(),
5010                },
5011                comments: vec![forge_primitives::review::ReviewComment {
5012                    author: forge_primitives::review::ReviewAuthor::User,
5013                    text: "c".to_owned(),
5014                    at: "2026-01-01T00:00:00Z".to_owned(),
5015                    review_id: None,
5016                }],
5017                status: forge_primitives::review::ReviewStatus::Open,
5018                created_at: "2026-01-01T00:00:00Z".to_owned(),
5019                updated_at: "2026-01-01T00:00:00Z".to_owned(),
5020                commit: commit.map(str::to_owned),
5021            }
5022        };
5023        for held in [
5024            thread("revealed-new", ReviewSide::New, "wide 2", &["wide 1", "wide 3"], None),
5025            thread("revealed-old", ReviewSide::Old, "wide 2", &["wide 1", "wide 3"], None),
5026            thread("revealed-gone", ReviewSide::New, "not in the file", &[], None),
5027            // The same path and side as an eligible read, but its line counts
5028            // in its own commit: it must not borrow that read.
5029            thread(
5030                "scoped-borrow",
5031                ReviewSide::New,
5032                "wide 2",
5033                &["wide 1", "wide 3"],
5034                Some("abc123"),
5035            ),
5036        ] {
5037            assert!(
5038                fleet.workspace().upsert_review_thread("proj", "worktree-pr", held),
5039                "the thread stores",
5040            );
5041        }
5042        let state = TransportState {
5043            surface,
5044            work: Arc::new(WorkCache::new()),
5045            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5046            live: Mutex::new(crate::live::Live::new()),
5047            settings: crate::transport::settings_channel(),
5048            browser_host: None,
5049            heartbeat: Heartbeat::default(),
5050        };
5051        let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
5052        let threads =
5053            encoded["reviews"]["threads"]["value"].as_array().expect("the threads read answered");
5054        let of = |id: &str| threads.iter().find(|held| held["id"] == id).expect(id);
5055        assert_eq!(
5056            of("revealed-new")["resolution"],
5057            serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5058            "a new-side line outside the hunks lands in the branch tip's file: {encoded}",
5059        );
5060        assert_eq!(
5061            of("revealed-old")["resolution"],
5062            serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5063            "and an old-side one in the base's, whose numbering is its own: {encoded}",
5064        );
5065        assert_eq!(
5066            of("revealed-gone")["resolution"],
5067            serde_json::json!({"state": "outdated", "reason": "gone"}),
5068            "code the file does not carry is still outdated: {encoded}",
5069        );
5070        assert_eq!(
5071            of("scoped-borrow")["resolution"],
5072            serde_json::json!({"state": "outdated", "reason": "gone"}),
5073            "a commit-scoped thread never borrows the branch's read: {encoded}",
5074        );
5075    }
5076
5077    /// Run one git command in a fixture repository, with pinned dates.
5078    fn git_in(path: &Path, args: &[&str]) {
5079        let out = std::process::Command::new("git")
5080            .env_remove("GIT_DIR")
5081            .env_remove("GIT_WORK_TREE")
5082            .env_remove("GIT_COMMON_DIR")
5083            .env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00+00:00")
5084            .env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00+00:00")
5085            .arg("-C")
5086            .arg(path)
5087            .args(args)
5088            .output()
5089            .expect("run git");
5090        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
5091    }
5092
5093    /// Init a fixture repository on `main` with `user`/`email`/no signing.
5094    fn init_repo(path: &Path) {
5095        std::fs::create_dir_all(path).expect("the fixture's directory");
5096        git_in(path, &["init", "-q"]);
5097        git_in(path, &["symbolic-ref", "HEAD", "refs/heads/main"]);
5098        git_in(path, &["config", "user.email", "fixture@example.test"]);
5099        git_in(path, &["config", "user.name", "Fixture"]);
5100        git_in(path, &["config", "commit.gpgsign", "false"]);
5101    }
5102
5103    /// Twenty numbered lines, with `ten` at the tenth.
5104    fn wide_lines(ten: &str) -> String {
5105        (1..=20).map(|n| if n == 10 { format!("{ten}\n") } else { format!("wide {n}\n") }).collect()
5106    }
5107
5108    /// Store `diff` as the seat's scan and encode the record.
5109    async fn encode_repo(
5110        fleet: &crate::testing::Fleet,
5111        seat: &SessionSlot,
5112        repo: &Path,
5113    ) -> serde_json::Value {
5114        let surface = fleet.surface();
5115        let diff =
5116            forge_workspace::env::git_diff::scan(repo, &std::collections::HashMap::new(), None)
5117                .await;
5118        surface.store_work_snapshot(
5119            seat,
5120            forge_workspace::work::WorkSnapshot {
5121                diff,
5122                cwd: repo.to_owned(),
5123                read_at: std::time::Instant::now(),
5124                base: None,
5125                base_at: None,
5126                base_error: None,
5127            },
5128        );
5129        let state = TransportState {
5130            surface,
5131            work: Arc::new(WorkCache::new()),
5132            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5133            live: Mutex::new(crate::live::Live::new()),
5134            settings: crate::transport::settings_channel(),
5135            browser_host: None,
5136            heartbeat: Heartbeat::default(),
5137        };
5138        encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode")
5139    }
5140
5141    /// One thread anchored on `wide.txt` at `line`, on `side`.
5142    fn wide_thread(
5143        id: &str,
5144        side: ReviewSide,
5145        line: u32,
5146        text: &str,
5147        context: &[&str],
5148        commit: Option<&str>,
5149    ) -> ReviewThread {
5150        ReviewThread {
5151            id: id.to_owned(),
5152            anchor: ReviewAnchor {
5153                path: "wide.txt".to_owned(),
5154                side,
5155                line,
5156                content_hash: forge_workspace::env::git_diff::resolver::anchor_hash(text),
5157                context: context.iter().map(|held| (*held).to_owned()).collect(),
5158                base_ref: "main".to_owned(),
5159            },
5160            comments: vec![forge_primitives::review::ReviewComment {
5161                author: forge_primitives::review::ReviewAuthor::User,
5162                text: "c".to_owned(),
5163                at: "2026-01-01T00:00:00Z".to_owned(),
5164                review_id: None,
5165            }],
5166            status: forge_primitives::review::ReviewStatus::Open,
5167            created_at: "2026-01-01T00:00:00Z".to_owned(),
5168            updated_at: "2026-01-01T00:00:00Z".to_owned(),
5169            commit: commit.map(str::to_owned),
5170        }
5171    }
5172
5173    /// The landing of one thread in an encoded record.
5174    fn landing(encoded: &serde_json::Value, id: &str) -> serde_json::Value {
5175        let threads =
5176            encoded["reviews"]["threads"]["value"].as_array().expect("the threads read answered");
5177        threads.iter().find(|held| held["id"] == id).expect(id)["resolution"].clone()
5178    }
5179
5180    /// **The old side is numbered at the merge-base, not at the base's
5181    /// current tip.** A base that has advanced since the branch forked shifts
5182    /// every line in its tip, so a revealed old-side comment read there would
5183    /// land one line off (or park Gone). A once-forked, then-advanced base is
5184    /// the ordinary shape of a long-lived branch.
5185    #[tokio::test]
5186    async fn the_record_reads_the_old_side_at_the_merge_base() {
5187        let fleet =
5188            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5189        fleet.start("TestOrg", "proj").expect("the project starts");
5190        let seat = fixture_seat();
5191        let repo = tempfile::tempdir().expect("a repository directory");
5192        init_repo(repo.path());
5193        std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10")).expect("write");
5194        git_in(repo.path(), &["add", "-A"]);
5195        git_in(repo.path(), &["commit", "-qm", "base"]);
5196        git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5197        std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10 changed")).expect("write");
5198        git_in(repo.path(), &["add", "-A"]);
5199        git_in(repo.path(), &["commit", "-qm", "branch"]);
5200        // The base advances under the fork: a line inserted at the top shifts
5201        // every line number below it.
5202        git_in(repo.path(), &["checkout", "-q", "main"]);
5203        let advanced: String = std::iter::once("wide 0\n".to_owned())
5204            .chain((1..=20).map(|n| format!("wide {n}\n")))
5205            .collect();
5206        std::fs::write(repo.path().join("wide.txt"), advanced).expect("write");
5207        git_in(repo.path(), &["add", "-A"]);
5208        git_in(repo.path(), &["commit", "-qm", "advance"]);
5209        git_in(repo.path(), &["checkout", "-q", "worktree-pr"]);
5210        for held in [
5211            // The old side numbers at the merge-base.
5212            wide_thread("revealed-old", ReviewSide::Old, 2, "wide 2", &["wide 1", "wide 3"], None),
5213            // And the new side at the branch tip: the base's own numbering
5214            // shifted the same line, so a read there would place it one down.
5215            wide_thread(
5216                "revealed-new",
5217                ReviewSide::New,
5218                16,
5219                "wide 16",
5220                &["wide 15", "wide 17"],
5221                None,
5222            ),
5223        ] {
5224            assert!(
5225                fleet.workspace().upsert_review_thread("proj", "worktree-pr", held),
5226                "the thread stores",
5227            );
5228        }
5229        let encoded = encode_repo(&fleet, &seat, repo.path()).await;
5230        assert_eq!(
5231            landing(&encoded, "revealed-old"),
5232            serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5233            "the merge-base's line 2 is the comment's own, though main's tip shifted it: {encoded}",
5234        );
5235        assert_eq!(
5236            landing(&encoded, "revealed-new"),
5237            serde_json::json!({"state": "placed", "line": 16, "moved_from": null}),
5238            "and the branch tip's line 16 is the new side's own: {encoded}",
5239        );
5240    }
5241
5242    /// **The old side resolves against the base the diff was pinned to, not
5243    /// the ref's name.** The name can have moved to a commit the branch now
5244    /// contains - the base-only move - and the merge-base resolved at that
5245    /// name is a different commit from the one the diff was computed against.
5246    #[tokio::test]
5247    async fn the_old_side_reads_the_pinned_base_not_the_moved_name() {
5248        let repo = tempfile::tempdir().expect("a repository directory");
5249        init_repo(repo.path());
5250        std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10")).expect("write");
5251        git_in(repo.path(), &["add", "-A"]);
5252        git_in(repo.path(), &["commit", "-qm", "base"]);
5253        git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5254        std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10 changed")).expect("write");
5255        git_in(repo.path(), &["add", "-A"]);
5256        git_in(repo.path(), &["commit", "-qm", "branch"]);
5257        let base = forge_workspace::env::git_diff::file::merge_base(repo.path(), "main")
5258            .await
5259            .expect("the fork point");
5260        // The base advances with a line inserted at the top, which shifts every
5261        // line number below it, and the branch then merges that advance: the
5262        // committed-to name IS the branch's history, while the pinned base the
5263        // diff was computed against is the fork.
5264        git_in(repo.path(), &["checkout", "-q", "main"]);
5265        let advanced: String = std::iter::once("wide 0\n".to_owned())
5266            .chain((1..=20).map(|n| format!("wide {n}\n")))
5267            .collect();
5268        std::fs::write(repo.path().join("wide.txt"), advanced).expect("write");
5269        git_in(repo.path(), &["add", "-A"]);
5270        git_in(repo.path(), &["commit", "-qm", "advance"]);
5271        let moved = std::process::Command::new("git")
5272            .arg("-C")
5273            .arg(repo.path())
5274            .args(["rev-parse", "main"])
5275            .output()
5276            .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
5277            .expect("git rev-parse");
5278        git_in(repo.path(), &["checkout", "-q", "worktree-pr"]);
5279        git_in(repo.path(), &["merge", "-q", "--no-edit", "-X", "theirs", "main"]);
5280
5281        let snapshot = forge_workspace::env::git_diff::scan(
5282            repo.path(),
5283            &std::collections::HashMap::new(),
5284            None,
5285        )
5286        .await;
5287        let content =
5288            forge_workspace::env::git_diff::content::scan(repo.path(), &snapshot, None, None, None)
5289                .await;
5290        let threads = vec![wide_thread(
5291            "revealed-old",
5292            ReviewSide::Old,
5293            2,
5294            "wide 2",
5295            &["wide 1", "wide 3"],
5296            None,
5297        )];
5298        let key = ("wide.txt".to_owned(), ReviewSide::Old);
5299
5300        let pinned =
5301            whole_files_for(repo.path(), 1 << 20, &threads, &content, Some("main"), Some(&base))
5302                .await;
5303        assert!(pinned.contains_key(&key), "the pinned base places the anchor");
5304        assert!(
5305            pinned[&key].lines.iter().any(|line| line.as_str() == "wide 2"),
5306            "and the file it read is the merge-base's",
5307        );
5308
5309        let by_name =
5310            whole_files_for(repo.path(), 1 << 20, &threads, &content, Some("main"), Some(&moved))
5311                .await;
5312        assert_eq!(
5313            by_name[&key].lines.get(1).map(String::as_str),
5314            Some("wide 1"),
5315            "the moved base reads a shifted line 2, which is the anchor's own proof it moved",
5316        );
5317    }
5318
5319    /// **A line the hunks do not carry is answered by the whole file alone.**
5320    /// A repeated block inside a hunk offers a content-and-neighbours match
5321    /// for an outside line that reads the same; the bounded content must not
5322    /// place the comment there.
5323    #[tokio::test]
5324    async fn the_whole_file_wins_for_a_line_no_hunk_carries() {
5325        let fleet =
5326            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5327        fleet.start("TestOrg", "proj").expect("the project starts");
5328        let seat = fixture_seat();
5329        let repo = tempfile::tempdir().expect("a repository directory");
5330        init_repo(repo.path());
5331        let body = |target: &str| -> String {
5332            let mut lines: Vec<String> = (1..=30).map(|n| format!("filler {n}\n")).collect();
5333            lines[1] = "alpha\n".to_owned();
5334            lines[2] = "beta\n".to_owned();
5335            lines[3] = "gamma\n".to_owned();
5336            lines[17] = "alpha\n".to_owned();
5337            lines[18] = "beta\n".to_owned();
5338            lines[19] = "gamma\n".to_owned();
5339            lines[20] = format!("{target}\n");
5340            lines.concat()
5341        };
5342        std::fs::write(repo.path().join("wide.txt"), body("target")).expect("write");
5343        git_in(repo.path(), &["add", "-A"]);
5344        git_in(repo.path(), &["commit", "-qm", "base"]);
5345        git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5346        std::fs::write(repo.path().join("wide.txt"), body("target changed")).expect("write");
5347        git_in(repo.path(), &["add", "-A"]);
5348        git_in(repo.path(), &["commit", "-qm", "branch"]);
5349        for held in [
5350            // Outside the hunk (which covers 18..=24), and its text plus
5351            // neighbours also match the repeated block inside it.
5352            wide_thread("outside", ReviewSide::New, 3, "beta", &["alpha", "gamma"], None),
5353            // And the hunk's own copy still places there.
5354            wide_thread("inside", ReviewSide::New, 19, "beta", &["alpha", "gamma"], None),
5355        ] {
5356            assert!(
5357                fleet.workspace().upsert_review_thread("proj", "worktree-pr", held),
5358                "the thread stores",
5359            );
5360        }
5361        let encoded = encode_repo(&fleet, &seat, repo.path()).await;
5362        assert_eq!(
5363            landing(&encoded, "outside"),
5364            serde_json::json!({"state": "placed", "line": 3, "moved_from": null}),
5365            "the outside line is its own, not the hunk's lookalike: {encoded}",
5366        );
5367        assert_eq!(
5368            landing(&encoded, "inside"),
5369            serde_json::json!({"state": "placed", "line": 19, "moved_from": null}),
5370            "a line the hunks carry still places in the hunks: {encoded}",
5371        );
5372    }
5373
5374    /// **The old side's read follows the rename.** A file renamed with edits
5375    /// keeps its old content at the old path in the base tree; reading the
5376    /// new path there would park an old-side comment Gone though its line is
5377    /// untouched.
5378    #[tokio::test]
5379    async fn the_record_reads_a_renamed_files_old_side_at_its_old_path() {
5380        let fleet =
5381            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5382        fleet.start("TestOrg", "proj").expect("the project starts");
5383        let seat = fixture_seat();
5384        let repo = tempfile::tempdir().expect("a repository directory");
5385        init_repo(repo.path());
5386        std::fs::write(repo.path().join("wide.txt"), wide_lines("wide 10")).expect("write");
5387        git_in(repo.path(), &["add", "-A"]);
5388        git_in(repo.path(), &["commit", "-qm", "base"]);
5389        git_in(repo.path(), &["checkout", "-q", "-b", "worktree-pr"]);
5390        git_in(repo.path(), &["mv", "wide.txt", "renamed.txt"]);
5391        std::fs::write(repo.path().join("renamed.txt"), wide_lines("wide 10 changed"))
5392            .expect("write");
5393        git_in(repo.path(), &["add", "-A"]);
5394        git_in(repo.path(), &["commit", "-qm", "rename"]);
5395        let mut held =
5396            wide_thread("revealed-old", ReviewSide::Old, 2, "wide 2", &["wide 1", "wide 3"], None);
5397        held.anchor.path = "renamed.txt".to_owned();
5398        assert!(fleet.workspace().upsert_review_thread("proj", "worktree-pr", held), "stores");
5399        let encoded = encode_repo(&fleet, &seat, repo.path()).await;
5400        assert_eq!(
5401            landing(&encoded, "revealed-old"),
5402            serde_json::json!({"state": "placed", "line": 2, "moved_from": null}),
5403            "the old side lives at the old path, unchanged: {encoded}",
5404        );
5405    }
5406
5407    /// **The whole-file reads are bounded per record.** Ten comments outside
5408    /// their hunks ask for ten files; the encode reads eight and leaves the
5409    /// rest to the bounded content, so a heavily commented branch cannot cost
5410    /// an unbounded pile of git subprocesses on every seat read.
5411    #[tokio::test]
5412    async fn the_whole_file_reads_are_bounded_per_record() {
5413        let repo = tempfile::tempdir().expect("a repository directory");
5414        init_repo(repo.path());
5415        let mut files = Vec::new();
5416        let mut threads = Vec::new();
5417        for n in 0..10 {
5418            let path = format!("f{n}.txt");
5419            std::fs::write(repo.path().join(&path), "one\ntwo\nthree\n").expect("write");
5420            files.push(FileContent {
5421                path: path.clone(),
5422                old_path: None,
5423                status: forge_workspace::env::git_diff::hunks::FileStatus::Modified,
5424                binary: false,
5425                submodule: false,
5426                truncated: false,
5427                hunks: vec![forge_workspace::env::git_diff::hunks::Hunk {
5428                    old_start: 3,
5429                    old_count: 1,
5430                    new_start: 3,
5431                    new_count: 1,
5432                    lines: vec![forge_workspace::env::git_diff::hunks::DiffLine {
5433                        kind: forge_workspace::env::git_diff::hunks::DiffLineKind::Context,
5434                        text: "three".to_owned(),
5435                        old_line: Some(3),
5436                        new_line: Some(3),
5437                    }],
5438                }],
5439            });
5440            threads.push(ReviewThread {
5441                id: format!("t{n}"),
5442                anchor: ReviewAnchor {
5443                    path,
5444                    side: ReviewSide::New,
5445                    line: 2,
5446                    content_hash: forge_workspace::env::git_diff::resolver::anchor_hash("two"),
5447                    context: vec!["one".to_owned(), "three".to_owned()],
5448                    base_ref: "main".to_owned(),
5449                },
5450                comments: Vec::new(),
5451                status: forge_primitives::review::ReviewStatus::Open,
5452                created_at: "2026-01-01T00:00:00Z".to_owned(),
5453                updated_at: "2026-01-01T00:00:00Z".to_owned(),
5454                commit: None,
5455            });
5456        }
5457        git_in(repo.path(), &["add", "-A"]);
5458        git_in(repo.path(), &["commit", "-qm", "base"]);
5459        let content = DiffContent {
5460            worktree: LayerState::Clean,
5461            branch_ahead: LayerState::Populated(files),
5462            base: Some(forge_primitives::git_diff::BaseState::Current),
5463            base_error: None,
5464            truncated: false,
5465        };
5466        let whole = whole_files_for(
5467            repo.path(),
5468            10 * 1024 * 1024,
5469            &threads,
5470            &content,
5471            Some("main"),
5472            Some("main"),
5473        )
5474        .await;
5475        assert_eq!(whole.len(), 8, "the encode stops at its eight-file bound");
5476        assert!(
5477            !whole.contains_key(&("f9.txt".to_owned(), ReviewSide::New)),
5478            "and the threads past it keep the bounded content's answer",
5479        );
5480    }
5481
5482    /// A read cut before the anchored line cannot answer: the record carries
5483    /// no landing rather than calling the code gone.
5484    #[test]
5485    fn a_read_cut_before_the_anchor_answers_no_landing() {
5486        let anchor = ReviewAnchor {
5487            path: "wide.txt".to_owned(),
5488            side: ReviewSide::New,
5489            line: 5,
5490            content_hash: forge_workspace::env::git_diff::resolver::anchor_hash("wide 5"),
5491            context: Vec::new(),
5492            base_ref: "main".to_owned(),
5493        };
5494        let cut = WholeFiles::from([(
5495            ("wide.txt".to_owned(), ReviewSide::New),
5496            WholeFile { lines: vec!["wide 1".to_owned()], truncated: true },
5497        )]);
5498        assert!(
5499            resolution_wire(&anchor, &[], true, Some(&cut)).is_none(),
5500            "a line past the cut is unresolved, not gone",
5501        );
5502        let whole = WholeFiles::from([(
5503            ("wide.txt".to_owned(), ReviewSide::New),
5504            WholeFile { lines: vec!["wide 1".to_owned()], truncated: false },
5505        )]);
5506        assert!(
5507            matches!(
5508                resolution_wire(&anchor, &[], true, Some(&whole)),
5509                Some(ThreadResolutionWire::Outdated { reason: OutdatedReasonWire::Gone })
5510            ),
5511            "a complete read that does not carry the line is gone",
5512        );
5513    }
5514
5515    /// A commit-scoped thread must not borrow the branch's whole-file read:
5516    /// its line counts in its own commit's numbering.
5517    #[test]
5518    fn a_commit_scoped_thread_does_not_borrow_the_branch_read() {
5519        let anchor = wide_thread("commit", ReviewSide::New, 2, "wide 2", &["wide 1"], None).anchor;
5520        let whole = WholeFiles::from([(
5521            ("wide.txt".to_owned(), ReviewSide::New),
5522            WholeFile { lines: vec!["wide 1".to_owned(), "wide 2".to_owned()], truncated: false },
5523        )]);
5524        assert!(
5525            matches!(
5526                resolution_wire(&anchor, &[], false, None),
5527                Some(ThreadResolutionWire::Outdated { reason: OutdatedReasonWire::Gone })
5528            ),
5529            "with no read of its own, the commit's thread is not placed by the branch's",
5530        );
5531        assert!(
5532            matches!(
5533                resolution_wire(&anchor, &[], false, Some(&whole)),
5534                Some(ThreadResolutionWire::Placed { line: 2, .. })
5535            ),
5536            "and the same anchor would have been placed had it been eligible",
5537        );
5538    }
5539
5540    /// A POPULATED PR row, from a seeded scan rather than from `gh`.
5541    ///
5542    /// The key's presence is not enough on its own: a regression that answers
5543    /// no PR - a wrong directory handed to the read, say - keeps the key and
5544    /// passes that. This drives the values a client draws.
5545    #[tokio::test]
5546    async fn the_pr_row_carries_the_scans_pr_and_its_closing_issues() {
5547        let fleet =
5548            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5549        fleet.start("TestOrg", "proj").expect("the project starts");
5550        let seat = fixture_seat();
5551        let surface = fleet.surface();
5552        let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
5553        surface.store_work_snapshot(
5554            &seat,
5555            forge_workspace::work::WorkSnapshot {
5556                diff: scanned(),
5557                cwd: std::path::PathBuf::from(&cwd),
5558                read_at: std::time::Instant::now(),
5559                base: None,
5560                base_at: None,
5561                base_error: None,
5562            },
5563        );
5564        let work = Arc::new(WorkCache::new());
5565        let state = TransportState {
5566            surface,
5567            work,
5568            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5569            live: Mutex::new(crate::live::Live::new()),
5570            settings: crate::transport::settings_channel(),
5571            browser_host: None,
5572            heartbeat: Heartbeat::default(),
5573        };
5574
5575        let encoded = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
5576
5577        assert_eq!(encoded["pr"]["number"], 1249, "the open PR crosses with its number: {encoded}");
5578        assert_eq!(encoded["closes"][0]["number"], 1215, "and the issues it closes: {encoded}");
5579        assert_eq!(
5580            encoded["work"]["branch"], "worktree-pr",
5581            "and the branch comes from the SAME scan as the PR, so the two cannot disagree: \
5582             {encoded}"
5583        );
5584        assert_eq!(encoded["work"]["changed"], 3, "with the count that scan took: {encoded}");
5585    }
5586
5587    /// The pushed row and the record's own fields are one answer.
5588    ///
5589    /// Both are the seat's store read the same way, so a client that applies
5590    /// the update lands on exactly what a client that read the record holds -
5591    /// a second derivation is the only way the two could ever disagree.
5592    #[tokio::test]
5593    async fn the_pushed_row_is_the_records_own_fields() {
5594        // Its OWN fleet rather than `fixture_state`'s: what this pins is a
5595        // snapshot built by `scanned()`, not the fixture's own tree.
5596        let fleet =
5597            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5598        fleet.start("TestOrg", "proj").expect("the project starts");
5599        let seat = fixture_seat();
5600        let surface = fleet.surface();
5601        let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
5602        surface.store_work_snapshot(
5603            &seat,
5604            forge_workspace::work::WorkSnapshot {
5605                diff: scanned(),
5606                cwd: std::path::PathBuf::from(&cwd),
5607                read_at: std::time::Instant::now(),
5608                base: None,
5609                base_at: None,
5610                base_error: None,
5611            },
5612        );
5613        let state = TransportState {
5614            surface,
5615            work: Arc::new(WorkCache::new()),
5616            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5617            live: Mutex::new(crate::live::Live::new()),
5618            settings: crate::transport::settings_channel(),
5619            browser_host: None,
5620            heartbeat: Heartbeat::default(),
5621        };
5622
5623        let encoded =
5624            encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode");
5625        let held = state.surface.work(&seat, std::path::Path::new(&cwd), false).await;
5626        let pushed = forge_workspace::work::work_from_scan(&held.diff, &held.cwd);
5627
5628        assert_eq!(
5629            encoded["work"],
5630            serde_json::to_value(&pushed).expect("encode"),
5631            "the row the seat pushes is the row its record answers",
5632        );
5633        assert_eq!(
5634            encoded["pr"],
5635            serde_json::to_value(&held.diff.pr).expect("encode"),
5636            "and so is the PR beside it",
5637        );
5638        assert_eq!(
5639            encoded["closes"],
5640            serde_json::to_value(&held.diff.closes).expect("encode"),
5641            "and the issues it closes",
5642        );
5643        assert_eq!(
5644            encoded["git"],
5645            serde_json::to_value(forge_workspace::work::git_work_view(&held.diff, None, None))
5646                .expect("encode"),
5647            "and the tree behind the row's depth",
5648        );
5649    }
5650
5651    /// A scan as a view's inspector reads it: one branch, one worktree
5652    /// layer, one chain ahead, one PR and one closing issue. Both depth
5653    /// layers are populated so the record-vs-constructor guard covers both
5654    /// axes - a derivation omitting either reddens.
5655    fn scanned() -> GitDiffSnapshot {
5656        use forge_primitives::git::{GitBranch, GitCommit, GitIssueRef, GitPrInfo};
5657        use forge_primitives::git_diff::{GitBranchAhead, GitDiffStats, LayerState, RepoGate};
5658        GitDiffSnapshot {
5659            branch: GitBranch::Named("worktree-pr".to_owned()),
5660            pushed_sha: Some("abc123".to_owned()),
5661            pr_fetched_at: None,
5662            default_branch: Some("main".to_owned()),
5663            repo_gate: RepoGate::InRepo,
5664            worktree: LayerState::Populated(GitDiffStats {
5665                files: Vec::new(),
5666                total_files: 3,
5667                total_added: 9,
5668                total_removed: 2,
5669            }),
5670            branch_ahead: LayerState::Populated(GitBranchAhead {
5671                commit_count: 1,
5672                stats: GitDiffStats {
5673                    files: Vec::new(),
5674                    total_files: 0,
5675                    total_added: 0,
5676                    total_removed: 0,
5677                },
5678                commits: vec![GitCommit {
5679                    sha: "a1b2c3d".to_owned(),
5680                    subject: "the branch's own commit".to_owned(),
5681                    stats: GitDiffStats::default(),
5682                    time: 1_766_000_000,
5683                }],
5684            }),
5685            pr: Some(GitPrInfo {
5686                number: 1249,
5687                url: "https://example.test/pull/1249".to_owned(),
5688                draft: false,
5689            }),
5690            closes: vec![GitIssueRef {
5691                number: 1215,
5692                url: "https://example.test/issues/1215".to_owned(),
5693            }],
5694        }
5695    }
5696
5697    /// The token/cost report, on the `usage` subject.
5698    /// Nothing carried it: the pool is scanned off-thread by the server,
5699    /// and a client had no way to ask for the same numbers.
5700    ///
5701    /// Its own fleet rather than `fixture_state`'s, whose transcript pool the
5702    /// usage scan reads for a whole record: what this pins is a pool of one
5703    /// transcript.
5704    #[tokio::test]
5705    async fn a_usage_subscription_is_answered_with_the_pools_report() {
5706        let fleet =
5707            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5708        fleet.start("TestOrg", "proj").expect("the project starts");
5709        // A stamp in the past on purpose: a record stamped "now" flips which
5710        // rolling windows hold it at midnight, so the assertion below would be
5711        // a coin toss once a day.
5712        fleet
5713            .seed_transcript(
5714                "TestOrg",
5715                "proj",
5716                "lead",
5717                &[r#"{"type":"assistant","timestamp":"2025-01-02T03:04:05.000Z","message":{"id":"m-usage","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"counted"}],"usage":{"input_tokens":7,"output_tokens":11}}}"#],
5718            )
5719            .expect("the transcript seeds");
5720        let state = TransportState {
5721            surface: fleet.surface(),
5722            work: Arc::new(WorkCache::new()),
5723            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5724            live: Mutex::new(crate::live::Live::new()),
5725            settings: crate::transport::settings_channel(),
5726            browser_host: None,
5727            heartbeat: Heartbeat::default(),
5728        };
5729
5730        let encoded = encode_subject(&state, &Subject::Usage).await.expect("encode");
5731
5732        assert_eq!(
5733            encoded["lifetime"]["total"]["input"], 7,
5734            "the pool's own count crosses, so the read is the scanner's: {encoded}"
5735        );
5736        assert_eq!(
5737            encoded["today"]["total"]["input"], 0,
5738            "and a record from a past day is in no rolling window: {encoded}"
5739        );
5740        assert!(
5741            encoded.get("pricing_available").is_some(),
5742            "and the flag that blanks costs rather than reading a misleading zero: {encoded}"
5743        );
5744    }
5745
5746    /// **The served page caps an oversized `tool_use_result` (#2374), and
5747    /// only there.** The held copy keeps the record whole - the transcript
5748    /// and the visible block are untouched - while the page replaces it
5749    /// with an `omitted` marker; a record under the cap crosses as it is.
5750    #[tokio::test]
5751    async fn the_served_page_caps_an_oversized_tool_record() {
5752        let fleet =
5753            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
5754        fleet.start("TestOrg", "proj").expect("the project starts");
5755        let state = TransportState {
5756            surface: fleet.surface(),
5757            work: Arc::new(WorkCache::new()),
5758            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
5759            live: Mutex::new(crate::live::Live::new()),
5760            settings: crate::transport::settings_channel(),
5761            browser_host: None,
5762            heartbeat: Heartbeat::default(),
5763        };
5764        let huge = "x".repeat(100_000);
5765        let frame = |uuid: &str, record: Value| -> Message {
5766            serde_json::from_value(serde_json::json!({
5767                "type": "user",
5768                "uuid": uuid,
5769                "message": {"role": "user", "content": "the call"},
5770                "tool_use_result": record,
5771                "session_id": "s",
5772            }))
5773            .expect("a user frame")
5774        };
5775        let big = frame("b1", serde_json::json!({"payload": huge}));
5776        let small = frame("s1", serde_json::json!({"answers": {"q": "a"}}));
5777        state.conversations.insert(
5778            &fixture_seat(),
5779            crate::transport::conversation::Conversation::new(
5780                vec![big.clone(), small.clone()],
5781                0,
5782                0,
5783            ),
5784        );
5785
5786        let (value, _) = encode_subject_timed(&state, &Subject::Session(fixture_seat()), false)
5787            .await
5788            .expect("the seat encodes");
5789        let turns = value["conversation"]["turns"].as_array().expect("the page carries turns");
5790        let frame = |uuid: &str| -> &Value {
5791            turns
5792                .iter()
5793                .flat_map(|turn| turn["messages"].as_array().into_iter().flatten())
5794                .find(|frame| frame["uuid"] == uuid)
5795                .unwrap_or_else(|| panic!("the page carries the frame {uuid}"))
5796        };
5797        let capped = &frame("b1")["tool_use_result"];
5798        assert_eq!(capped["omitted"], true, "the big record is marked: {capped}");
5799        assert!(
5800            capped["bytes"].as_u64().is_some_and(|bytes| bytes > 100_000),
5801            "and says how much it weighed: {capped}",
5802        );
5803        assert_eq!(
5804            frame("s1")["tool_use_result"],
5805            serde_json::json!({"answers": {"q": "a"}}),
5806            "a record under the cap crosses as it is",
5807        );
5808
5809        // The page is bounded by the cap rather than by the record.
5810        let conversation = serde_json::to_vec(&value["conversation"]).expect("serialises");
5811        assert!(
5812            conversation.len() < 30_000,
5813            "the served conversation is bounded: {} bytes",
5814            conversation.len(),
5815        );
5816
5817        // **And the held copy still carries it whole**: the cap is the
5818        // page's, not the transcript's.
5819        let held = state.conversations.get(&fixture_seat()).expect("the seat is held");
5820        let kept = held.lock().messages()[0].clone();
5821        let Message::User { tool_use_result, .. } = kept else {
5822            panic!("the held frame is the user frame it was seeded with");
5823        };
5824        assert_eq!(
5825            tool_use_result.expect("the held copy keeps its record")["payload"]
5826                .as_str()
5827                .map(str::len),
5828            Some(100_000),
5829            "the held copy is untouched by the page's cap",
5830        );
5831    }
5832
5833    /// **The cap keeps what a client draws before it drops the record**
5834    /// (#2374). A Write update carries the whole file in
5835    /// `content`/`originalFile`, which nothing draws; keeping the patch and
5836    /// the outside-edit mark leaves the row intact under the cap.
5837    #[test]
5838    fn an_oversized_record_keeps_the_keys_a_client_draws() {
5839        let patch = serde_json::json!([
5840            {"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 1, "lines": ["-one", "+two"]}
5841        ]);
5842        let mut record = serde_json::json!({
5843            "content": "x".repeat(100_000),
5844            "originalFile": "x".repeat(100_000),
5845            "structuredPatch": patch,
5846            "userModified": true,
5847        });
5848        let report = cap_tool_record(&mut record, 64 * 1024).expect("the record is over the cap");
5849        assert_eq!(
5850            report.kept, "structuredPatch,userModified",
5851            "the drawn keys fit, so the record was reduced to them",
5852        );
5853        assert_eq!(record["structuredPatch"], patch, "the drawn patch survives the cap: {record}");
5854        assert_eq!(record["userModified"], true, "and the outside-edit mark with it: {record}");
5855        assert!(record.get("content").is_none(), "the unread whole file is dropped: {record}");
5856        assert!(record.get("omitted").is_none(), "nothing was omitted: {record}");
5857    }
5858
5859    /// The reduction keeps a question's drawn keys too: `answers` and
5860    /// `annotations` survive an over-cap record, so the card still draws what
5861    /// was picked and typed (#2374).
5862    #[test]
5863    fn an_oversized_question_record_keeps_its_answers() {
5864        let mut record = serde_json::json!({
5865            "answers": {"Which one?": "the second"},
5866            "annotations": {"Which one?": {"notes": "because"}},
5867            "debug": "x".repeat(100_000),
5868        });
5869        cap_tool_record(&mut record, 64 * 1024);
5870        assert_eq!(
5871            record["answers"]["Which one?"], "the second",
5872            "the picked answer survives the cap: {record}",
5873        );
5874        assert_eq!(
5875            record["annotations"]["Which one?"]["notes"], "because",
5876            "and the typed note with it: {record}",
5877        );
5878        assert!(record.get("omitted").is_none(), "nothing was omitted: {record}");
5879    }
5880
5881    /// **A question whose notes are what pushed it over keeps its answer**
5882    /// (#2374): the drawn keys do not fit together, so the reduction keeps
5883    /// `answers` alone and the live and transcript pages draw the same card.
5884    #[test]
5885    fn a_question_over_the_cap_keeps_its_answer_and_drops_the_notes() {
5886        let mut record = serde_json::json!({
5887            "answers": {"Which one?": "the second"},
5888            "annotations": {"Which one?": {"notes": "x".repeat(100_000)}},
5889        });
5890        let report = cap_tool_record(&mut record, 64 * 1024).expect("the record is over the cap");
5891        assert_eq!(
5892            record["answers"]["Which one?"], "the second",
5893            "the answer survives the cap: {record}",
5894        );
5895        assert!(record.get("annotations").is_none(), "the notes are dropped: {record}");
5896        assert_eq!(report.kept, "answers", "and the log says what was kept");
5897    }
5898
5899    /// **A capped record is logged against the call it capped** (#2374): the
5900    /// seat, the call's id, the record's own size, the cap and what survived,
5901    /// so "where did this payload go" is answered by one line. Both entry
5902    /// points are pinned, and a record under the cap logs nothing.
5903    #[test]
5904    fn a_capped_record_is_logged_with_its_seat_call_and_size() {
5905        use crate::test_support::logged;
5906
5907        let seat = fixture_seat();
5908        let record = serde_json::json!({
5909            "structuredPatch": [
5910                {"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 1,
5911                 "lines": ["-one", format!("+{}", "x".repeat(100_000))]}
5912            ],
5913            "userModified": true,
5914        });
5915        let bytes = serde_json::to_string(&record).expect("serialises").len();
5916        let mut turns = vec![TurnWire {
5917            key: None,
5918            messages: vec![serde_json::json!({
5919                "type": "user",
5920                "message": {"role": "user", "content": [
5921                    {"type": "tool_result", "tool_use_id": "toolu_edit", "content": "ok"}
5922                ]},
5923                "tool_use_result": record.clone(),
5924            })],
5925        }];
5926        let events = logged(|| cap_tool_records(&mut turns, 64 * 1024, &seat));
5927        assert!(
5928            events.iter().any(|(level, fields)| {
5929                *level == tracing::Level::DEBUG
5930                    && fields.contains(r#"event_name="tool_record_capped""#)
5931                    && fields.contains(&format!("slot={}", seat.display()))
5932                    && fields.contains(r#"tool_use_id="toolu_edit""#)
5933                    && fields.contains(&format!("bytes={bytes}"))
5934                    && fields.contains("cap=65536")
5935                    && fields.contains(r#"disposition="omitted""#)
5936            }),
5937            "the page's cap is recorded against the call it capped: {events:?}",
5938        );
5939
5940        // The live path has its own call site, so dropping one and keeping the
5941        // other must not read green.
5942        let live = |record: &Value| -> Message {
5943            serde_json::from_value(serde_json::json!({
5944                "type": "user",
5945                "message": {"role": "user", "content": [
5946                    {"type": "tool_result", "tool_use_id": "toolu_live", "content": "ok"}
5947                ]},
5948                "session_id": "s1",
5949                "tool_use_result": record.clone(),
5950            }))
5951            .expect("the frame decodes")
5952        };
5953        let mut msg = live(&record);
5954        let events = logged(|| cap_message_record(&mut msg, 64 * 1024, &seat));
5955        assert!(
5956            events.iter().any(|(level, fields)| {
5957                *level == tracing::Level::DEBUG
5958                    && fields.contains(r#"event_name="tool_record_capped""#)
5959                    && fields.contains(r#"tool_use_id="toolu_live""#)
5960            }),
5961            "the live path records its own cap: {events:?}",
5962        );
5963
5964        let mut under = live(&serde_json::json!({"payload": "x"}));
5965        let events = logged(|| cap_message_record(&mut under, 64 * 1024, &seat));
5966        assert!(
5967            events.is_empty(),
5968            "a record under the cap says nothing, and so logs nothing: {events:?}",
5969        );
5970    }
5971
5972    /// **A record exactly at the cap crosses whole**: the bound is `<=`, so a
5973    /// page does not mark a record it fully carries (#2374).
5974    #[test]
5975    fn a_record_at_the_cap_is_not_capped() {
5976        let record = serde_json::json!({"payload": "x".repeat(100_000)});
5977        let exact = serde_json::to_string(&record).expect("serialises").len();
5978        let mut at = record.clone();
5979        assert!(
5980            cap_tool_record(&mut at, exact).is_none(),
5981            "a record at the cap is served as it is, and nothing is logged",
5982        );
5983        assert_eq!(at, record, "a record at the cap is served as it is");
5984        let mut over = record.clone();
5985        let report = cap_tool_record(&mut over, exact - 1).expect("one byte over is capped");
5986        assert_eq!(report.kept, "", "nothing drawable fits, so the marker replaced it");
5987        assert_eq!(report.bytes, exact, "the log line carries the record's own size");
5988        assert_eq!(over["omitted"], true, "one byte over and it is marked: {over}");
5989    }
5990
5991    /// When even the drawn keys are over the cap the record becomes the
5992    /// marker, and `userModified` rides it so the row still says the file
5993    /// changed outside the edit (#2374).
5994    #[test]
5995    fn a_record_over_the_cap_after_reduction_is_the_marker() {
5996        let mut record = serde_json::json!({
5997            "structuredPatch": [{"lines": ["x".repeat(100_000)]}],
5998            "userModified": true,
5999        });
6000        cap_tool_record(&mut record, 64 * 1024);
6001        assert_eq!(record["omitted"], true, "the record is the marker: {record}");
6002        assert_eq!(record["userModified"], true, "and keeps the outside-edit mark: {record}");
6003        assert!(
6004            record["bytes"].as_u64().is_some_and(|bytes| bytes > 100_000),
6005            "and says how much it weighed: {record}",
6006        );
6007    }
6008
6009    /// **The open path carries its own numbers (#2374).** A session encode is
6010    /// where a client's switch waits on this half, so the slices of that wait
6011    /// - the page's cut and the record's encode - ride back with the value;
6012    /// with the client's own marks, a slow switch is then placed from the
6013    /// artefacts rather than guessed at.
6014    #[tokio::test]
6015    async fn the_open_path_carries_its_own_timings() {
6016        let root = tempfile::tempdir().expect("a temp dir");
6017        let state = fixture_state(root.path()).await;
6018
6019        let (_, timings) = encode_subject_timed(&state, &Subject::Session(fixture_seat()), false)
6020            .await
6021            .expect("the seat encodes");
6022
6023        assert!(timings.page_ms > 0.0, "the page's cut is work on the path: {timings:?}");
6024        assert!(timings.encode_ms > 0.0, "the record's encode is work on the path: {timings:?}");
6025        assert_eq!(timings.seed_ms, 0.0, "a HELD copy pays no transcript read: {timings:?}");
6026        // **An ordinary read neither refreshes the base nor re-scans for one.**
6027        // Both slices are on the path and both are empty here; a read that
6028        // reached the network or re-scanned would say so in them.
6029        assert!(
6030            timings.base_ms < 1.0,
6031            "no child runs for an ordinary read's base (a git call costs whole ms): {timings:?}",
6032        );
6033        assert!(timings.rescan_ms < 1.0, "and nothing re-scans the tree for one: {timings:?}");
6034    }
6035
6036    /// The other half: a seat the transport holds nothing for pays the
6037    /// transcript read, and the number says so.
6038    ///
6039    /// **A transcript big enough that the read dominates its own call**, so
6040    /// the assertion below has teeth: the seed is a whole file's read and the
6041    /// rest of `conversation_for` is a lookup and an ask, and the two must
6042    /// not overlap - a total that swallowed the read is short by the whole
6043    /// read on exactly the slow cold open the line exists to explain.
6044    #[tokio::test]
6045    async fn a_cold_seats_seed_is_timed_outside_the_conversation_total() {
6046        let fleet =
6047            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6048        fleet.start("TestOrg", "proj").expect("the project starts");
6049        let rows: Vec<String> = (0..3_000).map(a_turns_rows).collect();
6050        let rows: Vec<&str> = rows.iter().map(String::as_str).collect();
6051        fleet.seed_transcript("TestOrg", "proj", "lead", &rows).expect("the transcript seeds");
6052        // An empty transport over that fleet: the seed path is what a seat
6053        // nobody holds takes.
6054        let state = TransportState {
6055            surface: fleet.surface(),
6056            work: Arc::new(WorkCache::new()),
6057            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6058            live: Mutex::new(crate::live::Live::new()),
6059            settings: crate::transport::settings_channel(),
6060            browser_host: None,
6061            heartbeat: Heartbeat::default(),
6062        };
6063
6064        let (_, timings) = encode_subject_timed(&state, &Subject::Session(fixture_seat()), false)
6065            .await
6066            .expect("the seat encodes");
6067
6068        assert!(
6069            timings.seed_ms > 0.0,
6070            "the transcript read a cold seat takes is timed: {timings:?}"
6071        );
6072        assert!(
6073            timings.conversation_ms < timings.seed_ms,
6074            "the conversation total EXCLUDES the seed, so a read that took real time must \
6075             dominate the lookup around it - the two overlapping is the bug this pins: {timings:?}"
6076        );
6077    }
6078
6079    /// The Extensions read crosses with the inventory, the PATH checks and
6080    /// the run, and the inventory half is **the same shape the live frames
6081    /// carry** - a client that took either would be drawing the same bytes
6082    /// (rule 25).
6083    #[tokio::test]
6084    async fn an_extensions_subscription_is_answered_with_the_inventory() {
6085        let fleet = crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("fleet");
6086        let mut components = forge_primitives::plugins::PluginComponents {
6087            plugin: "demo@probe-market".to_owned(),
6088            marketplace: "probe-market".to_owned(),
6089            installed: true,
6090            enabled: true,
6091            version: Some("1.0.0".to_owned()),
6092            available_version: Some("1.1.0".to_owned()),
6093            skills: vec!["one".to_owned()],
6094            ..forge_primitives::plugins::PluginComponents::default()
6095        };
6096        components.lsp_servers.insert("absent-server".to_owned(), "not-a-binary-xyz".to_owned());
6097        let snapshot = forge_primitives::plugins::PluginsInventorySnapshot {
6098            components: vec![components],
6099            ..forge_primitives::plugins::PluginsInventorySnapshot::default()
6100        };
6101        let pinned = snapshot.clone();
6102        fleet.workspace().set_plugin_scan_for_test(move || Some(pinned.clone()));
6103        let state = TransportState {
6104            surface: fleet.surface(),
6105            work: Arc::new(WorkCache::new()),
6106            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6107            live: Mutex::new(crate::live::Live::new()),
6108            settings: crate::transport::settings_channel(),
6109            browser_host: None,
6110            heartbeat: Heartbeat::default(),
6111        };
6112
6113        let encoded = encode_subject(&state, &Subject::Extensions).await.expect("encode");
6114
6115        assert_eq!(
6116            encoded["snapshot"]["components"][0]["plugin"], "demo@probe-market",
6117            "the inventory crosses: {encoded}"
6118        );
6119        assert_eq!(
6120            encoded["snapshot"]["components"][0]["available_version"], "1.1.0",
6121            "with the version the marketplace reports"
6122        );
6123        assert_eq!(
6124            encoded["snapshot"],
6125            serde_json::to_value(&snapshot).expect("value"),
6126            "the snapshot half is byte-for-byte the object the Plugins frames embed"
6127        );
6128        assert_eq!(
6129            encoded["lsp"][0]["server"], "absent-server",
6130            "the PATH check crosses beside it"
6131        );
6132        assert_eq!(
6133            encoded["lsp"][0]["on_path"], false,
6134            "and reflects this machine rather than guessing"
6135        );
6136        assert!(encoded["update_run"].is_null(), "no run has happened this process");
6137
6138        // The read's own type accepts its own bytes back.
6139        let wire: ExtensionsWire = serde_json::from_value(encoded).expect("round trip");
6140        assert_eq!(wire.snapshot.expect("snapshot").components.len(), 1);
6141        assert_eq!(wire.lsp.len(), 1);
6142    }
6143
6144    /// What one fixture pins, and what it only appears to.
6145    ///
6146    /// The byte-compare above makes a renamed field fail, and it is silent
6147    /// about how much it is covering. A key the fixture carries as `null`
6148    /// pins the key's existence and nothing behind it; an ARRAY it carries
6149    /// empty pins nothing about the element shape at all, so a field renamed
6150    /// inside one is invisible to every pin in the tree. `projects[]/tasks[]`
6151    /// and `projects[]/crons[]` are on that list: named on every run and not
6152    /// yet seeded, which is #1367.
6153    ///
6154    /// Counted rather than listed, and printed on every run: the number is
6155    /// what says whether the fixture is the instrument it is taken for.
6156    #[derive(Default)]
6157    struct Coverage {
6158        keys: usize,
6159        nulls: BTreeSet<String>,
6160        empty_arrays: BTreeSet<String>,
6161    }
6162
6163    fn coverage(value: &Value, path: &str, out: &mut Coverage) {
6164        match value {
6165            Value::Object(fields) => {
6166                for (key, held) in fields {
6167                    out.keys += 1;
6168                    let here = format!("{path}/{key}");
6169                    if held.is_null() {
6170                        out.nulls.insert(here.clone());
6171                    }
6172                    coverage(held, &here, out);
6173                }
6174            }
6175            Value::Array(items) => {
6176                if items.is_empty() {
6177                    out.empty_arrays.insert(format!("{path}[]"));
6178                }
6179                for item in items {
6180                    coverage(item, &format!("{path}[]"), out);
6181                }
6182            }
6183            _ => {}
6184        }
6185    }
6186
6187    #[tokio::test]
6188    async fn every_subject_round_trips_through_its_fixture() {
6189        let dir = fixture_dir();
6190        let state = fixture_state(dir.path()).await;
6191        let forms = volatile(dir.path());
6192        let mut keys = 0usize;
6193        let mut nulls = 0usize;
6194        let mut empty: BTreeSet<String> = BTreeSet::new();
6195
6196        for (subject, fixture) in fixtures() {
6197            let mut encoded = encode_subject(&state, &subject).await.expect("encode");
6198            normalise(&mut encoded, &forms);
6199            let expected: Value =
6200                serde_json::from_str(&std::fs::read_to_string(&fixture).expect("read"))
6201                    .expect("parse");
6202            assert_eq!(encoded, expected, "{} changed shape", fixture.display());
6203
6204            let mut pin = Coverage::default();
6205            coverage(&encoded, "", &mut pin);
6206            keys += pin.keys;
6207            nulls += pin.nulls.len();
6208            empty.extend(pin.empty_arrays);
6209        }
6210
6211        // The floor: an empty fixture compares equal to a snapshot of nothing
6212        // and would otherwise report as clean as a populated one.
6213        assert!(keys > 0, "no fixture carries a key, so there is nothing to pin");
6214        eprintln!(
6215            "wire fixtures: {keys} key slots carried, {nulls} null (the key is named, nothing \
6216             behind it is pinned), {} empty collections (no element shape pinned anywhere): {}",
6217            empty.len(),
6218            empty.iter().cloned().collect::<Vec<_>>().join(", "),
6219        );
6220    }
6221
6222    /// The push-to-talk key and its press mapping reach the composer's record.
6223    ///
6224    /// A client draws the affordance the keyboard drives, and this is its only
6225    /// read of what `forge.toml` configured: without it the key is whatever
6226    /// the client hardcodes, which is the same for every install.
6227    #[tokio::test]
6228    async fn the_composer_carries_the_configured_bind_and_mode() {
6229        let dir = tempfile::tempdir().expect("tempdir");
6230        let forge = dir.path().join("forge");
6231        std::fs::create_dir_all(&forge).expect("forge/");
6232        let project = dir.path().join("proj");
6233        std::fs::create_dir_all(&project).expect("the project's directory");
6234        std::fs::write(
6235            forge.join("forge.toml"),
6236            format!(
6237                "\
6238[[orgs]]
6239name = \"TestOrg\"
6240accounts = [\"Acct\"]
6241
6242[[orgs.projects]]
6243name = \"proj\"
6244path = \"{}\"
6245
6246[[accounts]]
6247display_name = \"Acct\"
6248token = \"t\"
6249models = [\"claude-sonnet-5\"]
6250provider = \"anthropic\"
6251
6252[dictate]
6253enabled = true
6254bind = \"left_cmd\"
6255mode = \"toggle\"
6256",
6257                project.display()
6258            ),
6259        )
6260        .expect("write forge.toml");
6261        let workspace = Arc::new(
6262            forge_workspace::Workspace::new_for_test(dir.path().to_owned()).expect("workspace"),
6263        );
6264        let state = TransportState {
6265            surface: Arc::new(ViewSurface::new(workspace)),
6266            work: Arc::new(WorkCache::new()),
6267            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6268            live: Mutex::new(crate::live::Live::new()),
6269            settings: crate::transport::settings_channel(),
6270            browser_host: None,
6271            heartbeat: Heartbeat::default(),
6272        };
6273
6274        let session =
6275            encode_subject(&state, &Subject::Session(fixture_seat())).await.expect("encode");
6276        assert_eq!(
6277            session["composer"]["bind"], "left_cmd",
6278            "the composer carries the configured push-to-talk key, not a default: {session}",
6279        );
6280        assert_eq!(
6281            session["composer"]["mode"], "toggle",
6282            "and how a press maps onto a take, not a default either: {session}",
6283        );
6284    }
6285
6286    /// **A landed take nobody has sent is re-handed by the record** (#1360).
6287    ///
6288    /// The landing reaches its dictating connection live, and nothing
6289    /// retained it: a page that reloaded between the landing and the send
6290    /// lost the words. The seat holds them now, and the record says they are
6291    /// the seat's - `carried` - so any page may place them without reading
6292    /// them as a landing of its own.
6293    #[tokio::test]
6294    async fn the_composer_re_hands_a_landing_nobody_has_sent() {
6295        let fleet =
6296            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6297        let state = TransportState {
6298            surface: fleet.surface(),
6299            work: Arc::new(WorkCache::new()),
6300            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6301            live: Mutex::new(crate::live::Live::new()),
6302            settings: crate::transport::settings_channel(),
6303            browser_host: None,
6304            heartbeat: Heartbeat::default(),
6305        };
6306        let seat = fixture_seat();
6307        let landing = crate::SessionUpdate::DictateEnded {
6308            key: seat.clone(),
6309            outcome: crate::surface::DictateOutcome::Landed {
6310                text: "the unsent words".to_owned(),
6311                truncated: true,
6312            },
6313            generation: 7,
6314            surface: Some("review".to_owned()),
6315            initiator: None,
6316        };
6317
6318        let session =
6319            encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode");
6320        assert!(session["composer"]["notice"].is_null(), "nothing landed yet: {session}");
6321
6322        crate::live::Live::lock(&state.live).apply(&landing);
6323        let session =
6324            encode_subject(&state, &Subject::Session(seat.clone())).await.expect("encode");
6325        assert_eq!(
6326            session["composer"]["notice"]["text"], "the unsent words",
6327            "the unsent landing is on the record: {session}",
6328        );
6329        assert_eq!(
6330            session["composer"]["notice"]["generation"], 7,
6331            "with the take's own number, the landing memory's discriminator",
6332        );
6333        assert_eq!(
6334            session["composer"]["notice"]["truncated"], true,
6335            "and whether the take was partial, which the client draws a warn line from",
6336        );
6337        assert_eq!(
6338            session["composer"]["notice"]["carried"], true,
6339            "and marked as the seat's re-hand rather than a landing of this page's own",
6340        );
6341        assert_eq!(
6342            session["composer"]["notice"]["surface"], "review",
6343            "and naming the surface that began it, so only that surface places it",
6344        );
6345
6346        let msg: forge_primitives::Message = serde_json::from_value(serde_json::json!({
6347            "type": "user",
6348            "session_id": "s1",
6349            "message": {"role": "user", "content": "about to be sent"},
6350        }))
6351        .expect("a user frame");
6352        crate::live::Live::lock(&state.live).apply(&crate::SessionUpdate::ChatAppended {
6353            key: seat.clone(),
6354            msg,
6355            source: Some(forge_workspace::PromptSource::You),
6356        });
6357        let session = encode_subject(&state, &Subject::Session(seat)).await.expect("encode");
6358        assert!(
6359            session["composer"]["notice"].is_null(),
6360            "and a prompt takes the words with it, so sent words are never handed back: {session}",
6361        );
6362    }
6363
6364    /// The account a project's row chips, and its state.
6365    ///
6366    /// The state is derived from the project's model and the account pool, so
6367    /// `has_model` is not enough to draw it from - a client could draw the row
6368    /// and not the chip on it.
6369    ///
6370    /// The fixture's projects declare no model, so nothing binds and the chip
6371    /// is absent here: what this pins is the field being on the row at all,
6372    /// and the POPULATED case is the acceptance walk's, against a config whose
6373    /// projects do declare one.
6374    #[tokio::test]
6375    async fn a_projects_row_carries_the_chip_field() {
6376        let fleet =
6377            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6378        let state = TransportState {
6379            surface: fleet.surface(),
6380            work: Arc::new(WorkCache::new()),
6381            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6382            live: Mutex::new(crate::live::Live::new()),
6383            settings: crate::transport::settings_channel(),
6384            browser_host: None,
6385            heartbeat: Heartbeat::default(),
6386        };
6387
6388        let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
6389        let rows = encoded["projects"].as_array().expect("the home carries project rows");
6390
6391        assert!(rows[0].get("chip").is_some(), "the row carries a chip: {}", rows[0]);
6392        assert_eq!(
6393            rows[0]["project"]["has_model"], false,
6394            "and this fixture's project declares no model, which is why the chip is absent",
6395        );
6396    }
6397
6398    /// A view draws a project's SCHEDULES section from a read the wire
6399    /// never carried, so a client could create a cron and never see it again.
6400    #[tokio::test]
6401    async fn a_projects_schedules_ride_its_board_row() {
6402        let fleet =
6403            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
6404        fleet.add_cron("proj", "a nightly sweep").expect("the cron is added");
6405        let state = TransportState {
6406            surface: fleet.surface(),
6407            work: Arc::new(WorkCache::new()),
6408            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6409            live: Mutex::new(crate::live::Live::new()),
6410            settings: crate::transport::settings_channel(),
6411            browser_host: None,
6412            heartbeat: Heartbeat::default(),
6413        };
6414
6415        let encoded = encode_subject(&state, &Subject::Board).await.expect("encode");
6416        let rows = encoded["projects"].as_array().expect("the board carries project rows");
6417        let crons = rows[0]["crons"].as_array().expect("and each row carries its schedules");
6418
6419        assert_eq!(crons.len(), 1, "the project's own cron reached the wire: {crons:?}");
6420        assert_eq!(crons[0]["prompt"], "a nightly sweep");
6421    }
6422
6423    /// A project's connector subscriptions ride its row, which is the read the
6424    /// section draws.
6425    ///
6426    /// The home's own `connectors` carried a `subscriptions` list that no read
6427    /// could fill - it was built with no project scope, and `None` means "the
6428    /// liveness facts only" - so a page drawing that list showed the connector
6429    /// section empty however long it waited, and would have flip-flopped
6430    /// against the next re-read once a frame patched it.
6431    #[tokio::test]
6432    async fn a_projects_connector_subscriptions_ride_its_board_row() {
6433        let (workspace, _dir) = crate::surface::testing::workspace_with_connector_subs();
6434        let state = TransportState {
6435            surface: Arc::new(crate::surface::ViewSurface::new(Arc::clone(&workspace))),
6436            work: Arc::new(WorkCache::new()),
6437            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6438            live: Mutex::new(crate::live::Live::new()),
6439            settings: crate::transport::settings_channel(),
6440            browser_host: None,
6441            heartbeat: Heartbeat::default(),
6442        };
6443
6444        let encoded = encode_subject(&state, &Subject::Board).await.expect("encode");
6445        let rows = encoded["projects"].as_array().expect("the board carries project rows");
6446        let row = rows
6447            .iter()
6448            .find(|row| row["project"]["name"] == "forge")
6449            .unwrap_or_else(|| panic!("the project has a row: {encoded}"));
6450
6451        assert_eq!(
6452            row["connectors"]["gotify"].as_array().map(Vec::len),
6453            Some(1),
6454            "the project's own gotify subscription is on its row: {encoded}",
6455        );
6456        assert_eq!(
6457            row["connectors"]["gotify"][0]["applications"][0], "forge",
6458            "as the subscription itself, not a placeholder",
6459        );
6460        assert_eq!(
6461            row["connectors"]["slack"].as_array().map(Vec::len),
6462            Some(1),
6463            "and so is its slack subscription: {encoded}",
6464        );
6465
6466        // The home-level `connectors` is the liveness facts and nothing else:
6467        // a subscription field a stale reader still looks for is a section
6468        // that draws empty forever.
6469        let home = encode_subject(&state, &Subject::Home).await.expect("encode");
6470        assert!(
6471            home["connectors"]["gotify"].get("subscriptions").is_none(),
6472            "the home's own connectors carries no subscription list: {home}",
6473        );
6474        assert!(
6475            home["connectors"]["slack"].get("subscriptions").is_none(),
6476            "for either connector: {home}",
6477        );
6478        assert!(
6479            home["connectors"]["slack"]["connected_workspaces"].is_array(),
6480            "the facts it does carry are still there: {home}",
6481        );
6482    }
6483
6484    /// A worker's row draws ITS OWN working tree, not its project's.
6485    ///
6486    /// The home carried one `work` per project, read at the project's own path
6487    /// from the lead's seat, so every worker row under it drew that branch and
6488    /// that count. On a fleet with a worker in a worktree that is a plausible
6489    /// wrong answer rather than an obviously missing one - `main` on a worker's
6490    /// row reads as the worker's branch - which is why the client drew no
6491    /// branch at all on a worker's row rather than another seat's.
6492    #[tokio::test]
6493    async fn a_workers_row_carries_its_own_tree_and_not_the_projects() {
6494        let dir = tempfile::tempdir().expect("tempdir");
6495        // The trees go up BEFORE the fleet does. A project's registry key is
6496        // derived from its path and the derivation canonicalises, so a key
6497        // taken while the directory is absent is not the key taken once it is
6498        // there - and a worker registered under the first is registered under
6499        // one nothing looks up again.
6500        let root = dir.path().join("proj");
6501        let worktree = root.join(".claude/worktrees/w1");
6502        repo_at(&root, "main", 1);
6503        repo_at(&worktree, "worktree-em-dash-sweep", 2);
6504        // The composed directory is canonical, as the CLI's own worktree
6505        // root is; a `/var` tempdir would otherwise compare a spelling
6506        // claude never uses.
6507        let worktree = std::fs::canonicalize(&worktree).expect("canonical worktree");
6508
6509        let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
6510            .expect("the fleet builds");
6511        fleet.start("TestOrg", "proj").expect("the project starts");
6512        fleet.add_git_worker("TestOrg", "proj", "w1").expect("the worker is added");
6513
6514        let state = TransportState {
6515            surface: fleet.surface(),
6516            work: Arc::new(WorkCache::new()),
6517            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6518            live: Mutex::new(crate::live::Live::new()),
6519            settings: crate::transport::settings_channel(),
6520            browser_host: None,
6521            heartbeat: Heartbeat::default(),
6522        };
6523
6524        let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
6525        let rows = encoded["agents"].as_array().expect("the home carries agent rows");
6526        let row_of = |label: &str| {
6527            rows.iter().find(|row| row["label"] == label).expect("the seat has a row")
6528        };
6529
6530        // The premise, so a row with no tree at all cannot pass as a row with
6531        // the right one: the worker's seat resolves to its own worktree.
6532        assert_eq!(
6533            state
6534                .surface
6535                .roster()
6536                .cwd_for(&SessionSlot::worker("TestOrg", "proj", "w1"))
6537                .as_deref(),
6538            Some(worktree.as_path()),
6539            "precondition: the worker's own directory is the worktree",
6540        );
6541        assert_eq!(
6542            row_of("lead")["work"]["branch"],
6543            "main",
6544            "the lead's row draws the project's own tree: {encoded}",
6545        );
6546        assert_eq!(row_of("lead")["work"]["changed"], 1, "with the count that tree has: {encoded}");
6547        assert_eq!(
6548            row_of("w1")["work"]["branch"],
6549            "worktree-em-dash-sweep",
6550            "and the worker's row draws its own, which the project's read cannot answer: \
6551             {encoded}",
6552        );
6553        assert_eq!(
6554            row_of("w1")["work"]["changed"],
6555            2,
6556            "with the worker's own count beside it: {encoded}",
6557        );
6558    }
6559
6560    /// A seat forge holds no directory for draws NO tree, rather than the
6561    /// project's.
6562    ///
6563    /// The row is real: a despawned worker leaves its label behind and is
6564    /// drawn until the label goes, and the registry has nothing to compose a
6565    /// directory from. `None` is what says so, and the tempting repair - fall
6566    /// back to `ProjectWire::work` so the cell is not blank - is the bug this
6567    /// whole read exists to remove, one row narrower.
6568    #[tokio::test]
6569    async fn a_seat_with_no_directory_borrows_no_tree() {
6570        let dir = tempfile::tempdir().expect("tempdir");
6571        let root = dir.path().join("proj");
6572        repo_at(&root, "main", 1);
6573
6574        let fleet = crate::testing::Fleet::in_dir(dir.path(), &[("TestOrg", &["proj"])])
6575            .expect("the fleet builds");
6576        fleet.start("TestOrg", "proj").expect("the project starts");
6577        fleet.add_despawned_worker("proj", "gone").expect("the label is left behind");
6578
6579        let state = TransportState {
6580            surface: fleet.surface(),
6581            work: Arc::new(WorkCache::new()),
6582            conversations: Arc::new(crate::transport::conversation::Conversations::new()),
6583            live: Mutex::new(crate::live::Live::new()),
6584            settings: crate::transport::settings_channel(),
6585            browser_host: None,
6586            heartbeat: Heartbeat::default(),
6587        };
6588
6589        let encoded = encode_subject(&state, &Subject::Home).await.expect("encode");
6590        let rows = encoded["agents"].as_array().expect("the home carries agent rows");
6591        let row_of = |label: &str| {
6592            rows.iter().find(|row| row["label"] == label).expect("the seat has a row")
6593        };
6594
6595        assert_eq!(
6596            state.surface.roster().cwd_for(&SessionSlot::worker("TestOrg", "proj", "gone")),
6597            None,
6598            "precondition: forge holds no directory for the despawned label",
6599        );
6600        assert_eq!(
6601            row_of("gone")["work"],
6602            Value::Null,
6603            "so its row draws no tree rather than borrowing the project's: {encoded}",
6604        );
6605        assert_eq!(
6606            row_of("lead")["work"]["branch"],
6607            "main",
6608            "while the project's own row still draws the project's tree: {encoded}",
6609        );
6610    }
6611
6612    /// A git repository at `dir` on `branch`, with `changed` uncommitted files.
6613    ///
6614    /// `.claude/` is ignored so a worker's worktree, nested inside the
6615    /// project, does not land in the project's own count as an untracked
6616    /// directory.
6617    fn repo_at(dir: &Path, branch: &str, changed: usize) {
6618        std::fs::create_dir_all(dir).expect("mkdir");
6619        git(dir, &["init", "-q"]);
6620        git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
6621        git(dir, &["config", "user.email", "t@e.com"]);
6622        git(dir, &["config", "user.name", "T"]);
6623        git(dir, &["config", "commit.gpgsign", "false"]);
6624        std::fs::write(dir.join(".gitignore"), ".claude/\n").expect("write");
6625        std::fs::write(dir.join("tracked.txt"), "one\n").expect("write");
6626        git(dir, &["add", ".gitignore", "tracked.txt"]);
6627        git(dir, &["commit", "-qm", "init"]);
6628        for file in 0..changed {
6629            std::fs::write(dir.join(format!("changed-{file}.txt")), "x\n").expect("write");
6630        }
6631    }
6632
6633    /// Spawn git with the ambient repo-location variables scrubbed, as the
6634    /// product's own constructor does: a fixture that skipped it would answer
6635    /// about a foreign repository when the suite runs under a git hook.
6636    fn git(dir: &Path, args: &[&str]) {
6637        let out = std::process::Command::new("git")
6638            .env_remove("GIT_DIR")
6639            .env_remove("GIT_WORK_TREE")
6640            .env_remove("GIT_COMMON_DIR")
6641            .arg("-C")
6642            .arg(dir)
6643            .args(args)
6644            .output()
6645            .expect("run git");
6646        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
6647    }
6648}