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}