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}