Skip to main content

forge_server/surface/
inspector.rs

1//! The session's own facts: its header and the sections an inspector
2//! draws from it.
3//!
4//! Each is held by the core from the same event the matching
5//! `SessionUpdate` is built from, so this hands over what the core has
6//! rather than deriving anything: a view reads the session instead of
7//! folding the update stream a second time.
8
9use forge_primitives::git_diff::PrRefresh;
10use forge_primitives::runtime::AvailableModel;
11use forge_primitives::{
12    CurrentModel, EffortLevel, MonitorRecord, PermissionMode, SessionId, SessionSlot,
13};
14
15use crate::surface::ViewSurface;
16
17// A view reads the values these verbs hand it, so it needs their names
18// too - re-exported here rather than reached for in the crates below,
19// which a view does not name.
20pub use forge_workspace::env::processes::{
21    ProcessEntry, ProcessSnapshot, basename_exe, extract_inner_command,
22};
23pub use forge_workspace::work::WorkSnapshot;
24pub use forge_workspace::{ContextUsage, McpServers};
25
26/// What a session header states about the session.
27pub struct SessionHeader {
28    /// The occupant's id, as the CLI named it, or `None` when there is no
29    /// occupant to name: a seat nothing has started, one whose CLI has not
30    /// answered yet, and one whose id was dropped after a background sign-in
31    /// or connection failure all read the same.
32    ///
33    /// Carried here rather than read off the conversation's frames, because a
34    /// client attaching to a running seat hears no `Connected` - a
35    /// subscription carries no backlog - and a frame's id is the turn's fact
36    /// rather than the seat's. The worker registry carries the id for a live
37    /// dynamic worker, and nothing carries it for any other seat.
38    pub session_id: Option<SessionId>,
39    /// The model the CLI resolved for this session, from its connect and
40    /// from every later frame that names a different one.
41    pub model: Option<CurrentModel>,
42    /// The effort the session runs at: the hook-observed level when the
43    /// session has reported one, else the level its launch stamped, else
44    /// forge's default. Always a value rather than an `Option`, because
45    /// a session that has not used a tool yet still runs at some level
46    /// and a header that showed nothing there would be wrong.
47    pub effort: EffortLevel,
48    /// The permission mode the session runs in: the hook-observed mode
49    /// when one has fired, else the level its launch stamped, which forge
50    /// always writes from its own effective default. `None` only when the
51    /// launch pinned nothing and no hook has reported, which is a session
52    /// nothing has started.
53    pub permission_mode: Option<PermissionMode>,
54    /// How full the context window is, from the bridge's last answer.
55    pub context: ContextUsage,
56    /// The model catalogue the session's connect resolved against, which a
57    /// picker draws its rows from. Empty for a session nothing has started.
58    pub available_models: Vec<AvailableModel>,
59    /// Whether a turn is in flight, from the core's own OR of the stamped
60    /// signal and the wire-lagged one: the two disagree in the window
61    /// between a prompt being routed and its echo arriving, so a read of
62    /// either alone is wrong for one of them.
63    pub turn_in_flight: bool,
64}
65
66impl ViewSurface {
67    /// The session's header facts: model, effort, permission mode and
68    /// context usage.
69    ///
70    /// One call rather than four, because a header renders them together
71    /// and each read takes the session's lock.
72    pub fn header(&self, slot: &SessionSlot) -> SessionHeader {
73        let Some(domain) = self.workspace.domain_session_for(slot) else {
74            return SessionHeader {
75                session_id: None,
76                model: None,
77                effort: EffortLevel::Max,
78                permission_mode: None,
79                context: ContextUsage::default(),
80                available_models: Vec::new(),
81                turn_in_flight: false,
82            };
83        };
84        let held = domain.lock();
85        SessionHeader {
86            session_id: held.session_id.clone(),
87            model: held.current_model.clone(),
88            effort: held.observed_effort.unwrap_or(held.configured_effort),
89            permission_mode: held.observed_permission_mode.or(held.configured_permission_mode),
90            context: held.context_usage.unwrap_or_default(),
91            available_models: held.available_models.clone(),
92            turn_in_flight: held.turn_in_flight(),
93        }
94    }
95
96    /// The MCP servers the session's bridge last reported, and the
97    /// failure standing beside them when the read did not complete.
98    ///
99    /// MCP is configured per session rather than per account, so this is
100    /// the session's own set. `None` until the first snapshot lands, as
101    /// opposed to a snapshot that carries none.
102    pub fn mcp_servers(&self, slot: &SessionSlot) -> Option<McpServers> {
103        self.workspace.domain_session_for(slot).and_then(|domain| domain.lock().mcp_servers.clone())
104    }
105
106    /// The last OS walk of the session's process tree.
107    ///
108    /// The walk is driven by whoever owns the tick that performs it and
109    /// the core holds its answer, so this neither performs one nor waits
110    /// for the next: a slot nothing has walked reads as `None`.
111    ///
112    /// The snapshot carries the instant it was taken, and a view draws it:
113    /// a walk is only ever taken for the session a view is looking at, so
114    /// the answer here can be arbitrarily old and must not be drawn as
115    /// though it were taken now.
116    pub fn processes(&self, slot: &SessionSlot) -> Option<ProcessSnapshot> {
117        self.workspace.process_snapshot(slot)
118    }
119
120    /// The seat's working tree, read on the loop's own rule: the stored scan
121    /// while it is inside the snapshot window, a new one past it.
122    ///
123    /// **The record's read is the seat's first read, and the loop's too.** A
124    /// held seat's snapshot takes the tree under the same gate the watch's
125    /// first pass uses, so the two are one scan: whichever gets there first
126    /// stores, and the other answers that store rather than reading the tree
127    /// again. A seat nobody holds has no loop to race and reads unguarded.
128    ///
129    /// **The window is what keeps the record self-consistent.** The record
130    /// carries the scan's hunks beside its branch and count; serving an
131    /// arbitrarily old scan would put its hunks beside a row the first pass
132    /// has since moved, so a store past the window is re-read here rather
133    /// than carried.
134    pub async fn work(
135        &self,
136        slot: &SessionSlot,
137        cwd: &std::path::Path,
138        // Whether this is the review page's own read, which kicks the base
139        // refresh itself a moment later.
140        review: bool,
141    ) -> WorkSnapshot {
142        // **The record's read never waits on the PR lookup.** A
143        // lookup past its window is a TLS + API round trip (~0.9 s here) and
144        // it sat inside the open a client waits for; the seat's loop scans
145        // with `Fetch` and pushes the refreshed row instead.
146        if let Ok(held) =
147            self.workspace.scan_work_if_stale(slot, false, PrRefresh::ServeCached).await
148        {
149            return held;
150        }
151        // A seat with no session has no store to hold a read, and its record
152        // still answers the tree it stands in - so there is nothing cached to
153        // serve and no loop to refresh it either: this read fetches. **The
154        // review read kicks the base refresh itself**, so its scan takes none
155        // of the background kick: one read, one refresh, and nothing of
156        // forge's queued ahead of it on the shared repository gate.
157        if review {
158            return self.workspace.scan_work_at_without_base_fetch(slot, cwd).await;
159        }
160        self.workspace.scan_work_at(slot, cwd, PrRefresh::Fetch).await
161    }
162
163    /// Answer the base the seat's drawn branch diff is measured against, and
164    /// keep it fresh off the read.
165    ///
166    /// `None` when no branch layer is drawn: the default branch, a detached
167    /// HEAD, or no resolved default. Only the read that draws the diff calls
168    /// this, so the check it reports is the one that read drew.
169    ///
170    /// **A seat with no session waits for its own check.** No watch runs for
171    /// one, so a kicked refresh would land in a store nothing pushes to the
172    /// page: this read is the only path that can carry the answer.
173    pub async fn refresh_diff_base(
174        &self,
175        slot: &SessionSlot,
176        held: &WorkSnapshot,
177    ) -> Option<forge_workspace::env::git_diff::BaseRefresh> {
178        if !held.diff.branch_ahead.is_populated() {
179            return None;
180        }
181        let default = held.diff.default_branch.as_deref()?;
182        if self.workspace.domain_session_for(slot).is_none() {
183            return Some(
184                forge_workspace::env::git_diff::refresh_base_now(&held.cwd, default).await,
185            );
186        }
187        Some(forge_workspace::env::git_diff::refresh_base_for_display(&held.cwd, default).await)
188    }
189
190    /// The scan a drawing read must take: pinned to the base revision the
191    /// refresh resolved, and gate-awaited so it cannot serve a store written
192    /// before the base moved. Local only.
193    ///
194    /// `None` when the scan could not be taken - no session behind the seat,
195    /// or its gate held past the read's bound (which `scan_work_pinned` logs
196    /// with the slot and the wait). **The caller must then re-read locally**
197    /// ([`Self::work_pinned_local`]) at the same sha: the store's count
198    /// predates the base the refresh moved, and a scan against the ref's name
199    /// cannot be paired with content read at the sha this read resolved.
200    ///
201    /// The refresh's state rides the stored row, so the `base` beside a pinned
202    /// `ahead` is that base's own measurement rather than the last watch's.
203    pub async fn diff_scan(
204        &self,
205        slot: &SessionSlot,
206        cwd: &std::path::Path,
207        refresh: &forge_workspace::env::git_diff::BaseRefresh,
208    ) -> Option<WorkSnapshot> {
209        // The refresh's own answer is the measurement the pinned scan carries:
210        // its state, the instant it was established, and the words a failure
211        // left. Stamping it with the scan's own clock would let a background
212        // fetch that lands while this scan waits be read as older than the
213        // read that is about to store it.
214        let measured = measurement_of(refresh);
215        match self
216            .workspace
217            .scan_work_pinned(slot, cwd, refresh.base_ref.as_deref(), measured)
218            .await
219        {
220            Ok(held) => Some(held),
221            Err(why) => {
222                // DEBUG: the read answers a correct scan either way, and the
223                // no-session case is this door's own reason - the only one
224                // that would otherwise reach the caller as a bare `None`.
225                tracing::debug!(
226                    target: "forge_workspace::work",
227                    event_name = "review_diff_scan_refused",
228                    slot = %slot.display(),
229                    outcome = "no_data",
230                    error = %why,
231                    "the pinned scan could not be taken; the read re-reads locally",
232                );
233                None
234            }
235        }
236    }
237
238    /// The local re-read a drawing read takes when its pin could not be: the
239    /// count and the chain must describe the ref the content is read at, and
240    /// that ref is the one the refresh just left. **Pinned to the same sha the
241    /// content read takes**, so a fetch that moves the ref between the two
242    /// cannot straddle two bases; the refresh's own state rides it, so the
243    /// row's `base` and the diff's agree. Never fetches, and takes no gate.
244    pub async fn work_pinned_local(
245        &self,
246        slot: &SessionSlot,
247        cwd: &std::path::Path,
248        base: Option<&str>,
249        refresh: Option<&forge_workspace::env::git_diff::BaseRefresh>,
250    ) -> WorkSnapshot {
251        self.workspace
252            .scan_work_at_pinned_local(slot, cwd, base, refresh.and_then(measurement_of))
253            .await
254    }
255
256    /// Store a scan's answer, which is the write half of [`Self::work`].
257    ///
258    /// A fixture's door, and the same store the seat's own loop writes: one
259    /// store means a record read and a pushed row cannot disagree.
260    pub fn store_work_snapshot(&self, slot: &SessionSlot, snapshot: WorkSnapshot) {
261        self.workspace.store_work_snapshot(slot, snapshot);
262    }
263
264    /// Show a seat: this view is looking at it, which is what keeps its
265    /// working tree scanned while the view holds it. A sessionless seat is
266    /// held too - its watch waits for the session - so every call takes a
267    /// hold, and a caller must release it exactly once.
268    ///
269    /// **The caller owes [`Self::note_record_read`] once its record is built.**
270    /// That mark is what starts the watch's first pass; a view that holds
271    /// without it has a watch that waits and scans nothing.
272    pub fn hold_seat(&self, slot: &SessionSlot) {
273        self.workspace.hold_seat(slot);
274    }
275
276    /// Mark the seat's record read, so its watch's first pass starts from the
277    /// stores the snapshot carried.
278    pub fn note_record_read(&self, slot: &SessionSlot) {
279        self.workspace.note_record_read(slot);
280    }
281
282    /// Stop showing a seat, which is what lets its scan go when nobody else
283    /// is holding it.
284    pub fn release_seat(&self, slot: &SessionSlot) {
285        self.workspace.release_seat(slot);
286    }
287
288    /// Ask the core for a fresh context reading on `slot`, which its bridge
289    /// answers with [`SessionUpdate::ContextUsageSnapshot`](crate::SessionUpdate::ContextUsageSnapshot).
290    ///
291    /// The ask rather than the read, because the reading only exists once the
292    /// CLI has computed it: a view asks for the seat it is addressing, and
293    /// the socket asks for the seat a client reads, so both reach the one
294    /// probe rather than each holding a way to reach the CLI.
295    ///
296    /// # Errors
297    ///
298    /// [`DispatchError::UnknownSession`](forge_workspace::DispatchError::UnknownSession)
299    /// when the seat has no agent to ask or has not stamped a session id yet,
300    /// and [`DispatchError::SessionClosed`](forge_workspace::DispatchError::SessionClosed)
301    /// when the ask could not be handed to that agent.
302    pub fn refresh_context_usage(
303        &self,
304        slot: &SessionSlot,
305    ) -> Result<(), forge_workspace::DispatchError> {
306        self.workspace.refresh_context_usage(slot)
307    }
308
309    /// Ask the session's bridge for a fresh MCP snapshot, which it answers
310    /// with [`SessionUpdate::McpSnapshot`](crate::SessionUpdate::McpSnapshot).
311    ///
312    /// The ask rather than the read, for the same reason as
313    /// [`Self::refresh_context_usage`]: the snapshot only exists once the
314    /// bridge has answered one, and the socket asks for the seat a client
315    /// reads the way a view asks for the seat it is addressing.
316    ///
317    /// # Errors
318    ///
319    /// See [`Self::refresh_context_usage`].
320    pub fn refresh_mcp_snapshot(
321        &self,
322        slot: &SessionSlot,
323    ) -> Result<(), forge_workspace::DispatchError> {
324        self.workspace.refresh_mcp_snapshot(slot)
325    }
326
327    /// The monitors the session has running, and the ones that settled
328    /// while it did, folded from the wire.
329    ///
330    /// Empty when nothing is running, however many monitors have been and
331    /// gone: the set drains once every entry in it is terminal, which is
332    /// the rule a view applies to its own copy.
333    pub fn monitors(&self, slot: &SessionSlot) -> Vec<MonitorRecord> {
334        self.workspace
335            .domain_session_for(slot)
336            .map_or_else(Vec::new, |domain| domain.lock().monitors.clone())
337    }
338
339    /// The seat's live tasks, each entry with the command its own card
340    /// carried: the CLI's background registry plus the long foreground calls
341    /// it announced, which never enter that registry.
342    ///
343    /// Empty when nothing is running. A view draws the processes feed's
344    /// leading rows from this: the tasks are held on the session so a view
345    /// that attached after the snapshot arrived still reads them, and the
346    /// command is what tells it whether the OS walk has already adopted the
347    /// process behind one.
348    pub fn background_tasks(&self, slot: &SessionSlot) -> Vec<forge_workspace::BackgroundTask> {
349        self.workspace
350            .domain_session_for(slot)
351            .map_or_else(Vec::new, |domain| domain.lock().task_rows())
352    }
353}
354
355/// The measurement a refresh's own answer represents: its state, the instant
356/// it was established, and git's words when it could not run.
357///
358/// **A run that answered nothing is not a measurement.** One a bound cut and
359/// one whose leader panicked or was cancelled carry forge's own placeholder
360/// words; stored newest on the row they would read as git's words, and the
361/// store's own rule skips them so nothing would rescan it.
362fn measurement_of(
363    refresh: &forge_workspace::env::git_diff::BaseRefresh,
364) -> Option<forge_workspace::env::git_diff::BaseMeasurement> {
365    (!refresh.unanswered && !refresh.cut).then(|| forge_workspace::env::git_diff::BaseMeasurement {
366        at: refresh.at,
367        state: refresh.state,
368        error: refresh.error.clone(),
369    })
370}
371
372#[cfg(test)]
373mod tests {
374    use std::sync::Arc;
375
376    use forge_primitives::runtime::{AvailableModel, RuntimeSessionState};
377    use forge_primitives::{
378        CurrentModel, EffortLevel, MonitorRecord, MonitorStatus, PermissionMode, SessionSlot,
379    };
380    use forge_workspace::{ContextUsage, McpServers};
381
382    use crate::surface::ViewSurface;
383
384    fn seat(label: &str) -> SessionSlot {
385        SessionSlot::lead("TestOrg", label)
386    }
387
388    /// **A run that answered nothing is not a measurement.** The row must not
389    /// carry forge's own placeholder words for a fetch that never reported,
390    /// nor for one its bound cut.
391    #[test]
392    fn a_placeholder_answer_is_not_a_measurement() {
393        let answered = forge_workspace::env::git_diff::BaseRefresh {
394            state: forge_primitives::git_diff::BaseState::Current,
395            base_ref: Some("abc123".to_owned()),
396            error: None,
397            cut: false,
398            unanswered: false,
399            at: std::time::Instant::now(),
400        };
401        assert!(super::measurement_of(&answered).is_some(), "an answered run is the row's base");
402
403        for placeholder in [
404            forge_workspace::env::git_diff::BaseRefresh {
405                cut: true,
406                error: Some("the fetch did not finish inside the 10s it was given".to_owned()),
407                ..answered.clone()
408            },
409            forge_workspace::env::git_diff::BaseRefresh {
410                unanswered: true,
411                error: Some("the base refresh ended without an answer".to_owned()),
412                ..answered.clone()
413            },
414        ] {
415            assert_eq!(
416                super::measurement_of(&placeholder),
417                None,
418                "a run that answered nothing: {placeholder:?}",
419            );
420        }
421    }
422
423    /// **The inspector's hold is served at once** (#2506): opening a seat must
424    /// not wait for the tree's scan, the process walk or the file index - the
425    /// watch the hold starts answers those as updates.
426    #[tokio::test]
427    async fn the_hold_is_served_before_the_seats_scans_complete() {
428        let (workspace, _dir) = crate::surface::testing::workspace();
429        let surface = Arc::new(ViewSurface::new(Arc::clone(&workspace)));
430        let seat = seat("forge");
431        workspace.register_domain_session(seat.clone(), None);
432        let gate = Arc::new(tokio::sync::Semaphore::new(0));
433        workspace.seed_test_scan_gate(Arc::clone(&gate));
434
435        surface.hold_seat(&seat);
436
437        assert!(workspace.work_snapshot(&seat).is_none(), "the tree is not read on the hold path");
438    }
439
440    /// **The surface reads the holds the registry does not name.** A long
441    /// foreground call never enters `background_tasks_changed`, so a read
442    /// that served only that registry would draw nothing for it.
443    #[test]
444    fn the_read_includes_the_announced_holds_the_registry_does_not_name() {
445        let fleet = crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("a fleet");
446        fleet.start("TestOrg", "proj").expect("the project starts");
447        let seat = seat("forge");
448        fleet.seed_view_facts(
449            &seat,
450            crate::testing::ViewFacts {
451                live_tasks: vec![forge_workspace::BackgroundTask {
452                    task_id: "t-live".to_owned(),
453                    is_backgrounded: false,
454                    task_type: "local_bash".to_owned(),
455                    description: "the gate".to_owned(),
456                    command: Some("cargo nextest run".to_owned()),
457                    tool_use_id: Some("tu-live".to_owned()),
458                }],
459                ..crate::testing::ViewFacts::default()
460            },
461        );
462
463        let rows = fleet.surface().background_tasks(&seat);
464        assert_eq!(rows.len(), 1, "the announced hold is read: {rows:?}");
465        assert_eq!(rows[0].task_id, "t-live", "under its own id");
466        assert!(!rows[0].is_backgrounded, "and its own flag crosses with it");
467    }
468
469    /// The header reports the four facts the core was handed, rather than
470    /// re-deriving any of them: each one is set to a value no derivation
471    /// from an empty session could produce.
472    #[test]
473    fn the_header_reports_the_facts_the_core_holds() {
474        let (workspace, _dir) = crate::surface::testing::workspace();
475        let surface = ViewSurface::new(Arc::clone(&workspace));
476        let lead = seat("forge");
477        let domain = workspace.register_domain_session(lead.clone(), None);
478        {
479            let mut held = domain.lock();
480            held.observed_permission_mode = Some(PermissionMode::Plan);
481            held.observed_effort = Some(EffortLevel::Xhigh);
482            held.current_model = Some(CurrentModel::new("claude-opus-5", "Opus", "Claude Opus 5"));
483            held.context_usage =
484                Some(ContextUsage { percent: Some(62), max_tokens: Some(1_000_000) });
485        }
486
487        let header = surface.header(&lead);
488
489        assert_eq!(
490            header.permission_mode,
491            Some(PermissionMode::Plan),
492            "the mode the hook observed is the mode the header states",
493        );
494        assert_eq!(
495            header.effort,
496            EffortLevel::Xhigh,
497            "and the level it observed stands over the configured one",
498        );
499        assert_eq!(
500            header.model.as_ref().map(|model| model.resolved_id.as_str()),
501            Some("claude-opus-5"),
502            "the model the session resolved is reported as it resolved",
503        );
504        assert_eq!(header.context.percent, Some(62), "and the context reading comes through");
505        assert_eq!(header.context.max_tokens, Some(1_000_000), "with the window it is a share of");
506    }
507
508    /// A picker has no rows and a chat cannot tell whether the turn it is
509    /// watching has finished, because neither fact is reachable: the
510    /// catalogue and the turn's presence are held by the core and read by
511    /// nothing. Both are carried here.
512    #[test]
513    fn the_header_carries_the_catalogue_and_the_turn() {
514        let (workspace, _dir) = crate::surface::testing::workspace();
515        let surface = ViewSurface::new(Arc::clone(&workspace));
516        let lead = seat("forge");
517        let domain = workspace.register_domain_session(lead.clone(), None);
518        {
519            let mut held = domain.lock();
520            held.available_models = vec![
521                AvailableModel::new("claude-opus-5", "Opus"),
522                AvailableModel::new("sonnet", "S"),
523            ];
524            held.turn_pending = true;
525        }
526
527        let header = surface.header(&lead);
528
529        assert_eq!(
530            header.available_models.len(),
531            2,
532            "the catalogue the connect resolved against is readable, or a picker draws no rows",
533        );
534        assert!(header.turn_in_flight, "and a turn the core stamped as pending reads as in flight");
535    }
536
537    /// The other input of the OR, and the reason it is an OR: `turn_pending`
538    /// is stamped synchronously and `runtime_state` is wire-lagged, so a
539    /// read that trusted either alone would be wrong for one of the two.
540    #[test]
541    fn a_wire_lagged_running_state_alone_still_reads_as_in_flight() {
542        let (workspace, _dir) = crate::surface::testing::workspace();
543        let surface = ViewSurface::new(Arc::clone(&workspace));
544        let lead = seat("forge");
545        let domain = workspace.register_domain_session(lead.clone(), None);
546        {
547            let mut held = domain.lock();
548            held.runtime_state = Some(RuntimeSessionState::Running);
549            held.turn_pending = false;
550        }
551
552        assert!(
553            surface.header(&lead).turn_in_flight,
554            "the wire-lagged state alone still says a turn is in flight",
555        );
556    }
557
558    /// Effort has two sources and a session that has not used a tool yet
559    /// has only the second. A read that answered with nothing there would
560    /// leave the header blank on exactly the sessions a user just opened.
561    #[test]
562    fn the_effort_the_header_reports_falls_back_to_the_launch_level() {
563        let (workspace, _dir) = crate::surface::testing::workspace();
564        let surface = ViewSurface::new(Arc::clone(&workspace));
565        let lead = seat("forge");
566        let domain = workspace.register_domain_session(lead.clone(), None);
567        domain.lock().configured_effort = EffortLevel::High;
568
569        let header = surface.header(&lead);
570
571        assert_eq!(
572            header.effort,
573            EffortLevel::High,
574            "a session whose hook has not fired reports the level it was launched at",
575        );
576    }
577
578    /// The mode has the same shape as the effort: a session that has not
579    /// used a tool yet was still launched in a mode, and a header that
580    /// went blank there would be wrong about every session between its
581    /// spawn and its first tool call.
582    #[test]
583    fn the_mode_the_header_reports_falls_back_to_the_launch_mode() {
584        let (workspace, _dir) = crate::surface::testing::workspace();
585        let surface = ViewSurface::new(Arc::clone(&workspace));
586        let lead = seat("forge");
587        let domain = workspace.register_domain_session(lead.clone(), None);
588        domain.lock().configured_permission_mode = Some(PermissionMode::Plan);
589
590        let header = surface.header(&lead);
591
592        assert_eq!(
593            header.permission_mode,
594            Some(PermissionMode::Plan),
595            "a session whose hook has not fired reports the mode it was launched in",
596        );
597    }
598
599    /// The hook is the higher-fidelity source, so it stands over the
600    /// launch's own answer once it has something to say.
601    #[test]
602    fn an_observed_mode_stands_over_the_launch_mode() {
603        let (workspace, _dir) = crate::surface::testing::workspace();
604        let surface = ViewSurface::new(Arc::clone(&workspace));
605        let lead = seat("forge");
606        let domain = workspace.register_domain_session(lead.clone(), None);
607        {
608            let mut held = domain.lock();
609            held.configured_permission_mode = Some(PermissionMode::Plan);
610            held.observed_permission_mode = Some(PermissionMode::BypassPermissions);
611        }
612
613        let header = surface.header(&lead);
614
615        assert_eq!(
616            header.permission_mode,
617            Some(PermissionMode::BypassPermissions),
618            "the mode a hook saw is the mode the session is in",
619        );
620    }
621
622    /// The occupant's own id, which a view has no other way to read: a client
623    /// attaching to a running seat hears no `Connected` (a subscription
624    /// carries no backlog) and the conversation's frames are not the seat's
625    /// fact.
626    #[test]
627    fn the_header_carries_the_occupants_session_id() {
628        let (workspace, _dir) = crate::surface::testing::workspace();
629        let surface = ViewSurface::new(Arc::clone(&workspace));
630        let lead = seat("forge");
631        let domain = workspace.register_domain_session(lead.clone(), None);
632        domain.lock().session_id = Some(forge_primitives::SessionId::new("d4f70669-1f2a"));
633
634        assert_eq!(
635            surface.header(&lead).session_id,
636            Some(forge_primitives::SessionId::new("d4f70669-1f2a")),
637            "the id the core holds for the occupant is the id the header states",
638        );
639    }
640
641    /// A seat nobody has started reads as blank rather than borrowing
642    /// another session's header.
643    #[test]
644    fn the_header_of_an_unstarted_seat_reads_blank() {
645        let (workspace, _dir) = crate::surface::testing::workspace();
646        let surface = ViewSurface::new(Arc::clone(&workspace));
647
648        let header = surface.header(&seat("never-started"));
649
650        assert!(header.model.is_none(), "no session, no model");
651        assert_eq!(header.permission_mode, None, "and no mode observed");
652        assert_eq!(header.context, ContextUsage::default(), "and no context to report");
653        assert_eq!(header.effort, EffortLevel::Max, "and forge's default level, not a blank");
654        assert_eq!(header.session_id, None, "and no occupant to name");
655    }
656
657    /// The MCP set is the session's own, and a failure replaces the set
658    /// it invalidates rather than sitting beside it.
659    #[test]
660    fn the_mcp_read_answers_with_the_snapshot_the_core_holds() {
661        let (workspace, _dir) = crate::surface::testing::workspace();
662        let surface = ViewSurface::new(Arc::clone(&workspace));
663        let lead = seat("forge");
664        let domain = workspace.register_domain_session(lead.clone(), None);
665
666        assert!(
667            surface.mcp_servers(&lead).is_none(),
668            "nothing read yet is not the same as nothing configured",
669        );
670
671        domain.lock().mcp_servers =
672            Some(McpServers { servers: Vec::new(), error: Some("the CLI refused".to_owned()) });
673        let held = surface.mcp_servers(&lead).expect("the held snapshot comes back");
674        assert_eq!(held.error.as_deref(), Some("the CLI refused"), "and carries why it is empty");
675        assert!(held.servers.is_empty(), "with no server standing beside the failure");
676    }
677
678    /// The process walk is held rather than performed: a slot nothing has
679    /// walked answers `None` rather than paying for a `sysinfo` refresh
680    /// on the calling thread.
681    #[test]
682    fn the_process_read_answers_with_the_walk_the_core_holds() {
683        let (workspace, _dir) = crate::surface::testing::workspace();
684        let surface = ViewSurface::new(Arc::clone(&workspace));
685        let lead = seat("forge");
686        workspace.register_domain_session(lead.clone(), None);
687
688        assert!(surface.processes(&lead).is_none(), "a slot nothing walked reads as none");
689
690        let walked_at = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(60);
691        workspace.store_process_snapshot(
692            &lead,
693            Some(forge_workspace::env::processes::ProcessSnapshot {
694                processes: Vec::new(),
695                scanned_at: walked_at,
696            }),
697        );
698
699        assert_eq!(
700            surface.processes(&lead).map(|snapshot| snapshot.scanned_at),
701            Some(walked_at),
702            "the walk the core holds is the walk the read answers with",
703        );
704    }
705
706    /// The monitor set comes back whole, settled entries included: which
707    /// of them a surface draws is the view's business, not the read's.
708    #[test]
709    fn the_monitor_read_answers_with_the_set_the_core_holds() {
710        let (workspace, _dir) = crate::surface::testing::workspace();
711        let surface = ViewSurface::new(Arc::clone(&workspace));
712        let lead = seat("forge");
713        let domain = workspace.register_domain_session(lead.clone(), None);
714        domain.lock().monitors = vec![
715            MonitorRecord {
716                tool_use_id: "tu-live".to_owned(),
717                task_id: Some("t-live".to_owned()),
718                description: "ci-watch".to_owned(),
719                command: "gh run watch 1".to_owned(),
720                persistent: true,
721                timeout_ms: 0,
722                status: MonitorStatus::Running,
723                output_file: None,
724                ended_at: None,
725            },
726            MonitorRecord {
727                tool_use_id: "tu-done".to_owned(),
728                task_id: Some("t-done".to_owned()),
729                description: "deploy-gate".to_owned(),
730                command: "gh run watch 2".to_owned(),
731                persistent: false,
732                timeout_ms: 0,
733                status: MonitorStatus::Completed,
734                output_file: Some("/tmp/monitor-out".to_owned()),
735                ended_at: None,
736            },
737        ];
738
739        let held = surface.monitors(&lead);
740
741        assert_eq!(held.len(), 2, "the whole set comes back, settled entries included");
742        assert_eq!(held[0].status, MonitorStatus::Running, "a live monitor reads as running");
743        assert_eq!(
744            held[1].status,
745            MonitorStatus::Completed,
746            "and a settled one keeps the way it settled",
747        );
748        assert_eq!(
749            held[1].output_file.as_deref(),
750            Some("/tmp/monitor-out"),
751            "with the file its output went to",
752        );
753        assert!(
754            surface.monitors(&seat("never-started")).is_empty(),
755            "and a seat with no session has no monitors",
756        );
757    }
758}