Skip to main content

car_server_core/assistant/
browser_control.rs

1//! Pure state core for the browser drawer: WHO is driving the browser
2//! (agent vs user) and WHAT the drawer shows (tabs, action label, sign-in
3//! strip, blackout).
4//!
5//! Two pieces, both pure — state + event -> new state (+ effects as data).
6//! No I/O, no timers, no channels live here; [`super::browser_tools`]
7//! supplies those around this core (a blocking poll for the agent-pause-at-
8//! tool-boundary check, the real clock for the disconnect grace period). The
9//! wire surface (`browser.view.*` RPCs, event fanout, and the actual gating
10//! of the screencast pump using [`ControlState::is_blackout_active`]) is a
11//! later task's job entirely — nothing here does I/O or knows about JSON-RPC.
12//!
13//! - [`ControlState`] — the control-ownership state machine: who drives,
14//!   the agent's current action label, and the pending sign-in request.
15//!   [`ControlState::apply`] is the only way to change it. Exposes the two
16//!   predicates callers gate on: [`ControlState::may_agent_act`] (the
17//!   tool-boundary pause check, also what closes the approval race — see
18//!   its doc comment) and [`ControlState::is_blackout_active`] (what the
19//!   frame/recording layer will gate on).
20//! - [`PresentationState`] / [`Presentation`] — the presentation-state
21//!   model: a [`ControlState`] plus the current tab list, projected into
22//!   the snapshot a later wire surface will serialize, stamped with a
23//!   monotonic `revision` that only advances when something actually
24//!   changed — the seam a snapshot+delta subscription needs.
25
26use car_browser::TabInfo;
27
28/// Who is driving the browser right now.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum ControlOwner {
31    /// No agent has attached to the browser during the current run — the
32    /// user's own browser, zero ceremony, no strip shown.
33    #[default]
34    NoAgent,
35    Agent,
36    User,
37}
38
39/// A pending sign-in request, surfaced as the drawer's orange strip.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PendingSignIn {
42    /// Plain-words prompt for the strip, e.g. "Sign in at accounts.example.com".
43    pub message: String,
44}
45
46/// One input to the control-ownership state machine.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ControlEvent {
49    /// An agent attaches to the browser at the start of a run — the first
50    /// browse-related tool call of the run.
51    AgentAttached,
52    /// The agent is about to perform a labeled browse action, e.g. "Filling
53    /// trip details on …" — sourced from the browse tool invocation.
54    AgentActionStarted(String),
55    /// That action returned (or was cancelled). Paired with the event above
56    /// on EVERY exit from the browse call, because nothing else clears the
57    /// label: `RunEnded` was the only clear, so the drawer's action strip
58    /// reported the last browse action as still running for the rest of the
59    /// turn — including while the agent was doing something else entirely,
60    /// or nothing at all.
61    AgentActionFinished,
62    /// User presses "Take control".
63    TakeControl,
64    /// User presses "Hand back to CAR". Also how a pending sign-in resolves
65    /// early — it's the SAME affordance, since a pending sign-in already
66    /// hands page input to the user.
67    HandBack,
68    /// The run ends. Immediate, no grace period: the user's browser again,
69    /// zero ceremony.
70    RunEnded,
71    /// The connection holding user control dropped. Starts a grace period —
72    /// the caller owns the actual clock; see [`ControlEffect::StartGracePeriod`].
73    ControlHolderDisconnected,
74    /// The stated grace period elapsed with no reconnect or hand-back.
75    GracePeriodExpired,
76    /// An agent action needs the user to sign in.
77    SignInRequested(String),
78    /// The person drove the browser — a click, a keystroke, a paste, a
79    /// navigation from the drawer. Not an ownership change: it is the evidence
80    /// that somebody is actually AT the browser right now, which is what
81    /// decides whether a sign-in timeout may end their window (see
82    /// [`ControlState::user_engaged`]).
83    UserInput,
84    /// `browser_await_signin`'s own detection loop (URL heuristic, or its
85    /// timeout) settled the pending sign-in on its own, without a hand-back.
86    SignInResolved { signed_in: bool },
87}
88
89/// Something the caller applying an event — or a caller of ITS result —
90/// should act on. Emitted alongside the new state; the reducer never
91/// performs these itself.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ControlEffect {
94    /// Start the disconnect grace-period timer. Feed `GracePeriodExpired`
95    /// back in once it elapses; if control was handed back first, the late
96    /// expiry is a no-op (see the reducer's own handling).
97    StartGracePeriod,
98    /// A pending sign-in resolved as a SIDE EFFECT of this event — a
99    /// hand-back while `browser_await_signin` was still waiting, or the run
100    /// ending — rather than because a `SignInResolved` event was fed in
101    /// directly. Mirrors what `browser_await_signin` itself would return.
102    SignInResolved { signed_in: bool },
103}
104
105/// The control-ownership state machine. Pure: [`ControlState::apply`] is the
106/// only way to change it, and it never touches a clock, a socket, or a lock.
107///
108/// Every event is safe to feed at any time — an event that doesn't apply to
109/// the current state (`HandBack` with nothing handed back, a stale
110/// `GracePeriodExpired` after an on-time hand-back) is a no-op rather than
111/// an error, because these are UI-driven events a real client can double-
112/// fire under an ordinary race without that being a bug.
113#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct ControlState {
115    owner: ControlOwner,
116    current_action: Option<String>,
117    pending_signin: Option<PendingSignIn>,
118    /// Whether the person has engaged with the browser since the current
119    /// sign-in was requested — took control, or drove it directly.
120    ///
121    /// The sign-in timeout's contract is "the strip clears, the agent receives
122    /// the same timeout result, and control returns to it" — which is right
123    /// when the timer expired because nobody ever came. It is wrong when
124    /// somebody is mid-credential-entry, because clearing the strip lifts the
125    /// blackout under them: the model's page reads reopen and an in-flight
126    /// recording (a `FrameAudience::Model` consumer kept registered precisely
127    /// so it resumes) starts writing the login form to disk.
128    ///
129    /// This flag is what separates the two. Set by `TakeControl` and by
130    /// `UserInput`; cleared when a sign-in is requested (each window judges
131    /// its own) and when one resolves.
132    ///
133    /// **The rule is uniform across every agent-side ending.** The timeout was
134    /// the first one to consult it and for one round it was the only one, which
135    /// left `RunEnded` — a turn finishing, a `browser_await_signin` returning
136    /// its timeout error, the reaper aborting the run — resolving the sign-in
137    /// and lifting the blackout under a person still at the credential form.
138    /// Whatever ends on the AGENT's side, the person's window persists until a
139    /// signal from the PERSON: hand-back, or the disconnect grace expiring.
140    user_engaged: bool,
141    /// Whether the run that attached this agent has ended.
142    ///
143    /// Needed because `RunEnded` while the user holds control is deliberately
144    /// deferred — ownership and the blackout survive until hand-back — so at
145    /// hand-back time the reducer has to know whether there is still an agent
146    /// to hand back TO. Without it, hand-back after a run ended would return
147    /// control to an agent that no longer exists and leave the strip up
148    /// forever.
149    run_ended: bool,
150}
151
152impl ControlState {
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    pub fn owner(&self) -> ControlOwner {
158        self.owner
159    }
160
161    pub fn current_action(&self) -> Option<&str> {
162        self.current_action.as_deref()
163    }
164
165    pub fn pending_signin(&self) -> Option<&PendingSignIn> {
166        self.pending_signin.as_ref()
167    }
168
169    /// Is a person demonstrably at this browser right now — holding control,
170    /// or having driven it since the current sign-in was requested?
171    ///
172    /// The one question the sign-in timeout has to answer. See
173    /// [`ControlState::user_engaged`].
174    pub fn user_engaged(&self) -> bool {
175        self.user_engaged || self.owner == ControlOwner::User
176    }
177
178    /// Everything about this state a drawer subscriber can observe — what
179    /// `PresentationState::apply_control` bumps its revision on.
180    ///
181    /// `user_engaged` is deliberately absent; see that call site.
182    fn visible_state(&self) -> (ControlOwner, Option<String>, Option<PendingSignIn>, bool) {
183        (
184            self.owner,
185            self.current_action.clone(),
186            self.pending_signin.clone(),
187            self.run_ended,
188        )
189    }
190
191    /// The tool-boundary check a browse tool call makes before it runs:
192    /// "may the agent act right now?" `false` whenever the user holds
193    /// control (or no agent has attached at all).
194    ///
195    /// This is also what closes the **approval race**: a browse action
196    /// pending at an approval prompt (a separate, pre-existing concern —
197    /// see `assistant::governance`/`agent_loop::ApprovalDecision`, not
198    /// modeled here) must not execute while the user holds control even if
199    /// it gets approved mid-race, and must run only after hand-back. That
200    /// falls out for free as long as the caller re-checks
201    /// `may_agent_act()` at the moment it is ABOUT TO EXECUTE the approved
202    /// action, rather than caching the answer from when approval was
203    /// requested or granted — approval and execution are different moments,
204    /// and only the second one may be gated on stale information for this
205    /// to be correct. See the `approval_race_*` test below.
206    pub fn may_agent_act(&self) -> bool {
207        self.owner == ControlOwner::Agent
208    }
209
210    /// Whether the user explicitly holds control right now — `owner ==
211    /// User`, nothing else.
212    ///
213    /// This is deliberately NOT the same predicate as `may_agent_act`
214    /// (`owner == Agent`): they only disagree in the `NoAgent` case, where
215    /// `may_agent_act` is `false` (correct for the tool-boundary check,
216    /// which always runs after the calling tool has already attached) but
217    /// `user_holds_control` is also `false` (correct for a call site that
218    /// needs to gate BEFORE it has attached — e.g. `browser_record_start`/
219    /// `browser_record_stop`/`browser_await_signin`, which check this
220    /// before ever touching a session, so their very first invocation ever
221    /// — `NoAgent`, nothing to wait for — is never mistaken for the user
222    /// holding control). Use `may_agent_act` when the caller has already
223    /// established a session; use this when it hasn't yet.
224    pub fn user_holds_control(&self) -> bool {
225        self.owner == ControlOwner::User
226    }
227
228    /// Whether no screenshot/frame may reach the model right now. `true`
229    /// while the user holds control OR a sign-in is pending — sign-in
230    /// implies the same privacy boundary even though it's the agent's own
231    /// `browser_await_signin` call, not an explicit Take Control press,
232    /// that puts the page in front of the user.
233    pub fn is_blackout_active(&self) -> bool {
234        self.owner == ControlOwner::User || self.pending_signin.is_some()
235    }
236
237    /// Apply one event, mutating in place, and return what the caller (or a
238    /// caller of its result) should do as a result.
239    pub fn apply(&mut self, event: ControlEvent) -> Vec<ControlEffect> {
240        match event {
241            ControlEvent::AgentAttached => {
242                self.owner = ControlOwner::Agent;
243                self.run_ended = false;
244                Vec::new()
245            }
246            ControlEvent::AgentActionStarted(label) => {
247                self.current_action = Some(label);
248                Vec::new()
249            }
250            ControlEvent::AgentActionFinished => {
251                self.current_action = None;
252                Vec::new()
253            }
254            ControlEvent::TakeControl => {
255                if self.owner == ControlOwner::Agent {
256                    self.owner = ControlOwner::User;
257                }
258                self.user_engaged = true;
259                Vec::new()
260            }
261            ControlEvent::UserInput => {
262                self.user_engaged = true;
263                Vec::new()
264            }
265            ControlEvent::HandBack => {
266                let mut effects = Vec::new();
267                if self.pending_signin.take().is_some() {
268                    effects.push(ControlEffect::SignInResolved { signed_in: false });
269                }
270                if self.owner == ControlOwner::User {
271                    // Back to the agent — unless its run already ended while
272                    // the user was driving, in which case there is no agent
273                    // to hand back TO and this is the moment the ceremony
274                    // ends: no strip, no blackout, the user's own browser.
275                    self.owner = if self.run_ended {
276                        ControlOwner::NoAgent
277                    } else {
278                        ControlOwner::Agent
279                    };
280                }
281                effects
282            }
283            ControlEvent::RunEnded => {
284                let mut effects = Vec::new();
285                // The user holding control OUTLIVES the run. "The agent's
286                // turn ending on its own while the user holds control does
287                // not reclaim control: the strip keeps stating the user has
288                // control until hand-back."
289                //
290                // The privacy half is why this is not merely cosmetic: the
291                // blackout is derived from ownership, so clearing ownership
292                // here also lifted it — a recording spanning a user-control
293                // or sign-in window would resume capturing frames the moment
294                // the run happened to end, mid-window, with the person still
295                // driving. Leaving both in place until an explicit hand-back
296                // closes that.
297                //
298                // A pending sign-in is likewise NOT resolved here: the page
299                // is still in front of the person, still blacked out, and
300                // hand-back is what settles it (honestly, as
301                // `signed_in: false` if they never completed it).
302                self.run_ended = true;
303                self.current_action = None;
304                if self.owner == ControlOwner::User {
305                    return effects;
306                }
307                // Engaged WITHOUT a Take control press — the ordinary sign-in
308                // flow, where the orange strip IS the affordance and
309                // `require_control` admits input so the person can type
310                // without pressing anything. Ownership is blind to it, which
311                // is why this branch reads engagement instead.
312                //
313                // The run really has ended, so ownership goes to `NoAgent`;
314                // what does NOT go is the pending sign-in, and with it the
315                // blackout, because the person is at the credential form right
316                // now. Their own signal settles it — hand-back, or the
317                // disconnect grace (`BrowserView::note_watcher_disconnect`) —
318                // each of which resolves it honestly as `signed_in: false`.
319                // The agent is unaffected either way: `browser_await_signin`
320                // has already returned its timeout error to the model.
321                if self.user_engaged {
322                    self.owner = ControlOwner::NoAgent;
323                    return effects;
324                }
325                if self.pending_signin.take().is_some() {
326                    effects.push(ControlEffect::SignInResolved { signed_in: false });
327                }
328                self.owner = ControlOwner::NoAgent;
329                effects
330            }
331            ControlEvent::ControlHolderDisconnected => {
332                // A pending sign-in with nobody holding control is the engaged
333                // window above: the person's connection going away is the only
334                // signal left that says they are not coming back, so it starts
335                // the same clock a holder's disconnect does. Reaching this arm
336                // at all requires the caller to have established that the
337                // connection was watching THIS view — see
338                // `BrowserView::note_watcher_disconnect`.
339                if self.owner == ControlOwner::User || self.pending_signin.is_some() {
340                    vec![ControlEffect::StartGracePeriod]
341                } else {
342                    Vec::new()
343                }
344            }
345            ControlEvent::GracePeriodExpired => {
346                // Same question hand-back asks — and it has to answer BOTH
347                // halves of it, not just ownership.
348                //
349                // `is_blackout_active()` is `owner == User || pending_signin
350                // .is_some()`, so reverting ownership while leaving a pending
351                // sign-in set leaves a live blackout with nobody holding the
352                // wheel to end it: the strip stays up forever, and every
353                // model-facing read stays gated behind a person who has
354                // already gone. Reachable on the shared process-lifetime
355                // `BrowserTools` — sign-in requested, user takes control, the
356                // host quits, the run is cancelled inside the grace window
357                // (so `RunEnded` takes the deferred `owner == User` branch
358                // that deliberately does not touch `pending_signin`), then
359                // grace expires. Nothing else clears it after that.
360                //
361                // Resolved honestly as `signed_in: false`, exactly as
362                // hand-back does: the person vanished mid-flow, so the
363                // sign-in did not complete.
364                let mut effects = Vec::new();
365                if self.pending_signin.take().is_some() {
366                    effects.push(ControlEffect::SignInResolved { signed_in: false });
367                }
368                if self.owner == ControlOwner::User {
369                    self.owner = if self.run_ended {
370                        ControlOwner::NoAgent
371                    } else {
372                        ControlOwner::Agent
373                    };
374                }
375                effects
376            }
377            ControlEvent::SignInRequested(message) => {
378                self.pending_signin = Some(PendingSignIn { message });
379                // Each sign-in window judges its own engagement. A person who
380                // typed during a PREVIOUS sign-in says nothing about whether
381                // anyone is at the browser for this one.
382                self.user_engaged = self.owner == ControlOwner::User;
383                Vec::new()
384            }
385            ControlEvent::SignInResolved { .. } => {
386                self.pending_signin = None;
387                self.user_engaged = false;
388                Vec::new()
389            }
390        }
391    }
392}
393
394/// The presentation-state model — what the drawer shows. A snapshot type,
395/// not itself a reducer: [`PresentationState`] is the mutable holder,
396/// [`Presentation`] is what it projects out for a wire surface to serialize.
397#[derive(Debug, Clone, PartialEq)]
398pub struct Presentation {
399    /// Monotonically increases by one on every change (a control-state
400    /// transition that actually did something, or a tab-list update).
401    /// Two snapshots at the same revision are content-identical, which is
402    /// the seam a later snapshot+delta subscription needs.
403    pub revision: u64,
404    pub owner: ControlOwner,
405    pub current_action: Option<String>,
406    pub pending_signin: Option<PendingSignIn>,
407    pub blackout_active: bool,
408    /// id / URL / title / active, per tab — see [`car_browser::TabInfo`].
409    pub tabs: Vec<TabInfo>,
410}
411
412/// Combines the control-ownership state machine with the current tab list,
413/// bumping a monotonic revision on every actual change — never on a no-op
414/// event or a re-set of identical tabs — so a subscriber never mistakes "I
415/// asked" for "it changed."
416#[derive(Debug, Clone, Default)]
417pub struct PresentationState {
418    control: ControlState,
419    tabs: Vec<TabInfo>,
420    revision: u64,
421}
422
423impl PresentationState {
424    pub fn new() -> Self {
425        Self::default()
426    }
427
428    pub fn control(&self) -> &ControlState {
429        &self.control
430    }
431
432    /// Apply one control-ownership event. Bumps `revision` iff the control
433    /// state actually changed as a result.
434    pub fn apply_control(&mut self, event: ControlEvent) -> Vec<ControlEffect> {
435        // Compared on what a subscriber can SEE, not on the whole reducer
436        // state. `user_engaged` is bookkeeping for the sign-in timeout, and
437        // `UserInput` fires on every click and keystroke — letting it bump the
438        // revision would emit a presentation event per input, at input rate,
439        // for something nothing renders.
440        let before = self.control.visible_state();
441        let effects = self.control.apply(event);
442        if self.control.visible_state() != before {
443            self.revision += 1;
444        }
445        effects
446    }
447
448    /// Replace the tab list — fed from `ChromiumBackend::list_tabs()` (or a
449    /// future `subscribe_tabs()` update). Bumps `revision` iff it actually
450    /// changed.
451    pub fn set_tabs(&mut self, tabs: Vec<TabInfo>) {
452        if self.tabs != tabs {
453            self.tabs = tabs;
454            self.revision += 1;
455        }
456    }
457
458    /// The current snapshot. Cheap to call repeatedly — only
459    /// `apply_control`/`set_tabs` advance `revision`, not this.
460    pub fn snapshot(&self) -> Presentation {
461        Presentation {
462            revision: self.revision,
463            owner: self.control.owner(),
464            current_action: self.control.current_action().map(str::to_string),
465            pending_signin: self.control.pending_signin().cloned(),
466            blackout_active: self.control.is_blackout_active(),
467            tabs: self.tabs.clone(),
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    /// The un-engaged branch of the timeout ruling: nobody ever came, so the
477    /// contract's semantics apply exactly — the strip clears, the blackout
478    /// lifts, control returns to the agent. Latching here is what wedged the
479    /// one-shot `car do` path, which has no drawer, no Hand back, and no
480    /// run-end signal reaching this reducer at all.
481    #[test]
482    fn a_signin_nobody_engaged_with_is_resolvable_on_timeout() {
483        let mut state = ControlState::new();
484        state.apply(ControlEvent::AgentAttached);
485        state.apply(ControlEvent::SignInRequested("Sign in at x".into()));
486
487        assert!(
488            !state.user_engaged(),
489            "nobody took control and nobody typed"
490        );
491        // What the timeout then does.
492        state.apply(ControlEvent::SignInResolved { signed_in: false });
493        assert!(!state.is_blackout_active(), "the blackout lifts");
494        assert_eq!(state.owner(), ControlOwner::Agent, "control returns to it");
495    }
496
497    /// The engaged branch, by INPUT rather than by Take control — the case
498    /// ownership cannot see, and the ordinary sign-in flow, since the orange
499    /// strip is the affordance and nobody presses anything.
500    #[test]
501    fn typing_during_a_signin_makes_it_the_persons_window() {
502        let mut state = ControlState::new();
503        state.apply(ControlEvent::AgentAttached);
504        state.apply(ControlEvent::SignInRequested("Sign in at x".into()));
505
506        state.apply(ControlEvent::UserInput);
507
508        assert!(
509            state.user_engaged(),
510            "somebody is at the credential form, whatever the owner flag says"
511        );
512        assert_eq!(
513            state.owner(),
514            ControlOwner::Agent,
515            "and never pressed anything"
516        );
517        assert!(state.is_blackout_active());
518    }
519
520    /// Take control is the other way in, and each window judges its own: a
521    /// person who engaged with a PREVIOUS sign-in says nothing about this one.
522    #[test]
523    fn engagement_is_scoped_to_the_current_signin_window() {
524        let mut state = ControlState::new();
525        state.apply(ControlEvent::AgentAttached);
526        state.apply(ControlEvent::SignInRequested("first".into()));
527        state.apply(ControlEvent::UserInput);
528        assert!(state.user_engaged());
529
530        // That one settles, and the agent later asks again.
531        state.apply(ControlEvent::HandBack);
532        state.apply(ControlEvent::SignInRequested("second".into()));
533        assert!(
534            !state.user_engaged(),
535            "a fresh window starts with nobody in it"
536        );
537
538        // Taking control counts too.
539        state.apply(ControlEvent::TakeControl);
540        assert!(state.user_engaged());
541    }
542
543    /// `UserInput` fires on every click and keystroke, so it must not be
544    /// presentation state — a revision bump per input would emit a
545    /// `browser.view.event` per input, at input rate, for something nothing
546    /// renders.
547    #[test]
548    fn user_input_does_not_bump_the_presentation_revision() {
549        let mut p = PresentationState::new();
550        p.apply_control(ControlEvent::AgentAttached);
551        let before = p.snapshot().revision;
552        for _ in 0..25 {
553            p.apply_control(ControlEvent::UserInput);
554        }
555        assert_eq!(p.snapshot().revision, before);
556    }
557
558    /// The invariant that makes "the timeout does not resolve the sign-in"
559    /// safe: a pending sign-in nobody completes still cannot latch forever,
560    /// because all three settling events clear it honestly. Without this the
561    /// timeout was the only clearer — and it fires on a timer, which is not
562    /// evidence the person finished or left.
563    ///
564    /// **Scoped to the UN-engaged window**, which is what these three events
565    /// may settle on their own. Nobody typed and nobody took control here (a
566    /// `SignInRequested` with `owner == Agent` seeds `user_engaged` false), so
567    /// there is no person whose blackout this could lift. The engaged case is
568    /// `a_run_ending_under_an_engaged_person_keeps_their_window` below.
569    #[test]
570    fn every_settling_event_clears_an_unengaged_pending_signin() {
571        for settle in [
572            ControlEvent::HandBack,
573            ControlEvent::RunEnded,
574            ControlEvent::GracePeriodExpired,
575        ] {
576            let mut state = ControlState::new();
577            state.apply(ControlEvent::AgentAttached);
578            state.apply(ControlEvent::SignInRequested("Sign in at x".into()));
579            assert!(state.is_blackout_active());
580
581            let effects = state.apply(settle.clone());
582
583            assert!(
584                state.pending_signin().is_none(),
585                "{settle:?} must settle a sign-in nobody completed"
586            );
587            assert!(
588                !state.is_blackout_active(),
589                "{settle:?} must lift the blackout"
590            );
591            assert_eq!(
592                effects,
593                vec![ControlEffect::SignInResolved { signed_in: false }],
594                "{settle:?} must report it honestly"
595            );
596        }
597    }
598
599    /// `is_blackout_active()` is `owner == User || pending_signin.is_some()`,
600    /// so reverting ownership while leaving a pending sign-in set leaves a
601    /// live blackout with nobody holding the wheel to end it — the strip up
602    /// forever and every model-facing read gated behind a person who has gone.
603    #[test]
604    fn grace_expiry_resolves_a_pending_signin_the_way_hand_back_does() {
605        let mut state = ControlState::new();
606        state.apply(ControlEvent::AgentAttached);
607        state.apply(ControlEvent::SignInRequested("Sign in at x".into()));
608        state.apply(ControlEvent::TakeControl);
609        assert!(state.is_blackout_active());
610
611        // The controller vanished and never came back.
612        state.apply(ControlEvent::ControlHolderDisconnected);
613        let effects = state.apply(ControlEvent::GracePeriodExpired);
614
615        assert_eq!(state.owner(), ControlOwner::Agent);
616        assert!(
617            state.pending_signin().is_none(),
618            "a sign-in nobody is completing must not outlive the person completing it"
619        );
620        assert!(
621            !state.is_blackout_active(),
622            "and the blackout must lift with it"
623        );
624        assert_eq!(
625            effects,
626            vec![ControlEffect::SignInResolved { signed_in: false }],
627            "reported honestly: they never finished"
628        );
629    }
630
631    fn sample_tabs(url: &str, title: &str) -> Vec<TabInfo> {
632        // TabId is opaque outside car-browser by design (Task 2's report:
633        // "ids are never reused") — mint real ones through TabRegistry
634        // itself, the same generic-over-a-cheap-handle seam Task 2's own
635        // tests use, rather than trying to fabricate one.
636        let (mut registry, _rx) = car_browser::tabs::TabRegistry::<&'static str>::new();
637        registry.open("handle", url, title);
638        registry.list()
639    }
640
641    // ---- owner transitions ----
642
643    #[test]
644    fn no_agent_is_the_default_owner() {
645        assert_eq!(ControlState::new().owner(), ControlOwner::NoAgent);
646    }
647
648    #[test]
649    fn agent_attaches_and_becomes_owner() {
650        let mut s = ControlState::new();
651        let effects = s.apply(ControlEvent::AgentAttached);
652        assert_eq!(s.owner(), ControlOwner::Agent);
653        assert!(effects.is_empty());
654    }
655
656    #[test]
657    fn take_control_hands_owner_to_user() {
658        let mut s = ControlState::new();
659        s.apply(ControlEvent::AgentAttached);
660        s.apply(ControlEvent::TakeControl);
661        assert_eq!(s.owner(), ControlOwner::User);
662    }
663
664    #[test]
665    fn take_control_is_a_noop_without_an_agent() {
666        // "no ceremony" — there is nothing to take yet.
667        let mut s = ControlState::new();
668        s.apply(ControlEvent::TakeControl);
669        assert_eq!(s.owner(), ControlOwner::NoAgent);
670    }
671
672    #[test]
673    fn hand_back_returns_owner_to_agent() {
674        let mut s = ControlState::new();
675        s.apply(ControlEvent::AgentAttached);
676        s.apply(ControlEvent::TakeControl);
677        s.apply(ControlEvent::HandBack);
678        assert_eq!(s.owner(), ControlOwner::Agent);
679    }
680
681    #[test]
682    fn hand_back_without_taking_control_is_a_noop() {
683        let mut s = ControlState::new();
684        s.apply(ControlEvent::AgentAttached);
685        let effects = s.apply(ControlEvent::HandBack);
686        assert_eq!(s.owner(), ControlOwner::Agent);
687        assert!(effects.is_empty());
688    }
689
690    #[test]
691    fn run_end_clears_ownership_with_no_ceremony() {
692        // The AGENT-holding case: the run ends while the agent still owns the
693        // browser, so there is nothing to hand back and the ceremony ends
694        // immediately — no strip, every control live.
695        let mut s = ControlState::new();
696        s.apply(ControlEvent::AgentAttached);
697        s.apply(ControlEvent::AgentActionStarted("Navigating".to_string()));
698        s.apply(ControlEvent::RunEnded);
699        assert_eq!(s.owner(), ControlOwner::NoAgent);
700        assert_eq!(s.current_action(), None);
701    }
702
703    /// "The agent's turn ending on its own while the user holds control does
704    /// not reclaim control: the strip keeps stating the user has control
705    /// until hand-back."
706    ///
707    /// This case previously asserted the opposite — `RunEnded` cleared
708    /// ownership unconditionally — which is the bug the live verification
709    /// caught: the user was driving, the run ended on its own, and the drawer
710    /// silently reported `owner=none blackout=false` with the person still at
711    /// the keyboard.
712    #[test]
713    fn a_run_ending_while_the_user_drives_does_not_reclaim_control() {
714        let mut s = ControlState::new();
715        s.apply(ControlEvent::AgentAttached);
716        s.apply(ControlEvent::AgentActionStarted("Navigating".to_string()));
717        s.apply(ControlEvent::TakeControl);
718        s.apply(ControlEvent::RunEnded);
719
720        assert_eq!(s.owner(), ControlOwner::User, "the user is still driving");
721        assert_eq!(
722            s.current_action(),
723            None,
724            "but the agent is no longer doing anything, so the action line clears"
725        );
726    }
727
728    /// The privacy half, and the reason this is not merely cosmetic: the
729    /// blackout is derived from ownership, so clearing ownership at run end
730    /// also LIFTED it — a recording spanning a user-control window would
731    /// resume capturing frames the moment the run happened to end, with the
732    /// person still driving.
733    #[test]
734    fn a_run_ending_while_the_user_drives_does_not_lift_the_blackout() {
735        let mut s = ControlState::new();
736        s.apply(ControlEvent::AgentAttached);
737        s.apply(ControlEvent::TakeControl);
738        assert!(s.is_blackout_active());
739
740        s.apply(ControlEvent::RunEnded);
741        assert!(
742            s.is_blackout_active(),
743            "no frame may reach the model or a recording while the user is still driving"
744        );
745
746        s.apply(ControlEvent::HandBack);
747        assert!(!s.is_blackout_active(), "hand-back is what lifts it");
748    }
749
750    /// A sign-in is likewise not settled by the run ending underneath it: the
751    /// page is still in front of the person. Hand-back settles it honestly.
752    #[test]
753    fn a_run_ending_mid_signin_leaves_the_strip_up_until_hand_back() {
754        let mut s = ControlState::new();
755        s.apply(ControlEvent::AgentAttached);
756        s.apply(ControlEvent::TakeControl);
757        s.apply(ControlEvent::SignInRequested("Sign in at x".to_string()));
758        let effects = s.apply(ControlEvent::RunEnded);
759
760        assert!(effects.is_empty(), "nothing was resolved by the run ending");
761        assert!(s.pending_signin().is_some(), "the strip stays up");
762        assert!(s.is_blackout_active());
763
764        let effects = s.apply(ControlEvent::HandBack);
765        assert_eq!(
766            effects,
767            vec![ControlEffect::SignInResolved { signed_in: false }],
768            "hand-back settles it, honestly"
769        );
770        assert!(s.pending_signin().is_none());
771    }
772
773    /// The round-8 blocker, and the half `a_run_ending_mid_signin_leaves_the
774    /// _strip_up_until_hand_back` above could not reach: it presses Take
775    /// control first, so it only ever covered `owner == User`. The ORDINARY
776    /// sign-in flow has no Take control press at all — the strip is the
777    /// affordance and `require_control` admits input while a sign-in is
778    /// pending — so `owner` stays `Agent` and the run ending resolved the
779    /// sign-in, lifted the blackout, and reopened the model's page reads (and
780    /// any in-flight recording) on a live credential form.
781    #[test]
782    fn a_run_ending_under_an_engaged_person_keeps_their_window() {
783        let mut s = ControlState::new();
784        s.apply(ControlEvent::AgentAttached);
785        s.apply(ControlEvent::SignInRequested("Sign in at x".to_string()));
786        // They never pressed Take control; they typed.
787        s.apply(ControlEvent::UserInput);
788        assert_eq!(s.owner(), ControlOwner::Agent, "precondition: no take");
789
790        let effects = s.apply(ControlEvent::RunEnded);
791        assert!(
792            effects.is_empty(),
793            "an agent-side ending resolves nothing for a person who is still here"
794        );
795        assert!(s.pending_signin().is_some(), "the strip stays up");
796        assert!(
797            s.is_blackout_active(),
798            "no frame may reach the model or a recording while they are typing"
799        );
800        assert_eq!(
801            s.owner(),
802            ControlOwner::NoAgent,
803            "the run really did end — what survives is the person's window, not the agent"
804        );
805
806        // Their own signal, and it is honest about what happened.
807        let effects = s.apply(ControlEvent::HandBack);
808        assert_eq!(
809            effects,
810            vec![ControlEffect::SignInResolved { signed_in: false }]
811        );
812        assert!(!s.is_blackout_active(), "hand-back is what lifts it");
813        assert_eq!(s.owner(), ControlOwner::NoAgent);
814    }
815
816    /// The other person-side signal the engaged window may end on: their
817    /// connection went away and the grace period elapsed. Without this the
818    /// window above would have exactly one exit, and a person who closed the
819    /// laptop mid-sign-in would leave the blackout latched for the daemon's
820    /// life — wedging every later run's browse call behind it.
821    #[test]
822    fn the_disconnect_grace_settles_an_engaged_window_the_run_ended_under() {
823        let mut s = ControlState::new();
824        s.apply(ControlEvent::AgentAttached);
825        s.apply(ControlEvent::SignInRequested("Sign in at x".to_string()));
826        s.apply(ControlEvent::UserInput);
827        s.apply(ControlEvent::RunEnded);
828        assert!(s.is_blackout_active());
829
830        // No holder — they never took control — so the ARM has to key on the
831        // pending sign-in rather than on ownership.
832        assert_eq!(
833            s.apply(ControlEvent::ControlHolderDisconnected),
834            vec![ControlEffect::StartGracePeriod],
835            "a person-facing window with no holder still arms the clock"
836        );
837        let effects = s.apply(ControlEvent::GracePeriodExpired);
838        assert_eq!(
839            effects,
840            vec![ControlEffect::SignInResolved { signed_in: false }],
841            "they vanished mid-flow, so the sign-in did not complete"
842        );
843        assert!(!s.is_blackout_active());
844    }
845
846    /// The un-engaged side of the same arm: a connection dropping on a browser
847    /// nobody is signing into and nobody has taken must not start a clock.
848    #[test]
849    fn a_disconnect_with_no_person_facing_window_arms_nothing() {
850        let mut s = ControlState::new();
851        s.apply(ControlEvent::AgentAttached);
852        assert!(s.apply(ControlEvent::ControlHolderDisconnected).is_empty());
853    }
854
855    /// Hand-back after the run already ended has no agent to hand back TO —
856    /// so it lands on the no-ceremony state, not on a strip pointing at an
857    /// agent that is gone.
858    #[test]
859    fn hand_back_after_the_run_ended_lands_on_no_agent() {
860        let mut s = ControlState::new();
861        s.apply(ControlEvent::AgentAttached);
862        s.apply(ControlEvent::TakeControl);
863        s.apply(ControlEvent::RunEnded);
864        s.apply(ControlEvent::HandBack);
865
866        assert_eq!(s.owner(), ControlOwner::NoAgent);
867        assert!(!s.is_blackout_active());
868        assert_eq!(s.current_action(), None);
869    }
870
871    /// Ordinary hand-back — the run is still going — still returns to the
872    /// agent. The deferred-run-end path must not have changed that.
873    #[test]
874    fn hand_back_during_a_live_run_still_returns_control_to_the_agent() {
875        let mut s = ControlState::new();
876        s.apply(ControlEvent::AgentAttached);
877        s.apply(ControlEvent::TakeControl);
878        s.apply(ControlEvent::HandBack);
879        assert_eq!(s.owner(), ControlOwner::Agent);
880    }
881
882    /// The disconnect grace period asks hand-back's question, so it needs
883    /// hand-back's answer: revert to the agent, or to nobody if the run ended
884    /// while the vanished controller held the wheel.
885    #[test]
886    fn a_grace_expiry_after_the_run_ended_reverts_to_no_agent() {
887        let mut s = ControlState::new();
888        s.apply(ControlEvent::AgentAttached);
889        s.apply(ControlEvent::TakeControl);
890        s.apply(ControlEvent::ControlHolderDisconnected);
891        s.apply(ControlEvent::RunEnded);
892        s.apply(ControlEvent::GracePeriodExpired);
893
894        assert_eq!(s.owner(), ControlOwner::NoAgent);
895        assert!(!s.is_blackout_active());
896    }
897
898    /// A fresh run attaching resets the flag, so a later hand-back inside
899    /// THAT run returns control to it rather than to nobody.
900    #[test]
901    fn a_new_run_attaching_clears_the_previous_run_ended_state() {
902        let mut s = ControlState::new();
903        s.apply(ControlEvent::AgentAttached);
904        s.apply(ControlEvent::RunEnded);
905        s.apply(ControlEvent::AgentAttached);
906        s.apply(ControlEvent::TakeControl);
907        s.apply(ControlEvent::HandBack);
908        assert_eq!(s.owner(), ControlOwner::Agent);
909    }
910
911    #[test]
912    fn agent_action_while_user_holds_control_does_not_reclaim_ownership() {
913        // "The agent's turn ending on its own while the user holds control
914        // does not reclaim control." There is no event in this reducer that
915        // reclaims ownership except HandBack/RunEnded/GracePeriodExpired —
916        // prove that feeding agent activity while owner=User does not
917        // silently do so either.
918        let mut s = ControlState::new();
919        s.apply(ControlEvent::AgentAttached);
920        s.apply(ControlEvent::TakeControl);
921        s.apply(ControlEvent::AgentActionStarted(
922            "Filling a form".to_string(),
923        ));
924        assert_eq!(s.owner(), ControlOwner::User);
925    }
926
927    // ---- blackout enforcement ----
928
929    #[test]
930    fn blackout_is_inactive_by_default() {
931        assert!(!ControlState::new().is_blackout_active());
932    }
933
934    #[test]
935    fn blackout_activates_when_user_takes_control() {
936        let mut s = ControlState::new();
937        s.apply(ControlEvent::AgentAttached);
938        s.apply(ControlEvent::TakeControl);
939        assert!(s.is_blackout_active());
940    }
941
942    #[test]
943    fn blackout_deactivates_on_hand_back() {
944        let mut s = ControlState::new();
945        s.apply(ControlEvent::AgentAttached);
946        s.apply(ControlEvent::TakeControl);
947        s.apply(ControlEvent::HandBack);
948        assert!(!s.is_blackout_active());
949    }
950
951    #[test]
952    fn blackout_activates_on_pending_signin_even_while_agent_owns() {
953        let mut s = ControlState::new();
954        s.apply(ControlEvent::AgentAttached);
955        s.apply(ControlEvent::SignInRequested(
956            "Sign in at example.com".to_string(),
957        ));
958        assert_eq!(s.owner(), ControlOwner::Agent);
959        assert!(s.is_blackout_active());
960    }
961
962    #[test]
963    fn blackout_stays_active_until_signin_resolves() {
964        let mut s = ControlState::new();
965        s.apply(ControlEvent::AgentAttached);
966        s.apply(ControlEvent::SignInRequested("Sign in".to_string()));
967        assert!(s.is_blackout_active());
968        s.apply(ControlEvent::SignInResolved { signed_in: true });
969        assert!(!s.is_blackout_active());
970    }
971
972    #[test]
973    fn blackout_requires_both_user_control_and_signin_to_clear() {
974        let mut s = ControlState::new();
975        s.apply(ControlEvent::AgentAttached);
976        s.apply(ControlEvent::TakeControl);
977        s.apply(ControlEvent::SignInRequested("Sign in".to_string()));
978        assert!(s.is_blackout_active());
979        // HandBack clears BOTH at once (owner and the pending sign-in).
980        s.apply(ControlEvent::HandBack);
981        assert!(!s.is_blackout_active());
982        assert_eq!(s.owner(), ControlOwner::Agent);
983        assert_eq!(s.pending_signin(), None);
984    }
985
986    // ---- sign-in lifecycle ----
987
988    #[test]
989    fn signin_requested_surfaces_the_pending_strip_with_its_message() {
990        let mut s = ControlState::new();
991        s.apply(ControlEvent::AgentAttached);
992        s.apply(ControlEvent::SignInRequested(
993            "Sign in at example.com".to_string(),
994        ));
995        assert_eq!(
996            s.pending_signin(),
997            Some(&PendingSignIn {
998                message: "Sign in at example.com".to_string()
999            })
1000        );
1001    }
1002
1003    #[test]
1004    fn signin_resolved_true_clears_the_strip() {
1005        let mut s = ControlState::new();
1006        s.apply(ControlEvent::AgentAttached);
1007        s.apply(ControlEvent::SignInRequested("Sign in".to_string()));
1008        s.apply(ControlEvent::SignInResolved { signed_in: true });
1009        assert_eq!(s.pending_signin(), None);
1010    }
1011
1012    #[test]
1013    fn signin_resolved_false_on_timeout_clears_the_strip() {
1014        let mut s = ControlState::new();
1015        s.apply(ControlEvent::AgentAttached);
1016        s.apply(ControlEvent::SignInRequested("Sign in".to_string()));
1017        s.apply(ControlEvent::SignInResolved { signed_in: false });
1018        assert_eq!(s.pending_signin(), None);
1019    }
1020
1021    #[test]
1022    fn hand_back_resolves_a_pending_signin_as_not_signed_in() {
1023        // "Hand-back triggers resolution of browse_await_signin; ... hand-
1024        // back without signing in returns signed_in: false" — and this
1025        // works even without an explicit prior Take Control, since a
1026        // pending sign-in implies the same "user has the page" state.
1027        let mut s = ControlState::new();
1028        s.apply(ControlEvent::AgentAttached);
1029        s.apply(ControlEvent::SignInRequested("Sign in".to_string()));
1030        let effects = s.apply(ControlEvent::HandBack);
1031        assert_eq!(s.pending_signin(), None);
1032        assert_eq!(
1033            effects,
1034            vec![ControlEffect::SignInResolved { signed_in: false }]
1035        );
1036        // The agent, which never lost run-scoped ownership during a plain
1037        // sign-in, keeps driving.
1038        assert_eq!(s.owner(), ControlOwner::Agent);
1039    }
1040
1041    #[test]
1042    fn run_ended_clears_a_pending_signin_too() {
1043        let mut s = ControlState::new();
1044        s.apply(ControlEvent::AgentAttached);
1045        s.apply(ControlEvent::SignInRequested("Sign in".to_string()));
1046        let effects = s.apply(ControlEvent::RunEnded);
1047        assert_eq!(s.pending_signin(), None);
1048        assert_eq!(
1049            effects,
1050            vec![ControlEffect::SignInResolved { signed_in: false }]
1051        );
1052    }
1053
1054    // ---- agent-pause-at-tool-boundary ----
1055
1056    #[test]
1057    fn agent_may_not_act_before_any_agent_attaches() {
1058        assert!(!ControlState::new().may_agent_act());
1059    }
1060
1061    #[test]
1062    fn agent_may_act_once_attached() {
1063        let mut s = ControlState::new();
1064        s.apply(ControlEvent::AgentAttached);
1065        assert!(s.may_agent_act());
1066    }
1067
1068    #[test]
1069    fn agent_may_not_act_while_user_holds_control() {
1070        let mut s = ControlState::new();
1071        s.apply(ControlEvent::AgentAttached);
1072        s.apply(ControlEvent::TakeControl);
1073        assert!(!s.may_agent_act());
1074    }
1075
1076    #[test]
1077    fn agent_may_act_again_after_hand_back() {
1078        let mut s = ControlState::new();
1079        s.apply(ControlEvent::AgentAttached);
1080        s.apply(ControlEvent::TakeControl);
1081        s.apply(ControlEvent::HandBack);
1082        assert!(s.may_agent_act());
1083    }
1084
1085    #[test]
1086    fn user_holds_control_is_false_before_any_agent_attaches() {
1087        // The critical difference from may_agent_act: a call site gating
1088        // BEFORE it has attached (record_start/record_stop/await_signin)
1089        // must not mistake "nobody has attached yet" for "the user is
1090        // driving" — there is nothing to wait for.
1091        assert!(!ControlState::new().user_holds_control());
1092    }
1093
1094    #[test]
1095    fn user_holds_control_is_false_while_the_agent_owns() {
1096        let mut s = ControlState::new();
1097        s.apply(ControlEvent::AgentAttached);
1098        assert!(!s.user_holds_control());
1099    }
1100
1101    #[test]
1102    fn user_holds_control_is_true_once_the_user_takes_control() {
1103        let mut s = ControlState::new();
1104        s.apply(ControlEvent::AgentAttached);
1105        s.apply(ControlEvent::TakeControl);
1106        assert!(s.user_holds_control());
1107    }
1108
1109    #[test]
1110    fn user_holds_control_is_false_again_after_hand_back() {
1111        let mut s = ControlState::new();
1112        s.apply(ControlEvent::AgentAttached);
1113        s.apply(ControlEvent::TakeControl);
1114        s.apply(ControlEvent::HandBack);
1115        assert!(!s.user_holds_control());
1116    }
1117
1118    // ---- grace-period revert ----
1119
1120    #[test]
1121    fn disconnect_while_user_holds_control_starts_a_grace_period() {
1122        let mut s = ControlState::new();
1123        s.apply(ControlEvent::AgentAttached);
1124        s.apply(ControlEvent::TakeControl);
1125        let effects = s.apply(ControlEvent::ControlHolderDisconnected);
1126        assert_eq!(effects, vec![ControlEffect::StartGracePeriod]);
1127        // Still held — the grace period hasn't expired yet.
1128        assert_eq!(s.owner(), ControlOwner::User);
1129    }
1130
1131    #[test]
1132    fn disconnect_without_user_control_is_a_noop() {
1133        let mut s = ControlState::new();
1134        s.apply(ControlEvent::AgentAttached);
1135        let effects = s.apply(ControlEvent::ControlHolderDisconnected);
1136        assert!(effects.is_empty());
1137        assert_eq!(s.owner(), ControlOwner::Agent);
1138    }
1139
1140    #[test]
1141    fn grace_period_expiry_reverts_control_to_agent() {
1142        // "an agent is never parked forever behind a vanished controller."
1143        let mut s = ControlState::new();
1144        s.apply(ControlEvent::AgentAttached);
1145        s.apply(ControlEvent::TakeControl);
1146        s.apply(ControlEvent::ControlHolderDisconnected);
1147        s.apply(ControlEvent::GracePeriodExpired);
1148        assert_eq!(s.owner(), ControlOwner::Agent);
1149    }
1150
1151    #[test]
1152    fn hand_back_before_grace_expiry_makes_the_late_expiry_a_noop() {
1153        let mut s = ControlState::new();
1154        s.apply(ControlEvent::AgentAttached);
1155        s.apply(ControlEvent::TakeControl);
1156        s.apply(ControlEvent::ControlHolderDisconnected);
1157        s.apply(ControlEvent::HandBack);
1158        assert_eq!(s.owner(), ControlOwner::Agent);
1159        // A stale timer firing after an on-time hand-back must not panic or
1160        // do anything odd — the owner is already Agent.
1161        let effects = s.apply(ControlEvent::GracePeriodExpired);
1162        assert!(effects.is_empty());
1163        assert_eq!(s.owner(), ControlOwner::Agent);
1164    }
1165
1166    // ---- approval race ----
1167
1168    #[test]
1169    fn approval_race_action_blocked_while_user_holds_control_runs_only_after_handback() {
1170        // The approval system itself (whether a browse action needs a human
1171        // sign-off before it runs) lives elsewhere — this reducer only owns
1172        // the owner gate. What has to hold here: a caller that re-checks
1173        // may_agent_act() at the moment it is about to EXECUTE an approved
1174        // action (not when it was proposed/approved) can never run that
1175        // action while the user holds control, and it becomes runnable the
1176        // instant hand-back happens.
1177        let mut s = ControlState::new();
1178        s.apply(ControlEvent::AgentAttached);
1179        assert!(s.may_agent_act(), "proposal time: agent still owns");
1180
1181        // The user takes control while the action sits at an (external)
1182        // approval prompt.
1183        s.apply(ControlEvent::TakeControl);
1184        // Approval lands (external event, not modeled here) — but the gate,
1185        // re-checked now, still says no.
1186        assert!(
1187            !s.may_agent_act(),
1188            "approved but must not execute while the user holds control"
1189        );
1190
1191        s.apply(ControlEvent::HandBack);
1192        assert!(s.may_agent_act(), "now it may run");
1193    }
1194
1195    // ---- presentation state / revision ----
1196
1197    #[test]
1198    fn revision_starts_at_zero() {
1199        assert_eq!(PresentationState::new().snapshot().revision, 0);
1200    }
1201
1202    #[test]
1203    fn revision_bumps_on_a_real_control_change() {
1204        let mut p = PresentationState::new();
1205        p.apply_control(ControlEvent::AgentAttached);
1206        assert_eq!(p.snapshot().revision, 1);
1207    }
1208
1209    #[test]
1210    fn revision_does_not_bump_on_a_noop_event() {
1211        let mut p = PresentationState::new();
1212        // No agent attached yet — TakeControl is a no-op.
1213        p.apply_control(ControlEvent::TakeControl);
1214        assert_eq!(p.snapshot().revision, 0);
1215    }
1216
1217    #[test]
1218    fn revision_bumps_when_tabs_actually_change() {
1219        let mut p = PresentationState::new();
1220        p.set_tabs(sample_tabs("https://example.com", "Example"));
1221        assert_eq!(p.snapshot().revision, 1);
1222    }
1223
1224    #[test]
1225    fn revision_does_not_bump_when_tabs_are_reset_to_the_same_value() {
1226        let mut p = PresentationState::new();
1227        let tabs = sample_tabs("https://example.com", "Example");
1228        p.set_tabs(tabs.clone());
1229        p.set_tabs(tabs);
1230        assert_eq!(p.snapshot().revision, 1);
1231    }
1232
1233    #[test]
1234    fn snapshot_reflects_owner_action_signin_blackout_and_tabs_together() {
1235        let mut p = PresentationState::new();
1236        p.apply_control(ControlEvent::AgentAttached);
1237        p.apply_control(ControlEvent::AgentActionStarted(
1238            "Filling trip details on example.com".to_string(),
1239        ));
1240        p.set_tabs(sample_tabs("https://example.com", "Example"));
1241        let snap = p.snapshot();
1242        assert_eq!(snap.owner, ControlOwner::Agent);
1243        assert_eq!(
1244            snap.current_action.as_deref(),
1245            Some("Filling trip details on example.com")
1246        );
1247        assert_eq!(snap.pending_signin, None);
1248        assert!(!snap.blackout_active);
1249        assert_eq!(snap.tabs.len(), 1);
1250        assert!(snap.tabs[0].active);
1251        assert_eq!(snap.tabs[0].url, "https://example.com");
1252    }
1253}