Skip to main content

car_server_core/
browser_relay.rs

1//! The agent→daemon browser hop: making a SUPERVISED AGENT PROCESS's browser a
2//! first-class producer for the daemon's [`crate::browser_view`] surface.
3//!
4//! ## Why this exists
5//!
6//! `browser.view.*` serves browsers the daemon can touch. The Command Deck's
7//! CAR Chat does not go through one: `agents.chat` hands the turn to a
8//! supervised `car do --serve` process, which builds its OWN
9//! [`crate::assistant::browser_tools::BrowserTools`] in that process. Without
10//! this module the drawer cannot show the browser the flagship agent actually
11//! uses.
12//!
13//! ## The shape
14//!
15//! Nothing new is invented for transport. The supervised process already holds
16//! a persistent WebSocket session with the daemon (`session.auth { token,
17//! agent_id }`), and that session is already bidirectional — it is how
18//! `agents.chat` reaches the process (`agent.chat` reverse request → ack →
19//! `agent.chat.event` notifications back). This module reuses exactly that
20//! machinery:
21//!
22//! ```text
23//!   agent process                              daemon
24//!   ─────────────                              ──────
25//!   browser.producer.register  ──── call ────▶  RelayProducer + BrowserView
26//!   browser.producer.presentation ─ notify ──▶  view.refresh_presentation()
27//!   browser.producer.frame ──────── notify ──▶  view.emit_wire_frame()
28//!   BrowserTools (+ Task 3 reducer) ◀── call ── agent.browser.input
29//!                                  ◀── call ── agent.browser.control
30//!                                  ◀── call ── agent.browser.capture
31//! ```
32//!
33//! The daemon is a RELAY, not a second source of truth: the reducer and the
34//! browser both live in the agent process, and Task 4's fanout (bounded
35//! channels, cursors, snapshots, host-only authorization) stays the single
36//! host-facing surface. A [`RelayProducer`] is indistinguishable from a
37//! daemon-owned browser above [`crate::browser_view::ViewBrowser`].
38//!
39//! ## Authorization
40//!
41//! `browser.producer.*` is the AGENT side and requires an agent session — the
42//! `session.auth { token, agent_id }` binding the supervisor's token
43//! establishes. It grants an agent exactly one thing: publishing ITS OWN
44//! browser. It cannot subscribe, it cannot read another view, and nothing it
45//! pushes ever comes back to it — so no agent gains perception or actuation it
46//! did not already have through its `full_access` `browse_*` tools. The
47//! host-only rule on `browser.view.*` is untouched.
48//!
49//! A conversation key may only be claimed by the agent that is actually
50//! serving that chat session (checked against `ServerState::chat_sessions`), so
51//! one agent cannot hijack another's drawer.
52
53use std::collections::HashMap;
54use std::sync::atomic::{AtomicBool, Ordering};
55use std::sync::{Arc, Weak};
56use std::time::Duration;
57
58use car_browser::Modifier;
59use futures::SinkExt;
60use serde_json::{json, Value};
61use tokio::sync::{watch, Mutex, MutexGuard};
62use tokio_tungstenite::tungstenite::Message;
63
64use crate::assistant::browser_control::{ControlEffect, ControlOwner};
65use crate::assistant::browser_tools::ControlStatus;
66use crate::browser_attention::{notify_signin_transition, BrowserSignInSnapshot, SignInAttention};
67use crate::browser_view::{BrowserView, ViewControl, ViewInput, WireFrame, WirePresentation};
68use crate::handler::JsonRpcMessage;
69use crate::session::{ClientSession, ServerState, WsChannel};
70
71/// How long the daemon waits for a supervised process to answer a relayed
72/// call. Generous enough for a navigation that has to launch Chromium, short
73/// enough that a wedged process surfaces as an error instead of a hung drawer.
74pub const RELAY_CALL_TIMEOUT: Duration = Duration::from_secs(30);
75
76/// How long the capture pump waits before re-sending a request that did not
77/// reach the agent process. Short: the drawer is frameless until it lands, and
78/// the call itself is already bounded by [`RELAY_CALL_TIMEOUT`].
79const CAPTURE_RETRY_BACKOFF: Duration = Duration::from_secs(2);
80
81/// How many conversation views one supervised process keeps.
82///
83/// `register_relay`'s doc calls a view "one per conversation key", and it is —
84/// but `agents.chat`'s `session_id` is minted FRESH PER TURN (`ChatTabView.send`,
85/// and `ChatBrowserBindingTracker`'s own header says so), so in practice that
86/// was one view per turn, forever: a long-lived `car do --serve` process
87/// accumulated a `BrowserView`, a registry entry and a binding for every turn it
88/// had ever served, and every screencast frame was cloned into all of them.
89/// `note_producer_disconnected` closed exactly this leak for the process-DEATH
90/// path; the per-turn key means the live-process path is the one that fires.
91/// A registration now retires the older ones — replacement, at the recency the
92/// two ends already agree on.
93///
94/// The number is [`MAX_KNOWN_CONVERSATIONS`](crate::assistant::browser_producer::MAX_KNOWN_CONVERSATIONS), not 1, and that is load-bearing:
95/// it is exactly the set the agent side re-publishes (`BrowserProducer::known`,
96/// same cap, same reasoning), so retiring anything newer would be churn the
97/// 10-second republish sweep immediately undoes. Anything older it will never
98/// re-register and the drawer's candidate chain — which reaches back exactly one
99/// turn — can never ask for.
100///
101/// Retiring is not deleting: `release_if_idle` refuses to drop a view anybody is
102/// subscribed to, so a drawer still watching an older key keeps it, and the
103/// eviction happens when that drawer unsubscribes.
104pub const MAX_VIEWS_PER_PRODUCER: usize =
105    crate::assistant::browser_producer::MAX_KNOWN_CONVERSATIONS;
106
107/// Largest `browser.producer.frame` payload the daemon will fan out, in
108/// base64 characters.
109///
110/// A registered producer already runs arbitrary code on this box, so this is
111/// not a privilege boundary — but the daemon is the SHARED component and must
112/// not be OOM-able by one agent's bug, and the only bound underneath is
113/// tungstenite's 64 MiB message cap. A 1920x1080 quality-60 JPEG is a few
114/// hundred KB and base64 adds a third, so 8 MiB is generous by an order of
115/// magnitude for anything the pump can legitimately produce.
116const MAX_PRODUCER_FRAME_BYTES: usize = 8 * 1024 * 1024;
117
118// ---------------------------------------------------------------------------
119// The codec — ONE definition, used by both ends
120// ---------------------------------------------------------------------------
121//
122// The daemon encodes and the agent process decodes (and vice versa for the
123// results), and both ends live in this crate. Writing the codec once, here,
124// is what keeps them from drifting: a new `ViewInput` variant or a new
125// `ControlEffect` is a compile error in the exhaustive matches below rather
126// than a shape one side silently fails to parse.
127
128/// Modifier → its wire name. Exhaustive: a new modifier must not silently
129/// serialize as an existing one.
130pub fn modifier_name(modifier: Modifier) -> &'static str {
131    match modifier {
132        Modifier::Shift => "shift",
133        Modifier::Control => "control",
134        Modifier::Alt => "alt",
135        Modifier::Meta => "meta",
136    }
137}
138
139/// One user input, as `agent.browser.input` carries it. The shape deliberately
140/// mirrors the `browser.view.*` input params a host sends, so the same field
141/// names mean the same thing end to end.
142pub fn input_to_wire(input: &ViewInput) -> Value {
143    match input {
144        ViewInput::Navigate { url } => json!({ "op": "navigate", "url": url }),
145        ViewInput::Click { x, y } => json!({ "op": "click", "x": x, "y": y }),
146        ViewInput::Type { text } => json!({ "op": "type", "text": text }),
147        ViewInput::Keypress { key, modifiers } => json!({
148            "op": "keypress",
149            "key": key,
150            "modifiers": modifiers.iter().map(|m| modifier_name(*m)).collect::<Vec<_>>(),
151        }),
152        ViewInput::Scroll { delta_y } => json!({ "op": "scroll", "delta_y": delta_y }),
153        ViewInput::Paste { text } => json!({ "op": "paste", "text": text }),
154        ViewInput::Back => json!({ "op": "back" }),
155        ViewInput::Forward => json!({ "op": "forward" }),
156        ViewInput::Reload => json!({ "op": "reload" }),
157        ViewInput::TabOpen => json!({ "op": "tab_open" }),
158        ViewInput::TabClose { tab_id } => json!({ "op": "tab_close", "tab_id": tab_id }),
159        ViewInput::TabSwitch { tab_id } => json!({ "op": "tab_switch", "tab_id": tab_id }),
160    }
161}
162
163/// The agent process's half of [`input_to_wire`].
164pub fn input_from_wire(params: &Value) -> Result<ViewInput, String> {
165    let op = params
166        .get("op")
167        .and_then(Value::as_str)
168        .ok_or("agent.browser.input requires { op }")?;
169    let string = |field: &str| -> Result<String, String> {
170        params
171            .get(field)
172            .and_then(Value::as_str)
173            .map(str::to_string)
174            .ok_or_else(|| format!("agent.browser.input `{op}` requires {{ {field} }}"))
175    };
176    match op {
177        "navigate" => Ok(ViewInput::Navigate {
178            url: string("url")?,
179        }),
180        "click" => {
181            let (x, y) = match (
182                params.get("x").and_then(Value::as_f64),
183                params.get("y").and_then(Value::as_f64),
184            ) {
185                (Some(x), Some(y)) => (x, y),
186                _ => return Err("agent.browser.input `click` requires { x, y }".to_string()),
187            };
188            Ok(ViewInput::Click { x, y })
189        }
190        "type" => Ok(ViewInput::Type {
191            text: string("text")?,
192        }),
193        "keypress" => {
194            let mut modifiers = Vec::new();
195            for name in params
196                .get("modifiers")
197                .and_then(Value::as_array)
198                .unwrap_or(&Vec::new())
199            {
200                let name = name
201                    .as_str()
202                    .ok_or("agent.browser.input `keypress` modifiers must be strings")?;
203                modifiers.push(crate::browser_view::parse_modifier(name)?);
204            }
205            Ok(ViewInput::Keypress {
206                key: string("key")?,
207                modifiers,
208            })
209        }
210        "scroll" => Ok(ViewInput::Scroll {
211            delta_y: params
212                .get("delta_y")
213                .and_then(Value::as_i64)
214                .and_then(|n| i32::try_from(n).ok())
215                .ok_or("agent.browser.input `scroll` requires { delta_y }")?,
216        }),
217        "paste" => Ok(ViewInput::Paste {
218            text: string("text")?,
219        }),
220        "back" => Ok(ViewInput::Back),
221        "forward" => Ok(ViewInput::Forward),
222        "reload" => Ok(ViewInput::Reload),
223        "tab_open" => Ok(ViewInput::TabOpen),
224        "tab_close" => Ok(ViewInput::TabClose {
225            tab_id: string("tab_id")?,
226        }),
227        "tab_switch" => Ok(ViewInput::TabSwitch {
228            tab_id: string("tab_id")?,
229        }),
230        other => Err(format!("unknown agent.browser.input op '{other}'")),
231    }
232}
233
234/// One control transition, as `agent.browser.control` carries it.
235pub fn control_to_wire(control: ViewControl) -> &'static str {
236    match control {
237        ViewControl::TakeControl => "take_control",
238        ViewControl::HandBack => "hand_back",
239        ViewControl::RunEnded => "run_ended",
240        ViewControl::HolderDisconnected => "holder_disconnected",
241        ViewControl::GraceExpired => "grace_expired",
242    }
243}
244
245/// The agent process's half of [`control_to_wire`].
246pub fn control_from_wire(action: &str) -> Result<ViewControl, String> {
247    match action {
248        "take_control" => Ok(ViewControl::TakeControl),
249        "hand_back" => Ok(ViewControl::HandBack),
250        "run_ended" => Ok(ViewControl::RunEnded),
251        "holder_disconnected" => Ok(ViewControl::HolderDisconnected),
252        "grace_expired" => Ok(ViewControl::GraceExpired),
253        other => Err(format!(
254            "unknown agent.browser.control action '{other}' — use take_control, hand_back, \
255             run_ended, holder_disconnected or grace_expired"
256        )),
257    }
258}
259
260/// What the reducer asked the CALLER to do. The reducer runs in the agent
261/// process; the effects have to cross back because the daemon owns the clock
262/// the grace period runs on.
263pub fn effects_to_wire(effects: &[ControlEffect]) -> Value {
264    Value::Array(
265        effects
266            .iter()
267            .map(|effect| match effect {
268                ControlEffect::StartGracePeriod => json!({ "effect": "start_grace_period" }),
269                ControlEffect::SignInResolved { signed_in } => json!({
270                    "effect": "sign_in_resolved",
271                    "signed_in": signed_in,
272                }),
273            })
274            .collect(),
275    )
276}
277
278/// The daemon's half of [`effects_to_wire`]. An effect it does not recognize is
279/// dropped rather than failing the call: a newer agent process talking to an
280/// older daemon must still be able to hand control back.
281pub fn effects_from_wire(value: &Value) -> Vec<ControlEffect> {
282    let Some(items) = value.as_array() else {
283        return Vec::new();
284    };
285    items
286        .iter()
287        .filter_map(|item| match item.get("effect").and_then(Value::as_str) {
288            Some("start_grace_period") => Some(ControlEffect::StartGracePeriod),
289            Some("sign_in_resolved") => Some(ControlEffect::SignInResolved {
290                signed_in: item
291                    .get("signed_in")
292                    .and_then(Value::as_bool)
293                    .unwrap_or(false),
294            }),
295            _ => {
296                tracing::debug!(effect = ?item, "browser relay: ignoring an unknown control effect");
297                None
298            }
299        })
300        .collect()
301}
302
303// ---------------------------------------------------------------------------
304// The reverse call
305// ---------------------------------------------------------------------------
306
307/// Send one JSON-RPC request DOWN the agent's own session and await its reply.
308///
309/// Same machinery `agents.chat` uses for `agent.chat`: a string request id, a
310/// oneshot parked in [`WsChannel::pending`], and the dispatcher's response
311/// demuxer routing the agent's reply back. Nothing about the transport is new.
312async fn call_agent(
313    channel: &Arc<WsChannel>,
314    method: &str,
315    params: Value,
316) -> Result<Value, String> {
317    let request_id = channel.next_request_id();
318    let (tx, rx) = tokio::sync::oneshot::channel();
319    channel.pending.lock().await.insert(request_id.clone(), tx);
320
321    let frame = json!({
322        "jsonrpc": "2.0",
323        "method": method,
324        "params": params,
325        "id": request_id,
326    });
327    let text = match serde_json::to_string(&frame) {
328        Ok(text) => text,
329        Err(e) => {
330            channel.pending.lock().await.remove(&request_id);
331            return Err(format!("serialize {method}: {e}"));
332        }
333    };
334    // Bounded write, and the timeout wraps the LOCK as well as the send.
335    //
336    // `channel.write` is shared with `agents.chat`'s own reverse call and with
337    // every response that connection sends, so parking here on a full TCP
338    // buffer parks all of them — up to `handler_default_deadline_secs` (1800s)
339    // of wedged chat, and the detached `broadcast_host_connected` spawns
340    // outlive even that. There is no transport-level timeout underneath:
341    // `accept_async` installs no `WebSocketConfig` and tokio-tungstenite adds
342    // none. Same shape, and the same reason, as `handler.rs`'s keepalive ping
343    // and its `tools.stream.event` forwarder; the agent side of this very
344    // feature already bounds its half (`FRAME_PUSH_TIMEOUT`).
345    let sent = tokio::time::timeout(RELAY_CALL_TIMEOUT, async {
346        channel
347            .write
348            .lock()
349            .await
350            .send(Message::Text(text.into()))
351            .await
352    })
353    .await;
354    match sent {
355        Ok(Ok(())) => {}
356        Ok(Err(e)) => {
357            channel.pending.lock().await.remove(&request_id);
358            return Err(format!(
359                "the agent process serving this browser is unreachable: {e}"
360            ));
361        }
362        Err(_) => {
363            channel.pending.lock().await.remove(&request_id);
364            return Err(format!(
365                "the agent process serving this browser is unreachable: its connection did not \
366                 accept `{method}` within {}s",
367                RELAY_CALL_TIMEOUT.as_secs()
368            ));
369        }
370    }
371
372    match tokio::time::timeout(RELAY_CALL_TIMEOUT, rx).await {
373        Ok(Ok(response)) => match (response.error, response.output) {
374            (Some(error), _) => Err(error),
375            (None, Some(output)) => Ok(output),
376            (None, None) => Ok(Value::Null),
377        },
378        Ok(Err(_)) => {
379            Err("the agent process serving this browser disconnected before answering".to_string())
380        }
381        Err(_) => {
382            channel.pending.lock().await.remove(&request_id);
383            Err(format!(
384                "the agent process serving this browser did not answer `{method}` within {}s",
385                RELAY_CALL_TIMEOUT.as_secs()
386            ))
387        }
388    }
389}
390
391/// What every call on a producer whose process has gone away answers.
392pub const PRODUCER_GONE: &str =
393    "the agent process that owns this browser has disconnected — its browser is gone";
394
395// ---------------------------------------------------------------------------
396// The producer
397// ---------------------------------------------------------------------------
398
399/// One supervised agent process's browser, as the daemon sees it.
400///
401/// A producer is per PROCESS (per agent WS connection), not per conversation:
402/// `car do --serve` builds one `AssistantRuntime` and multiplexes every chat
403/// session through it, so the process has exactly one browser. It may
404/// therefore back several views — one per conversation key it registered —
405/// and every push fans out to all of them.
406pub struct RelayProducer {
407    /// The agent connection's client id. This is the producer's IDENTITY: a
408    /// reconnecting process is a different producer, which is what makes
409    /// re-registration a clean replacement rather than a resurrection.
410    client_id: String,
411    /// The agent this process serves, from `session.auth { agent_id }`.
412    agent_id: String,
413    channel: Arc<WsChannel>,
414    /// The last presentation the process pushed. Served to every view with no
415    /// round trip, exactly like `BrowserTools::control_status` is the cheap
416    /// read on the local path.
417    last: Mutex<WirePresentation>,
418    /// Operator attention belongs to the PROCESS, just like `last`.
419    ///
420    /// One supervised process owns one browser but can back up to eight
421    /// per-turn views. Keeping this state on a view made one presentation
422    /// push announce once per view. The conversation id is the newest view
423    /// key; moving it while a wait is pending resolves the old key before
424    /// announcing the new one so host state never contains both.
425    signin_attention: Mutex<RelaySignInAttention>,
426    /// The views this producer backs, one per conversation key.
427    views: Mutex<Vec<Weak<BrowserView>>>,
428    alive: AtomicBool,
429    /// How many views currently want frames, so a browser nobody is watching
430    /// never pays for a screencast or the WS traffic.
431    ///
432    /// A `std::sync::Mutex` rather than an atomic because the count and the
433    /// `capture` signal it drives MUST move together — see
434    /// [`Self::set_watchers`] for the interleaving that a separate atomic and
435    /// send allowed.
436    watchers: std::sync::Mutex<usize>,
437    capture: watch::Sender<bool>,
438    /// The newest host-connectivity transition, and the serializer that keeps
439    /// the wire order equal to the transition order.
440    ///
441    /// `broadcast_host_connected` fires one detached task per producer per
442    /// transition, and each one is a `RELAY_CALL_TIMEOUT`-bounded round trip —
443    /// so a host flap (disconnect, immediate reconnect) had two tasks racing
444    /// and whichever finished last decided what the process believed. The
445    /// process caches that answer, and it decides whether a browser launches
446    /// headless and whether `browser_await_signin` points the person at the
447    /// drawer or at an app that is not running.
448    ///
449    /// `host_desired` is read UNDER `host_push`, so a waiter always sends the
450    /// newest value, and `host_push`'s payload is the last value actually
451    /// delivered so a superseded transition collapses instead of being re-sent.
452    host_desired: AtomicBool,
453    host_push: Mutex<Option<bool>>,
454
455    /// Serializes the sign-in announcements themselves, so `signin_attention`
456    /// never has to be.
457    ///
458    /// Taken while the state lock is STILL held and released only after the
459    /// broadcast, which is what keeps two concurrent transitions — a hand-back
460    /// racing the sign-in tool's own request — reaching the host in the order
461    /// their decisions landed. Without that order a resolution can overtake
462    /// the request that preceded it and leave a badge asserting a wait that
463    /// already ended. What it buys is that every NON-transition caller (every
464    /// relayed drawer input, every presentation republish) settles its
465    /// compare-and-return under `signin_attention` and never waits on a host
466    /// socket at all.
467    ///
468    /// **A known residual, deliberately kept.** A second concurrent transition
469    /// still holds the state lock while it queues here, so a third caller can
470    /// block on that state lock for the length of the first broadcast. Every
471    /// way of removing that — take a ticket under the state lock, wait for
472    /// your turn after releasing it — trades a bounded stall for an unbounded
473    /// hazard, because a task cancelled between taking the ticket and taking
474    /// its turn either wedges every later announcement for this producer (if
475    /// the queue only advances in turn) or breaks the ordering the queue
476    /// exists to provide (if it always advances on drop), and a WS session
477    /// task being dropped is exactly the cancellation this code lives with.
478    /// The one cheap ordered variant — first-polling the `lock()` future under
479    /// the state lock — depends on tokio enqueuing a semaphore waiter on first
480    /// poll, which is an implementation detail and not a documented contract.
481    /// A FIFO mutex is correct under cancellation by construction: the guard
482    /// drops, the next waiter proceeds, order holds. Do not "fix" this back.
483    announce_order: Mutex<()>,
484}
485
486/// One decided sign-in transition, waiting to be told to the operator.
487///
488/// The point of the type is the split it forces: everything needed to make
489/// the announcement is COPIED OUT under the state lock, so the broadcast that
490/// follows — `HostState::record_event`, which awaits every `host.subscribe`
491/// socket in turn at up to 10s each — happens with that lock released. Held
492/// across the broadcast, it stalled `push_presentation` and every relayed
493/// drawer input behind N backpressured host sockets.
494struct PendingSignInAnnouncement {
495    attention: Arc<dyn SignInAttention>,
496    conversation_id: Option<String>,
497    before: Option<String>,
498    after: Option<String>,
499}
500
501#[derive(Default)]
502struct RelaySignInAttention {
503    attention: Option<Arc<dyn SignInAttention>>,
504    /// Route that owns the currently-announced wait.
505    conversation_id: Option<String>,
506    /// Most recently registered route, adopted after the current wait ends.
507    latest_conversation_id: Option<String>,
508    announced: Option<String>,
509}
510
511impl RelayProducer {
512    pub fn new(client_id: String, agent_id: String, channel: Arc<WsChannel>) -> Arc<Self> {
513        let (capture, rx) = watch::channel(false);
514        let producer = Arc::new(Self {
515            client_id,
516            agent_id,
517            channel,
518            last: Mutex::new(WirePresentation::empty()),
519            signin_attention: Mutex::new(RelaySignInAttention::default()),
520            views: Mutex::new(Vec::new()),
521            alive: AtomicBool::new(true),
522            watchers: std::sync::Mutex::new(0),
523            capture,
524            host_desired: AtomicBool::new(false),
525            host_push: Mutex::new(None),
526            announce_order: Mutex::new(()),
527        });
528        producer.spawn_capture_pump(rx);
529        producer
530    }
531
532    pub fn client_id(&self) -> &str {
533        &self.client_id
534    }
535
536    pub fn agent_id(&self) -> &str {
537        &self.agent_id
538    }
539
540    pub fn is_alive(&self) -> bool {
541        self.alive.load(Ordering::Acquire)
542    }
543
544    /// The cached presentation — what `browser.view.subscribe` snapshots and
545    /// what `require_control` reads.
546    pub async fn presentation(&self) -> WirePresentation {
547        self.last.lock().await.clone()
548    }
549
550    /// Install (or move) the one process-level attention route.
551    ///
552    /// A wait stays pinned to the conversation that raised it. Another turn
553    /// registering while the process is blocked must not move the badge to
554    /// that unrelated chat; its route becomes eligible only after resolution.
555    pub async fn set_signin_attention(
556        &self,
557        attention: Option<Arc<dyn SignInAttention>>,
558        conversation_id: Option<String>,
559    ) {
560        let mut binding = self.signin_attention.lock().await;
561        binding.latest_conversation_id = conversation_id.clone();
562        if binding.announced.is_none() {
563            binding.conversation_id = conversation_id;
564        }
565        binding.attention = attention;
566        let pending = self.decide_signin_transition(&mut binding).await;
567        self.announce(binding, Vec::from_iter(pending)).await;
568    }
569
570    /// Resolve the route `conversation_id` owns, and drop the sink only if
571    /// this view was the LAST one this producer serves.
572    ///
573    /// Three things are being separated here.
574    ///
575    /// Retiring an older view after the route already moved to a newer one
576    /// must not clear the newer badge — that is the key check at the top.
577    ///
578    /// One supervised process backs up to [`MAX_VIEWS_PER_PRODUCER`] views,
579    /// so nulling `attention` because the view that happened to own the route
580    /// retired left the producer with no sink while it was still serving the
581    /// others: a sign-in raised on a surviving view then fell straight through
582    /// [`Self::decide_signin_transition`]'s `attention` guard and told nobody.
583    /// When a survivor exists the route MOVES instead — to the newest one,
584    /// because that is the turn the operator is actually looking at. `views`
585    /// is in registration order (see [`Self::views_past_the_cap`]), so the
586    /// newest is the LAST match; taking the first handed the banner and the
587    /// badge to the oldest surviving turn and opened a stale conversation.
588    ///
589    /// And the wait itself does not end just because the view reporting it
590    /// retired. The resolve below is true of the ROUTE, not of the browser,
591    /// so when the route moves the wait is immediately re-raised against its
592    /// new owner — otherwise `announced` sits at `None` against a
593    /// `pending_signin` that is still `Some`, and since a browser parked on a
594    /// static login page publishes no further presentation, nothing would
595    /// re-announce it. The operator sees the badge move rather than vanish.
596    pub async fn detach_signin_attention(&self, conversation_id: Option<&str>) {
597        let mut binding = self.signin_attention.lock().await;
598        if binding.conversation_id.as_deref() != conversation_id {
599            return;
600        }
601        // Read the survivors UNDER this lock. Read before taking it, a
602        // `register_relay` landing in the window was invisible here — it
603        // attaches its view and then leaves `conversation_id` pinned to the
604        // announced wait — and the sink was nulled with that new view live,
605        // which is the exact state this method exists to prevent. The nesting
606        // is safe and one-directional: `attach_view`, `views_past_the_cap`
607        // and `live_views` are the only holders of `views`, and none of them
608        // takes `signin_attention`.
609        let live = self.live_view_keys().await;
610        let retiring_was_latest = binding.latest_conversation_id.as_deref() == conversation_id;
611        let latest_is_live = live
612            .iter()
613            .any(|key| key.as_deref() == binding.latest_conversation_id.as_deref());
614        let successor = if !retiring_was_latest && latest_is_live {
615            // The newest registration is where the operator should be sent,
616            // and it is still live. It is not necessarily the last element of
617            // `live`: a wait pins `conversation_id` while later turns move
618            // `latest_conversation_id` on ahead of it.
619            Some(binding.latest_conversation_id.clone())
620        } else {
621            live.into_iter()
622                .rev()
623                .find(|key| key.as_deref() != conversation_id)
624        };
625
626        let mut pending = Vec::new();
627        if let Some(before) = binding.announced.take() {
628            if let Some(attention) = binding.attention.as_ref() {
629                pending.push(PendingSignInAnnouncement {
630                    attention: Arc::clone(attention),
631                    conversation_id: conversation_id.map(str::to_string),
632                    before: Some(before),
633                    after: None,
634                });
635            }
636        }
637
638        match successor {
639            // The process is still serving somebody, so the browser — and the
640            // sink that reports it — outlive this view.
641            Some(key) => {
642                // `latest_conversation_id` is the NEWEST registration, which
643                // is still correct unless the view retiring is that
644                // registration. Overwriting it with the successor discarded
645                // the newest route and sent the next wait to a stale turn.
646                // It is ALSO wrong to keep when it names a view that is no
647                // longer live: another process can claim that key, and the
648                // adopt path's detach early-returns while the route is pinned
649                // by an announced wait, so nothing else rewrites it. Left
650                // stale, `decide_signin_transition` adopts it as the route on
651                // resolve and the next wait announces a conversation the
652                // operator cannot reach.
653                if retiring_was_latest || !latest_is_live {
654                    binding.latest_conversation_id = key.clone();
655                }
656                binding.conversation_id = key;
657                pending.extend(self.decide_signin_transition(&mut binding).await);
658            }
659            None => {
660                binding.attention = None;
661                binding.conversation_id = None;
662                binding.latest_conversation_id = None;
663            }
664        }
665        self.announce(binding, pending).await;
666    }
667
668    pub async fn signin_snapshot(&self) -> Option<BrowserSignInSnapshot> {
669        let binding = self.signin_attention.lock().await;
670        binding.announced.as_ref().map(|message| {
671            BrowserSignInSnapshot::new(binding.conversation_id.as_deref(), message.clone())
672        })
673    }
674
675    async fn sync_signin_attention(&self) {
676        let mut binding = self.signin_attention.lock().await;
677        let pending = self.decide_signin_transition(&mut binding).await;
678        self.announce(binding, Vec::from_iter(pending)).await;
679    }
680
681    /// Compare the cached presentation against what the operator was last
682    /// told and record the answer, WITHOUT telling anyone.
683    ///
684    /// The decision and the state write stay atomic under the caller's held
685    /// lock — that is what makes "one browser, one notification" hold when
686    /// several per-turn views push the same presentation. Only the broadcast
687    /// moves out, to [`Self::announce`].
688    async fn decide_signin_transition(
689        &self,
690        binding: &mut RelaySignInAttention,
691    ) -> Option<PendingSignInAnnouncement> {
692        let attention = Arc::clone(binding.attention.as_ref()?);
693        let current = self.last.lock().await.pending_signin.clone();
694        if binding.announced == current {
695            return None;
696        }
697        let before = std::mem::replace(&mut binding.announced, current.clone());
698        let conversation_id = binding.conversation_id.clone();
699        if current.is_none() {
700            binding.conversation_id = binding.latest_conversation_id.clone();
701        }
702        Some(PendingSignInAnnouncement {
703            attention,
704            conversation_id,
705            before,
706            after: current,
707        })
708    }
709
710    /// Broadcast decided transitions with the state lock released — see
711    /// [`Self::announce_order`] for why the order lock is taken first and the
712    /// state lock dropped second, and never the other way round.
713    ///
714    /// Takes a sequence rather than one announcement because a route move
715    /// resolves the old owner and re-raises against the new one, and those two
716    /// must reach the operator in that order with nothing interleaved between
717    /// them — so they share a single hold of the order lock.
718    async fn announce(
719        &self,
720        binding: MutexGuard<'_, RelaySignInAttention>,
721        pending: Vec<PendingSignInAnnouncement>,
722    ) {
723        if pending.is_empty() {
724            return;
725        }
726        let _order = self.announce_order.lock().await;
727        drop(binding);
728        for announcement in pending {
729            notify_signin_transition(
730                &announcement.attention,
731                announcement.conversation_id.as_deref(),
732                announcement.before.as_deref(),
733                announcement.after.as_deref(),
734            )
735            .await;
736        }
737    }
738
739    /// The conversation keys this producer still backs, in REGISTRATION
740    /// ORDER — oldest first, so the newest is the last element.
741    async fn live_view_keys(&self) -> Vec<Option<String>> {
742        self.live_views()
743            .await
744            .into_iter()
745            .map(|view| view.key().map(str::to_string))
746            .collect()
747    }
748
749    /// Who is driving, derived from the cache. No round trip, by design: this
750    /// is consulted on every input call.
751    pub async fn control_status(&self) -> ControlStatus {
752        let last = self.last.lock().await;
753        ControlStatus {
754            owner: last.owner.into(),
755            signin_pending: last.pending_signin.is_some(),
756            blackout_active: last.blackout_active,
757        }
758    }
759
760    /// Drive the reducer that lives WITH the browser, in the agent process,
761    /// and bring its effects back — the daemon owns the grace-period clock.
762    ///
763    /// **Fallible, and the caller must respect that.** A transition that never
764    /// reached the process did not happen: the process's reducer still says
765    /// whatever it said before. Reporting success would leave the two sides
766    /// disagreeing about who is driving — the daemon admitting input the
767    /// process then refuses, and, worse, a person told the blackout is up when
768    /// the process never entered it.
769    /// Returns the owner the agent's reducer landed on, taken from THIS
770    /// response rather than from the cache: the presentation pump writes that
771    /// cache too, unordered against this call, so a re-read can answer with a
772    /// pre-transition snapshot (see `BrowserView::take_control`).
773    pub async fn control(
774        &self,
775        control: ViewControl,
776    ) -> Result<(ControlOwner, Vec<ControlEffect>), String> {
777        if !self.is_alive() {
778            return Err(PRODUCER_GONE.to_string());
779        }
780        let value = call_agent(
781            &self.channel,
782            "agent.browser.control",
783            json!({ "action": control_to_wire(control) }),
784        )
785        .await
786        .map_err(|error| {
787            tracing::warn!(
788                agent_id = %self.agent_id,
789                action = control_to_wire(control),
790                %error,
791                "browser relay: control transition did not reach the agent process"
792            );
793            error
794        })?;
795        // Read the owner off THIS response before caching it, and fall back
796        // to the cache only when the agent sent no presentation at all.
797        let mut owner: Option<ControlOwner> = None;
798        if let Some(presentation) = value.get("presentation") {
799            match serde_json::from_value::<WirePresentation>(presentation.clone()) {
800                Ok(presentation) => {
801                    // The owner is read off THIS response (the reply is
802                    // authoritative about the transition it just performed),
803                    // but the CACHE write goes through the same monotonicity
804                    // guard every other writer uses. This response can be
805                    // older than a push that overtook it — the agent's
806                    // presentation pump fires on the same transition and can
807                    // land first — and the cache is what the input gate reads.
808                    owner = Some(presentation.owner.into());
809                    self.cache(presentation).await;
810                }
811                Err(e) => tracing::warn!(
812                    error = %e,
813                    "browser relay: agent returned an unparseable presentation"
814                ),
815            }
816        }
817        let owner = match owner {
818            Some(owner) => owner,
819            None => self.last.lock().await.owner.into(),
820        };
821        Ok((
822            owner,
823            effects_from_wire(value.get("effects").unwrap_or(&Value::Null)),
824        ))
825    }
826
827    /// Relay one user input to the process, which executes it against its own
828    /// `BrowserTools` exactly as the in-daemon path does. Errors come back
829    /// verbatim — the same code produces them on both sides.
830    pub async fn input(&self, input: ViewInput) -> Result<Option<String>, String> {
831        if !self.is_alive() {
832            return Err(PRODUCER_GONE.to_string());
833        }
834        let out = call_agent(&self.channel, "agent.browser.input", input_to_wire(&input)).await?;
835        Ok(out
836            .get("tab_id")
837            .and_then(Value::as_str)
838            .map(str::to_string))
839    }
840
841    /// The capture state this producer currently wants, for the registration
842    /// ack to carry.
843    ///
844    /// The daemon's signal is per-producer and edge-published while the agent
845    /// process's `capture` watch is per-PROCESS and survives the connection —
846    /// so a process that was capturing when its session dropped
847    /// (`note_disconnected`'s `send(false)` never reaches the wire; the pump
848    /// returns at `!is_alive()`) reconnects under a NEW producer whose count is
849    /// 0 and whose `send_if_modified` emits nothing. It kept screencasting and
850    /// pushing JPEGs with nobody watching. Answering it on the ack costs no
851    /// extra round trip and resynchronises exactly when the process reappears.
852    pub fn desired_capture(&self) -> bool {
853        match self.watchers.lock() {
854            Ok(watchers) => *watchers > 0,
855            Err(poisoned) => *poisoned.into_inner() > 0,
856        }
857    }
858
859    /// A view started streaming. The first one turns capture on in the process.
860    pub fn start_capture(&self) {
861        self.set_watchers(|n| n + 1);
862    }
863
864    /// A view stopped streaming. The last one turns capture off.
865    pub fn stop_capture(&self) {
866        self.set_watchers(|n| n.saturating_sub(1));
867    }
868
869    /// Move the watcher count and publish the state it implies as ONE step,
870    /// under the count's own mutex.
871    ///
872    /// The count and the signal used to be two unsynchronized operations —
873    /// an atomic, then a `send` on the 0→1 / 1→0 edge — and nothing above
874    /// serialized them either: `stop_streamer_inner` deliberately drops the
875    /// view's capture mutex BEFORE calling `stop_capture`. One host closing
876    /// and reopening the drawer was enough to interleave them as
877    /// stop(count 1→0) … start(0→1, send true) … stop's send(false), leaving
878    /// the agent process not capturing while the view believed it was — a
879    /// drawer frozen on its snapshot frame that `ensure_streamer` would never
880    /// restart, because `capture.active` was true.
881    ///
882    /// Publishing the DERIVED state (`count > 0`) rather than only the edges
883    /// is what makes the result independent of arrival order: whichever call
884    /// takes the lock last publishes the state that matches the final count.
885    /// `send_if_modified` keeps that from costing wire traffic — an unchanged
886    /// value notifies nobody, so a second watcher still does not re-ask the
887    /// process to start capturing. `std::sync::Mutex` because both callers are
888    /// synchronous and nothing awaits inside.
889    fn set_watchers(&self, f: impl Fn(usize) -> usize) {
890        let mut watchers = match self.watchers.lock() {
891            Ok(watchers) => watchers,
892            Err(poisoned) => poisoned.into_inner(),
893        };
894        *watchers = f(*watchers);
895        let desired = *watchers > 0;
896        self.capture.send_if_modified(|current| {
897            let changed = *current != desired;
898            *current = desired;
899            changed
900        });
901    }
902
903    /// Bind a view to this producer so pushes reach it.
904    pub async fn attach_view(&self, view: &Arc<BrowserView>) {
905        let mut views = self.views.lock().await;
906        views.retain(|existing| existing.strong_count() > 0);
907        views.push(Arc::downgrade(view));
908    }
909
910    /// The views this producer backs beyond the newest
911    /// [`MAX_VIEWS_PER_PRODUCER`], oldest first — the ones a fresh
912    /// registration retires. `views` is in registration order, which is what
913    /// makes "oldest" answerable here at all: the daemon sees only opaque
914    /// conversation ids and cannot tell one turn's key from another
915    /// conversation's.
916    pub async fn views_past_the_cap(&self) -> Vec<Arc<BrowserView>> {
917        let mut views = self.views.lock().await;
918        views.retain(|view| view.strong_count() > 0);
919        if views.len() <= MAX_VIEWS_PER_PRODUCER {
920            return Vec::new();
921        }
922        let stale = views.len() - MAX_VIEWS_PER_PRODUCER;
923        views.iter().take(stale).filter_map(Weak::upgrade).collect()
924    }
925
926    /// Seed the cache before the first push — the register call carries the
927    /// process's current presentation so a view is never born empty when its
928    /// browser is not.
929    pub async fn set_presentation(&self, presentation: WirePresentation) {
930        self.cache(presentation).await;
931        self.sync_signin_attention().await;
932    }
933
934    /// The process pushed a presentation delta. Cache it, then let every view
935    /// re-read: the view's own dedup decides whether that is an event.
936    pub async fn push_presentation(&self, presentation: WirePresentation) {
937        self.cache(presentation).await;
938        self.sync_signin_attention().await;
939        for view in self.live_views().await {
940            view.refresh_presentation().await;
941        }
942    }
943
944    /// Install a presentation, refusing one OLDER than what is cached.
945    ///
946    /// This cache is not a display detail — `control_status` projects it, and
947    /// that is what the input gate answers from. Both writers ran
948    /// unconditionally, so a `browser.producer.register` carrying a snapshot
949    /// taken before its round trip could rewind a newer push that landed
950    /// during it: a user who had just taken control would have `owner` read
951    /// back as `Agent` and every click refused with "the agent holds control
952    /// of this browser". Its two siblings already respect monotonicity —
953    /// `note_disconnected` bumps the revision so the empty state never moves
954    /// backwards, and `BrowserView::publish_presentation` refuses an older
955    /// read for the same reason — so this closes the last unguarded writer.
956    ///
957    /// Equal revisions are content-identical by construction (the reducer
958    /// bumps only on real change), so `<` rather than `<=` keeps a re-push of
959    /// the current state working.
960    async fn cache(&self, presentation: WirePresentation) {
961        let mut last = self.last.lock().await;
962        if presentation.revision < last.revision {
963            return;
964        }
965        *last = presentation;
966    }
967
968    /// The process pushed a screencast frame.
969    ///
970    /// Only views somebody is actually WATCHING get one, and the last of them
971    /// gets the frame by move. A `WireFrame` is a base64 full-viewport JPEG, so
972    /// each clone is a memcpy of a few hundred KB — and this fanned out to
973    /// every view the producer had ever registered, watched or not. The
974    /// ordinary case (one drawer, on one conversation) is now zero clones.
975    ///
976    /// A skipped view's cursor does not advance, which is exactly right:
977    /// `subscribe` hands out the CURRENT cursor, so a drawer arriving later
978    /// starts from wherever the view is and detects gaps from there.
979    pub async fn push_frame(&self, frame: WireFrame) {
980        let mut watched = Vec::new();
981        for view in self.live_views().await {
982            if view.has_subscribers().await {
983                watched.push(view);
984            }
985        }
986        let Some(last) = watched.pop() else { return };
987        for view in watched {
988            view.emit_wire_frame(frame.clone()).await;
989        }
990        last.emit_wire_frame(frame).await;
991    }
992
993    /// Tell this process that a host-client connected or disconnected.
994    ///
995    /// A reverse call like the other three `agent.browser.*` methods, so it
996    /// travels the same path and the process answers the same way. Its result
997    /// is dropped: the daemon has nothing to do about a process that cannot
998    /// be told, and the caller — a disconnect sweep or an auth handshake —
999    /// must not wait on it. [`ProducerRegistry::broadcast_host_connected`] is
1000    /// what keeps that non-blocking.
1001    pub async fn push_host_connected(&self, connected: bool) {
1002        if !self.is_alive() {
1003            return;
1004        }
1005        self.host_desired.store(connected, Ordering::Release);
1006        // Serialized: the wire order is the lock order, so two transitions can
1007        // no longer land out of order. See `host_push`.
1008        let mut last = self.host_push.lock().await;
1009        // Re-read under the lock — a newer transition arriving while this task
1010        // waited supersedes the value it was spawned with, and sending the
1011        // stale one would tell the process the older truth.
1012        let connected = self.host_desired.load(Ordering::Acquire);
1013        if *last == Some(connected) {
1014            return;
1015        }
1016        match call_agent(
1017            &self.channel,
1018            "agent.browser.host_connected",
1019            json!({ "connected": connected }),
1020        )
1021        .await
1022        {
1023            Ok(_) => *last = Some(connected),
1024            Err(error) => tracing::debug!(
1025                agent_id = %self.agent_id,
1026                %error,
1027                "browser relay: could not tell the agent process about a host transition"
1028            ),
1029        }
1030    }
1031
1032    /// The process's connection dropped. Every call from here on is a clean
1033    /// error, and the views report an empty browser — which is the truth: the
1034    /// process is gone and its Chromium went with it.
1035    ///
1036    /// The views stay REGISTERED. When the supervisor restarts the process and
1037    /// it registers the same conversation again, that registration replaces
1038    /// this view through the ordinary `adopt` path, so a drawer that never
1039    /// unsubscribed follows the agent to its new process without the cursor
1040    /// moving backwards.
1041    pub async fn note_disconnected(&self) {
1042        self.alive.store(false, Ordering::Release);
1043        let _ = self.capture.send(false);
1044        {
1045            let mut last = self.last.lock().await;
1046            let revision = last.revision.saturating_add(1);
1047            *last = WirePresentation::empty();
1048            // Never let the revision move backwards: a client that treats it
1049            // as monotonic would read the reset as a change it already saw.
1050            last.revision = revision;
1051        }
1052        self.sync_signin_attention().await;
1053        for view in self.live_views().await {
1054            view.refresh_presentation().await;
1055        }
1056    }
1057
1058    async fn live_views(&self) -> Vec<Arc<BrowserView>> {
1059        let mut views = self.views.lock().await;
1060        views.retain(|view| view.strong_count() > 0);
1061        views.iter().filter_map(Weak::upgrade).collect()
1062    }
1063
1064    fn spawn_capture_pump(self: &Arc<Self>, mut rx: watch::Receiver<bool>) {
1065        let producer = Arc::downgrade(self);
1066        tokio::spawn(async move {
1067            // A `watch` collapses intermediate values, so a rapid
1068            // open/close/open settles on the LAST desired state rather than
1069            // racing two calls into the process out of order.
1070            // `undelivered` is what turns a logged failure into a retry: it
1071            // means "a desired state has not reached the process", so the loop
1072            // re-sends instead of parking on the next change.
1073            let mut undelivered = false;
1074            loop {
1075                if !undelivered && rx.changed().await.is_err() {
1076                    return;
1077                }
1078                let enabled = *rx.borrow_and_update();
1079                let Some(producer) = producer.upgrade() else {
1080                    return;
1081                };
1082                if !producer.is_alive() {
1083                    return;
1084                }
1085                let sent = call_agent(
1086                    &producer.channel,
1087                    "agent.browser.capture",
1088                    json!({ "enabled": enabled }),
1089                )
1090                .await;
1091                undelivered = match sent {
1092                    Ok(_) => false,
1093                    Err(e) => {
1094                        // RETRIED, not just logged. Nothing else re-arms this:
1095                        // for a relay view `start_capture` returns no task, so
1096                        // `ensure_streamer` sees `capture.active == true` (set
1097                        // the instant the count moved, regardless of whether
1098                        // the wire call landed) and returns early forever; and
1099                        // `set_watchers` publishes only on a CHANGE, so a
1100                        // second host subscribing sends nothing new. One
1101                        // `RELAY_CALL_TIMEOUT` against a busy agent therefore
1102                        // left the drawer on its subscribe-time snapshot with
1103                        // zero frames until the user closed and reopened it.
1104                        tracing::debug!(
1105                            agent_id = %producer.agent_id,
1106                            enabled,
1107                            error = %e,
1108                            "browser relay: capture request did not reach the agent process; retrying"
1109                        );
1110                        true
1111                    }
1112                };
1113                drop(producer);
1114                if undelivered {
1115                    // A newer desired state supersedes the retry; otherwise
1116                    // back off and send the same one again. Either way the
1117                    // value is re-read at the top, so a retry never delivers
1118                    // something the watch has already replaced.
1119                    tokio::select! {
1120                        changed = rx.changed() => {
1121                            if changed.is_err() {
1122                                return;
1123                            }
1124                        }
1125                        _ = tokio::time::sleep(CAPTURE_RETRY_BACKOFF) => {}
1126                    }
1127                }
1128            }
1129        });
1130    }
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Handlers — the agent-facing wire surface
1135// ---------------------------------------------------------------------------
1136
1137/// The agent identity bound to this connection by `session.auth`, or a clean
1138/// refusal. This is the whole authorization rule for `browser.producer.*`: an
1139/// agent may publish its own browser and nothing else.
1140async fn authorize_producer(session: &ClientSession) -> Result<String, String> {
1141    session.agent_id.lock().await.clone().ok_or_else(|| {
1142        "not authorized to use browser.producer.*: this connection is not a supervised agent \
1143         (session.auth { token, agent_id })"
1144            .to_string()
1145    })
1146}
1147
1148/// May this agent publish a browser for this conversation?
1149///
1150/// Two ways to be entitled, and both validate the SAME binding — an agent may
1151/// only ever claim a conversation it serves:
1152///
1153/// - **A live chat session for it**, which is the first registration: the turn
1154///   is running, and `chat_sessions` says which agent the daemon dispatched it
1155///   to. This is the only way a binding is ESTABLISHED.
1156/// - **A binding this agent already established**, which is re-registration.
1157///   Needed because `chat_sessions` is per TURN (dropped on the terminal
1158///   event) while a published browser outlives its run: without this, a
1159///   process whose daemon session dropped between turns could not restore its
1160///   own drawer until the user happened to send another message, and the
1161///   drawer would sit on "its browser is gone" pointing at a live browser.
1162///
1163/// A conversation nobody has ever served, and one served by somebody else, are
1164/// both refused — the second case identically to before. This is a liveness
1165/// relaxation, not an authorization one: the agent_id ↔ conversation binding
1166/// is still validated against the daemon's own record every time.
1167pub fn authorize_conversation_claim(
1168    conversation_id: &str,
1169    agent_id: &str,
1170    live_owner: Option<&str>,
1171    bound_owner: Option<&str>,
1172) -> Result<(), String> {
1173    match live_owner.or(bound_owner) {
1174        Some(owner) if owner == agent_id => Ok(()),
1175        Some(owner) => Err(format!(
1176            "conversation '{conversation_id}' is served by agent '{owner}', not '{agent_id}'"
1177        )),
1178        None => Err(format!(
1179            "conversation '{conversation_id}' is not an active chat session for agent \
1180             '{agent_id}' — register from inside the turn that serves it"
1181        )),
1182    }
1183}
1184
1185/// `browser.producer.register { conversation_id, presentation? }` — the
1186/// supervised process publishes its browser for one chat session.
1187///
1188/// Idempotent: the same process registering the same conversation again (every
1189/// turn does) keeps the existing view, its subscribers and its cursor. A
1190/// DIFFERENT process claiming the key replaces the view through `adopt`, which
1191/// carries subscribers and the cursor across.
1192pub async fn handle_producer_register(
1193    req: &JsonRpcMessage,
1194    session: &Arc<ClientSession>,
1195    state: &Arc<ServerState>,
1196) -> Result<Value, String> {
1197    let agent_id = authorize_producer(session).await?;
1198    let conversation_id = req
1199        .params
1200        .get("conversation_id")
1201        .and_then(Value::as_str)
1202        .map(str::trim)
1203        .filter(|id| !id.is_empty())
1204        .ok_or("browser.producer.register requires a non-empty { conversation_id }")?
1205        .to_string();
1206
1207    // A conversation may only be claimed by the agent actually serving it.
1208    // `chat_sessions` is populated BEFORE the daemon sends `agent.chat`, so a
1209    // registration from inside the turn always finds its entry; a
1210    // re-registration between turns is entitled by the binding that first
1211    // registration established. See `authorize_conversation_claim`.
1212    let live_owner = state
1213        .chat_sessions
1214        .lock()
1215        .await
1216        .get(&conversation_id)
1217        .map(|chat| chat.agent_id.clone());
1218    let bound_owner = state
1219        .browser_views
1220        .conversation_owner(&conversation_id)
1221        .await;
1222    authorize_conversation_claim(
1223        &conversation_id,
1224        &agent_id,
1225        live_owner.as_deref(),
1226        bound_owner.as_deref(),
1227    )?;
1228    state
1229        .browser_views
1230        .bind_conversation(&conversation_id, &agent_id)
1231        .await;
1232
1233    let producer = state
1234        .browser_views
1235        .producer_for(&session.client_id, &agent_id, &session.channel)
1236        .await;
1237    if let Some(presentation) = req.params.get("presentation") {
1238        match serde_json::from_value::<WirePresentation>(presentation.clone()) {
1239            Ok(presentation) => producer.set_presentation(presentation).await,
1240            Err(e) => {
1241                return Err(format!(
1242                    "browser.producer.register `presentation` is not a presentation object: {e}"
1243                ))
1244            }
1245        }
1246    }
1247    state
1248        .browser_views
1249        .register_relay(conversation_id.clone(), Arc::clone(&producer))
1250        .await;
1251
1252    // Task 7: the supervised process has no direct read of the daemon's
1253    // session set, so it learns "is a CarHost host-client connected right
1254    // now" from this acknowledgment — the natural existing round trip,
1255    // rather than a dedicated method. See
1256    // `assistant::browser_producer::BrowserProducer` for how it's applied
1257    // (and that module's own doc comment for the freshness bound this
1258    // implies: refreshed on every registration that reaches the daemon, not
1259    // continuously).
1260    Ok(json!({
1261        "ok": true,
1262        "conversation_id": conversation_id,
1263        "host_connected": state.any_host_connected().await,
1264        // And whether anything is actually watching this producer's browser.
1265        // The process's capture watch is per-PROCESS and survives a
1266        // reconnect, while this signal is per-producer and edge-published, so
1267        // without an authoritative answer here a process that was capturing
1268        // when its session dropped keeps screencasting under a fresh producer
1269        // that will never tell it otherwise. See `RelayProducer::desired_capture`.
1270        "capture": producer.desired_capture(),
1271    }))
1272}
1273
1274/// Intercept the two producer NOTIFICATIONS (`browser.producer.presentation`,
1275/// `browser.producer.frame`). Returns `true` when the frame was ours, so the
1276/// dispatcher skips a method-not-found reply to something that has no id —
1277/// the same shape as `try_forward_agent_chat_event`.
1278pub(crate) async fn try_handle_producer_push(
1279    parsed: &JsonRpcMessage,
1280    state: &Arc<ServerState>,
1281    session: &Arc<ClientSession>,
1282) -> bool {
1283    let Some(method) = parsed.method.as_deref() else {
1284        return false;
1285    };
1286    if method != "browser.producer.presentation" && method != "browser.producer.frame" {
1287        return false;
1288    }
1289    if !parsed.id.is_null() {
1290        // Has an id → a request, not a notification. Let the dispatcher
1291        // answer method-not-found rather than swallowing it.
1292        return false;
1293    }
1294    // Fall THROUGH to the auth gate on an unauthenticated connection, rather
1295    // than consuming the frame here.
1296    //
1297    // Nothing leaks either way — the lookup below is by `session.client_id`,
1298    // which an unauthenticated connection cannot have registered a producer
1299    // under, so this handler is already fail-closed. What consuming the frame
1300    // costs is the reject-AND-CLOSE property: an unauthenticated peer could
1301    // hold the socket open indefinitely, forcing a full JSON parse (tungstenite's
1302    // 64 MiB default is the only bound) and a registry lock acquisition per
1303    // frame. Returning false hands it to the gate, which answers and closes.
1304    if state.auth_token.get().is_some()
1305        && !session
1306            .authenticated
1307            .load(std::sync::atomic::Ordering::Acquire)
1308    {
1309        return false;
1310    }
1311    // Only a connection that has actually registered a producer has one; an
1312    // unregistered session's push finds nothing and is dropped.
1313    let Some(producer) = state.browser_views.producer(&session.client_id).await else {
1314        tracing::debug!(
1315            client_id = %session.client_id,
1316            method,
1317            "browser relay: push from a connection with no registered producer"
1318        );
1319        return true;
1320    };
1321
1322    if method == "browser.producer.presentation" {
1323        match serde_json::from_value::<WirePresentation>(
1324            parsed
1325                .params
1326                .get("presentation")
1327                .cloned()
1328                .unwrap_or(Value::Null),
1329        ) {
1330            Ok(presentation) => producer.push_presentation(presentation).await,
1331            Err(e) => tracing::debug!(
1332                error = %e,
1333                "browser relay: unparseable presentation push"
1334            ),
1335        }
1336    } else {
1337        match serde_json::from_value::<WireFrame>(
1338            parsed.params.get("frame").cloned().unwrap_or(Value::Null),
1339        ) {
1340            Ok(frame) if frame.jpeg_base64.len() > MAX_PRODUCER_FRAME_BYTES => {
1341                tracing::debug!(
1342                    client_id = %session.client_id,
1343                    bytes = frame.jpeg_base64.len(),
1344                    "browser relay: dropped an oversized producer frame"
1345                );
1346            }
1347            Ok(frame) => producer.push_frame(frame).await,
1348            Err(e) => tracing::debug!(error = %e, "browser relay: unparseable frame push"),
1349        }
1350    }
1351    true
1352}
1353
1354/// The producers currently attached, keyed by the agent connection's client id.
1355/// Lives beside the view registry because both are torn down on the same
1356/// disconnect boundary.
1357#[derive(Default)]
1358pub struct ProducerRegistry {
1359    producers: Mutex<HashMap<String, Arc<RelayProducer>>>,
1360    /// conversation → the agent that established the claim on it. Outlives
1361    /// both the chat session (per turn) and the producer (per connection),
1362    /// because it is what entitles that agent — and only that agent — to
1363    /// republish its browser after either goes away.
1364    bindings: Mutex<HashMap<String, String>>,
1365}
1366
1367impl ProducerRegistry {
1368    pub async fn get(&self, client_id: &str) -> Option<Arc<RelayProducer>> {
1369        self.producers.lock().await.get(client_id).cloned()
1370    }
1371
1372    /// The agent entitled to publish this conversation, if one established a
1373    /// claim on it.
1374    pub async fn conversation_owner(&self, conversation_id: &str) -> Option<String> {
1375        self.bindings.lock().await.get(conversation_id).cloned()
1376    }
1377
1378    /// Record a validated claim. Only ever called AFTER
1379    /// [`authorize_conversation_claim`] passed, so this can never widen who is
1380    /// entitled to a conversation — it only remembers what the live chat
1381    /// session already said.
1382    pub async fn bind_conversation(&self, conversation_id: &str, agent_id: &str) {
1383        self.bindings
1384            .lock()
1385            .await
1386            .insert(conversation_id.to_string(), agent_id.to_string());
1387    }
1388
1389    /// Drop a claim whose view has been retired past
1390    /// [`MAX_VIEWS_PER_PRODUCER`]. Never widens anything: an agent that wants
1391    /// this conversation back has to be serving a LIVE chat session for it,
1392    /// which is the same check that established the binding in the first place.
1393    pub async fn forget_binding(&self, conversation_id: &str) {
1394        self.bindings.lock().await.remove(conversation_id);
1395    }
1396
1397    /// The producer for this connection, created on first registration.
1398    pub async fn get_or_create(
1399        &self,
1400        client_id: &str,
1401        agent_id: &str,
1402        channel: &Arc<WsChannel>,
1403    ) -> Arc<RelayProducer> {
1404        let mut producers = self.producers.lock().await;
1405        Arc::clone(producers.entry(client_id.to_string()).or_insert_with(|| {
1406            RelayProducer::new(
1407                client_id.to_string(),
1408                agent_id.to_string(),
1409                Arc::clone(channel),
1410            )
1411        }))
1412    }
1413
1414    /// Tell every live producer that host connectivity changed.
1415    ///
1416    /// Called on the two transitions the daemon actually observes: a
1417    /// connection authenticating as the host client, and a host connection
1418    /// dropping. Producers cache the answer, so a push is what keeps that
1419    /// cache honest between registrations.
1420    pub async fn broadcast_host_connected(&self, connected: bool) {
1421        let producers: Vec<Arc<RelayProducer>> =
1422            self.producers.lock().await.values().cloned().collect();
1423        // Fire-and-forget, one task each. The callers are a disconnect sweep
1424        // and an auth handshake — neither may be held up for the relay
1425        // timeout by a process that has stopped answering, and one wedged
1426        // producer must not delay telling the others.
1427        for producer in producers {
1428            tokio::spawn(async move { producer.push_host_connected(connected).await });
1429        }
1430    }
1431
1432    /// The connection dropped: the producer is gone. Its views stay registered
1433    /// and report an empty browser until a replacement claims the key.
1434    pub async fn note_disconnected(&self, client_id: &str) {
1435        let producer = self.producers.lock().await.remove(client_id);
1436        if let Some(producer) = producer {
1437            producer.note_disconnected().await;
1438        }
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445    use crate::browser_view::{BrowserViewRegistry, WireOwner};
1446
1447    use crate::browser_view::BrowserViewEvent;
1448    use crate::session::WsSink;
1449    use futures::StreamExt;
1450
1451    fn agent_channel() -> (
1452        Arc<WsChannel>,
1453        std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1454    ) {
1455        let (channel, frames) = WsChannel::test_capture();
1456        (Arc::new(channel), frames)
1457    }
1458
1459    /// A host connection whose pushed `browser.view.event` frames a test can
1460    /// read, mirroring `browser_view::tests::capture_channel`.
1461    fn capture_channel() -> (
1462        Arc<WsChannel>,
1463        futures::channel::mpsc::UnboundedReceiver<Message>,
1464    ) {
1465        use futures::sink::SinkExt as _;
1466        let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
1467        let sink: WsSink =
1468            Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
1469        let channel = Arc::new(WsChannel {
1470            write: Mutex::new(sink),
1471            pending: Mutex::new(HashMap::new()),
1472            active_actions: Mutex::new(HashMap::new()),
1473            next_id: std::sync::atomic::AtomicU64::new(0),
1474        });
1475        (channel, rx)
1476    }
1477
1478    async fn next_event(
1479        rx: &mut futures::channel::mpsc::UnboundedReceiver<Message>,
1480    ) -> BrowserViewEvent {
1481        let frame = tokio::time::timeout(Duration::from_secs(2), rx.next())
1482            .await
1483            .expect("an event within the deadline")
1484            .expect("a frame");
1485        let text = match frame {
1486            Message::Text(text) => text.to_string(),
1487            other => panic!("expected a text frame, got {other:?}"),
1488        };
1489        let json: Value = serde_json::from_str(&text).unwrap();
1490        assert_eq!(json["method"], "browser.view.event");
1491        serde_json::from_value(json["params"].clone()).expect("a browser.view.event payload")
1492    }
1493
1494    /// Answer the next reverse call the daemon parked on `channel`, exactly as
1495    /// the supervised process's `DaemonClient` would: match the request id,
1496    /// resolve the oneshot. Returns the request it answered.
1497    async fn answer_next_call(
1498        channel: &Arc<WsChannel>,
1499        frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1500        result: Value,
1501    ) -> Value {
1502        for _ in 0..200 {
1503            let request = frames
1504                .lock()
1505                .unwrap()
1506                .iter()
1507                .filter_map(|text| serde_json::from_str::<Value>(text).ok())
1508                .find(|value| value.get("id").and_then(Value::as_str).is_some());
1509            if let Some(request) = request {
1510                let id = request["id"].as_str().unwrap().to_string();
1511                let waiter = channel.pending.lock().await.remove(&id);
1512                if let Some(waiter) = waiter {
1513                    let _ = waiter.send(car_proto::ToolExecuteResponse {
1514                        action_id: id,
1515                        output: Some(result),
1516                        error: None,
1517                        terminal: false,
1518                    });
1519                    frames.lock().unwrap().clear();
1520                    return request;
1521                }
1522            }
1523            tokio::time::sleep(Duration::from_millis(5)).await;
1524        }
1525        panic!("no reverse call arrived on the agent channel");
1526    }
1527
1528    /// Wait for a reverse call to be written, then forget the frame WITHOUT
1529    /// answering it — leaving that request pending so it times out. Used to
1530    /// park one call while another is issued and answered.
1531    async fn park_next_call(frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1532        for _ in 0..200 {
1533            let seen = frames
1534                .lock()
1535                .unwrap()
1536                .iter()
1537                .filter_map(|text| serde_json::from_str::<Value>(text).ok())
1538                .any(|value| value.get("id").and_then(Value::as_str).is_some());
1539            if seen {
1540                frames.lock().unwrap().clear();
1541                return;
1542            }
1543            tokio::time::sleep(Duration::from_millis(5)).await;
1544        }
1545        panic!("no reverse call arrived on the agent channel");
1546    }
1547
1548    fn presentation(owner: WireOwner, url: &str) -> WirePresentation {
1549        let mut wire = WirePresentation::empty();
1550        wire.revision = 3;
1551        wire.owner = owner;
1552        wire.url = Some(url.to_string());
1553        wire
1554    }
1555
1556    // -----------------------------------------------------------------
1557    // Sign-in attention: the relay twin (Parslee-ai/car#1040)
1558    // -----------------------------------------------------------------
1559
1560    /// A presentation at `revision`, optionally with a sign-in pending.
1561    fn presentation_at(revision: u64, pending_signin: Option<&str>) -> WirePresentation {
1562        let mut wire = WirePresentation::empty();
1563        wire.revision = revision;
1564        wire.owner = WireOwner::Agent;
1565        wire.pending_signin = pending_signin.map(str::to_string);
1566        wire.blackout_active = pending_signin.is_some();
1567        wire
1568    }
1569
1570    /// A registry whose relayed views report to a recorder, plus a live
1571    /// producer registered under `conv-1` — the whole supervised-agent path
1572    /// minus a real agent process.
1573    async fn relayed_view_watching_signin() -> (
1574        Arc<RelayProducer>,
1575        Arc<crate::browser_view::BrowserViewRegistry>,
1576        Arc<crate::browser_attention::RecordingAttention>,
1577    ) {
1578        let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1579        let recorder = Arc::new(crate::browser_attention::RecordingAttention::default());
1580        registry.set_signin_attention(recorder.clone());
1581        let (channel, _frames) = agent_channel();
1582        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
1583        registry
1584            .register_relay("conv-1", Arc::clone(&producer))
1585            .await;
1586        (producer, registry, recorder)
1587    }
1588
1589    /// The relay half of the headline. The reducer lives in the agent
1590    /// process, so the daemon has to DERIVE the transition from the
1591    /// presentation push it already receives — and that push is not gated on
1592    /// anybody watching the drawer, which is the entire reason this works
1593    /// with the drawer closed.
1594    #[tokio::test]
1595    async fn a_relayed_sign_in_notifies_from_the_presentation_push_alone() {
1596        let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1597
1598        producer.push_presentation(presentation_at(1, None)).await;
1599        assert!(
1600            recorder.kinds().is_empty(),
1601            "an ordinary presentation is not news"
1602        );
1603
1604        producer
1605            .push_presentation(presentation_at(
1606                2,
1607                Some("Sign in at https://example.com/login"),
1608            ))
1609            .await;
1610        assert_eq!(
1611            recorder.calls(),
1612            vec![(
1613                crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1614                Some("conv-1".to_string()),
1615                Some("Sign in at https://example.com/login".to_string()),
1616            )],
1617            "the view key and the agent's own prompt both travel"
1618        );
1619
1620        producer.push_presentation(presentation_at(3, None)).await;
1621        assert_eq!(
1622            recorder.kinds(),
1623            vec![
1624                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1625                crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1626            ],
1627            "and the agent process resolving it clears the badge"
1628        );
1629    }
1630
1631    /// `presentation_pump` republishes on every change and the 10-second
1632    /// sweep re-registers, so the SAME state arriving again must say nothing.
1633    /// This is the case that would otherwise banner an operator every few
1634    /// seconds for one sign-in.
1635    #[tokio::test]
1636    async fn republishing_the_same_pending_sign_in_says_nothing() {
1637        let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1638
1639        let pending = presentation_at(2, Some("Sign in at https://example.com/login"));
1640        for _ in 0..4 {
1641            producer.push_presentation(pending.clone()).await;
1642        }
1643        assert_eq!(
1644            recorder.kinds(),
1645            vec![crate::browser_attention::BROWSER_SIGNIN_NEEDED],
1646            "one wait is one notification, however many times it is republished"
1647        );
1648
1649        // A later revision that still has the sign-in up — the tab list moved
1650        // under it — is still not a transition.
1651        let mut moved = presentation_at(3, Some("Sign in at https://example.com/login"));
1652        moved.url = Some("https://example.com/login?step=2".into());
1653        producer.push_presentation(moved).await;
1654        assert_eq!(
1655            recorder.kinds(),
1656            vec![crate::browser_attention::BROWSER_SIGNIN_NEEDED]
1657        );
1658    }
1659
1660    #[tokio::test]
1661    async fn one_process_with_two_views_emits_one_needed_event() {
1662        let (producer, registry, recorder) = relayed_view_watching_signin().await;
1663        registry
1664            .register_relay("conv-2", Arc::clone(&producer))
1665            .await;
1666
1667        producer
1668            .push_presentation(presentation_at(2, Some("Sign in")))
1669            .await;
1670
1671        assert_eq!(
1672            recorder.calls(),
1673            vec![(
1674                crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1675                Some("conv-2".to_string()),
1676                Some("Sign in".to_string()),
1677            )],
1678            "attention is process-owned and routed through the newest view"
1679        );
1680        assert_eq!(registry.pending_signins().await.len(), 1);
1681    }
1682
1683    #[tokio::test]
1684    async fn a_new_conversation_does_not_steal_an_announced_wait() {
1685        let (producer, registry, recorder) = relayed_view_watching_signin().await;
1686        producer
1687            .push_presentation(presentation_at(2, Some("Sign in for chat one")))
1688            .await;
1689
1690        registry
1691            .register_relay("conv-2", Arc::clone(&producer))
1692            .await;
1693        assert_eq!(
1694            recorder.calls(),
1695            vec![(
1696                crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1697                Some("conv-1".to_string()),
1698                Some("Sign in for chat one".to_string()),
1699            )],
1700            "a later turn cannot move an active badge off the blocked chat"
1701        );
1702        assert_eq!(
1703            registry.pending_signins().await[0].conversation_id,
1704            "conv-1"
1705        );
1706
1707        producer.push_presentation(presentation_at(3, None)).await;
1708        producer
1709            .push_presentation(presentation_at(4, Some("Sign in for chat two")))
1710            .await;
1711        assert_eq!(
1712            recorder.calls().last().unwrap().1.as_deref(),
1713            Some("conv-2"),
1714            "after resolution the newest registered chat owns the next wait"
1715        );
1716    }
1717
1718    #[tokio::test]
1719    async fn changing_the_pending_prompt_refreshes_operator_attention() {
1720        let (producer, registry, recorder) = relayed_view_watching_signin().await;
1721        producer
1722            .push_presentation(presentation_at(2, Some("Sign in at A")))
1723            .await;
1724        producer
1725            .push_presentation(presentation_at(3, Some("Sign in at B")))
1726            .await;
1727
1728        assert_eq!(
1729            recorder.kinds(),
1730            vec![
1731                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1732                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1733            ]
1734        );
1735        assert_eq!(registry.pending_signins().await[0].message, "Sign in at B");
1736    }
1737
1738    /// The supervised process dying is a real ending: its views publish the
1739    /// empty presentation, which is a `pending -> none` transition. Without
1740    /// this the badge would outlive the process that raised it, with nothing
1741    /// left anywhere that could ever clear it.
1742    #[tokio::test]
1743    async fn the_agent_process_going_away_resolves_its_pending_sign_in() {
1744        let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1745        producer
1746            .push_presentation(presentation_at(2, Some("Sign in")))
1747            .await;
1748        producer.note_disconnected().await;
1749        assert_eq!(
1750            recorder.kinds(),
1751            vec![
1752                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1753                crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1754            ]
1755        );
1756    }
1757
1758    /// A registry with no sink installed — every other test in this crate,
1759    /// and every embedder with no daemon behind it — relays the presentation
1760    /// exactly as it did before, and announces nothing because there is
1761    /// nowhere to announce to.
1762    #[tokio::test]
1763    async fn a_registry_with_no_attention_sink_relays_but_announces_nothing() {
1764        let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1765        let (channel, _frames) = agent_channel();
1766        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
1767        let view = registry
1768            .register_relay("conv-1", Arc::clone(&producer))
1769            .await;
1770        producer
1771            .push_presentation(presentation_at(2, Some("Sign in")))
1772            .await;
1773        // The drawer's own orange strip is unaffected: it reads the relayed
1774        // presentation, not the attention route.
1775        assert_eq!(
1776            view.snapshot_for_test().await.0.pending_signin.as_deref(),
1777            Some("Sign in")
1778        );
1779        // But nothing was ANNOUNCED. `announced` is the record of what an
1780        // operator was actually told, and with no sink it never advances —
1781        // which is the only handle this test has on "no attention call
1782        // happened", and is what `pending_signins` projects.
1783        assert!(
1784            producer.signin_snapshot().await.is_none(),
1785            "no sink means nothing was ever announced"
1786        );
1787        assert!(registry.pending_signins().await.is_empty());
1788    }
1789
1790    /// One supervised process backs up to [`MAX_VIEWS_PER_PRODUCER`] views.
1791    /// Retiring the view that happens to own the attention route must not
1792    /// leave the process with no sink while it is still serving the others —
1793    /// a sign-in raised afterwards would fall through the `attention` guard
1794    /// and tell nobody until the next turn's `register_relay` reinstalled one.
1795    #[tokio::test]
1796    async fn retiring_the_routed_view_keeps_the_sink_for_the_views_that_remain() {
1797        let (producer, registry, recorder) = relayed_view_watching_signin().await;
1798        // A second turn registers, so the route moves to the newest view.
1799        registry
1800            .register_relay("conv-2", Arc::clone(&producer))
1801            .await;
1802
1803        // That newest view retires while `conv-1` is still served by this
1804        // same process.
1805        producer.detach_signin_attention(Some("conv-2")).await;
1806
1807        producer
1808            .push_presentation(presentation_at(2, Some("Sign in")))
1809            .await;
1810        assert_eq!(
1811            recorder.calls(),
1812            vec![(
1813                crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1814                Some("conv-1".to_string()),
1815                Some("Sign in".to_string()),
1816            )],
1817            "the route moves to a surviving view instead of nulling the sink"
1818        );
1819    }
1820
1821    /// A wait pins `conversation_id` to the turn that raised it while later
1822    /// turns move `latest_conversation_id` on ahead of it. When that pinned
1823    /// view is finally retired past the cap, the route must land on the
1824    /// NEWEST turn — the one the operator is actually looking at — and the
1825    /// wait must be re-raised there, because the browser is still blocked.
1826    #[tokio::test]
1827    async fn retiring_the_routed_view_hands_the_route_to_the_newest_turn() {
1828        let (producer, registry, recorder) = relayed_view_watching_signin().await;
1829        producer
1830            .push_presentation(presentation_at(
1831                2,
1832                Some("Sign in at https://example.com/login"),
1833            ))
1834            .await;
1835
1836        // Eight further turns. `set_signin_attention` keeps the announced wait
1837        // pinned to conv-1 and moves only `latest_conversation_id`.
1838        for turn in 2..=9 {
1839            registry
1840                .register_relay(format!("conv-{turn}"), Arc::clone(&producer))
1841                .await;
1842        }
1843        // conv-1 is now past MAX_VIEWS_PER_PRODUCER. `retire_views_past_the_cap`
1844        // is what reaches this in production.
1845        producer.detach_signin_attention(Some("conv-1")).await;
1846
1847        assert_eq!(
1848            recorder.calls(),
1849            vec![
1850                (
1851                    crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1852                    Some("conv-1".to_string()),
1853                    Some("Sign in at https://example.com/login".to_string()),
1854                ),
1855                (
1856                    crate::browser_attention::BROWSER_SIGNIN_RESOLVED.to_string(),
1857                    Some("conv-1".to_string()),
1858                    None,
1859                ),
1860                (
1861                    crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1862                    Some("conv-9".to_string()),
1863                    Some("Sign in at https://example.com/login".to_string()),
1864                ),
1865            ],
1866            "the badge moves to the newest turn, not the oldest survivor, and \
1867             the still-blocked browser is re-raised rather than left cleared"
1868        );
1869        assert_eq!(
1870            producer
1871                .signin_snapshot()
1872                .await
1873                .expect("the browser is still blocked")
1874                .conversation_id,
1875            "conv-9"
1876        );
1877        assert_eq!(
1878            registry.pending_signins().await[0].conversation_id,
1879            "conv-9",
1880            "and a host reconnecting mid-wait is pointed at the same turn"
1881        );
1882    }
1883
1884    /// The survivor set has to be read UNDER the attention lock. Read before
1885    /// it, a `register_relay` landing in the window was invisible here, and
1886    /// the sink was nulled with that new view live — the exact state the
1887    /// survivor check exists to prevent, reached by a race.
1888    #[tokio::test]
1889    async fn a_registration_landing_during_a_detach_still_leaves_a_live_sink() {
1890        let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1891        producer
1892            .push_presentation(presentation_at(2, Some("Sign in")))
1893            .await;
1894
1895        // A conv-2 view built on a throwaway producer, so making it live
1896        // below touches `views` only and never the lock the test is holding.
1897        let (scratch_channel, _scratch_frames) = agent_channel();
1898        let scratch_producer =
1899            RelayProducer::new("other-conn".into(), "car-assistant".into(), scratch_channel);
1900        let scratch_registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1901        let view_two = scratch_registry
1902            .register_relay("conv-2", Arc::clone(&scratch_producer))
1903            .await;
1904
1905        // Freeze the detach at its lock acquisition — precisely where the old
1906        // order had already read the survivors and found none.
1907        let guard = producer.signin_attention.lock().await;
1908        let detach = tokio::spawn({
1909            let producer = Arc::clone(&producer);
1910            async move { producer.detach_signin_attention(Some("conv-1")).await }
1911        });
1912        tokio::task::yield_now().await;
1913
1914        // conv-2 becomes live inside that window.
1915        producer.attach_view(&view_two).await;
1916        drop(guard);
1917        detach.await.unwrap();
1918
1919        assert_eq!(
1920            producer
1921                .signin_snapshot()
1922                .await
1923                .expect("a live view remains, so the wait keeps a route")
1924                .conversation_id,
1925            "conv-2"
1926        );
1927        assert_eq!(
1928            recorder.kinds(),
1929            vec![
1930                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1931                crate::browser_attention::BROWSER_SIGNIN_RESOLVED,
1932                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1933            ],
1934            "the badge moves to the surviving view instead of the sink being nulled"
1935        );
1936    }
1937
1938    /// The other half of the rule: when the retiring view IS the last one this
1939    /// producer serves, the sink goes with it.
1940    #[tokio::test]
1941    async fn retiring_the_last_view_detaches_the_sink() {
1942        let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1943        producer
1944            .push_presentation(presentation_at(2, Some("Sign in")))
1945            .await;
1946
1947        producer.detach_signin_attention(Some("conv-1")).await;
1948        assert_eq!(
1949            recorder.kinds(),
1950            vec![
1951                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1952                crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1953            ],
1954            "the wait it owned is resolved on the way out"
1955        );
1956
1957        producer
1958            .push_presentation(presentation_at(4, Some("Sign in again")))
1959            .await;
1960        assert_eq!(
1961            recorder.kinds(),
1962            vec![
1963                crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1964                crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1965            ],
1966            "nothing left to serve, so nothing left to announce"
1967        );
1968    }
1969
1970    /// The broadcast must not happen inside the lock every relayed input and
1971    /// every presentation push contends on. `HostState::record_event` awaits
1972    /// each `host.subscribe` socket in turn, bounded at 10s apiece, so holding
1973    /// `signin_attention` across it made N backpressured hosts an N x 10s
1974    /// stall on the very next push — for a call with nothing to announce.
1975    #[tokio::test]
1976    async fn a_stalled_broadcast_does_not_block_the_next_presentation() {
1977        struct BlockingAttention {
1978            entered: Arc<tokio::sync::Notify>,
1979            release: Arc<tokio::sync::Notify>,
1980        }
1981
1982        #[async_trait::async_trait]
1983        impl SignInAttention for BlockingAttention {
1984            async fn signin_needed(&self, _conversation_id: Option<&str>, _message: &str) {
1985                self.entered.notify_one();
1986                self.release.notified().await;
1987            }
1988            async fn signin_resolved(&self, _conversation_id: Option<&str>) {}
1989        }
1990
1991        let entered = Arc::new(tokio::sync::Notify::new());
1992        let release = Arc::new(tokio::sync::Notify::new());
1993        let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1994        registry.set_signin_attention(Arc::new(BlockingAttention {
1995            entered: Arc::clone(&entered),
1996            release: Arc::clone(&release),
1997        }));
1998        let (channel, _frames) = agent_channel();
1999        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2000        registry
2001            .register_relay("conv-1", Arc::clone(&producer))
2002            .await;
2003
2004        let blocked = tokio::spawn({
2005            let producer = Arc::clone(&producer);
2006            async move {
2007                producer
2008                    .push_presentation(presentation_at(2, Some("Sign in")))
2009                    .await
2010            }
2011        });
2012        // The announcement is now in flight and wedged on a host socket.
2013        entered.notified().await;
2014
2015        // Same pending prompt at a later revision: the republish case, which
2016        // has nothing to announce and must settle without waiting on it.
2017        let mut moved = presentation_at(3, Some("Sign in"));
2018        moved.url = Some("https://example.com/login?step=2".into());
2019        tokio::time::timeout(Duration::from_secs(5), producer.push_presentation(moved))
2020            .await
2021            .expect("a non-transition push must not queue behind a stalled broadcast");
2022
2023        release.notify_one();
2024        blocked.await.unwrap();
2025    }
2026
2027    #[tokio::test]
2028    async fn input_crosses_to_the_agent_process_as_a_reverse_call_and_returns_its_answer() {
2029        let (channel, frames) = agent_channel();
2030        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2031
2032        let relayed = tokio::spawn({
2033            let producer = Arc::clone(&producer);
2034            async move { producer.input(ViewInput::TabOpen).await }
2035        });
2036        let request =
2037            answer_next_call(&producer.channel, &frames, json!({ "tab_id": "tab-4" })).await;
2038
2039        assert_eq!(request["method"], "agent.browser.input");
2040        assert_eq!(request["params"]["op"], "tab_open");
2041        assert_eq!(relayed.await.unwrap().unwrap().as_deref(), Some("tab-4"));
2042    }
2043
2044    #[tokio::test]
2045    async fn the_agent_s_error_reaches_the_caller_verbatim() {
2046        let (channel, frames) = agent_channel();
2047        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2048
2049        let relayed = tokio::spawn({
2050            let producer = Arc::clone(&producer);
2051            async move { producer.input(ViewInput::Click { x: 1.0, y: 2.0 }).await }
2052        });
2053        // The agent answers with an error frame; the daemon's demuxer turns
2054        // that into `ToolExecuteResponse.error`.
2055        for _ in 0..200 {
2056            let id = frames
2057                .lock()
2058                .unwrap()
2059                .iter()
2060                .filter_map(|t| serde_json::from_str::<Value>(t).ok())
2061                .find_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string));
2062            if let Some(id) = id {
2063                if let Some(waiter) = producer.channel.pending.lock().await.remove(&id) {
2064                    let _ = waiter.send(car_proto::ToolExecuteResponse {
2065                        action_id: id,
2066                        output: None,
2067                        error: Some("no browser is running for this view".into()),
2068                        terminal: false,
2069                    });
2070                    break;
2071                }
2072            }
2073            tokio::time::sleep(Duration::from_millis(5)).await;
2074        }
2075        assert_eq!(
2076            relayed.await.unwrap().unwrap_err(),
2077            "no browser is running for this view"
2078        );
2079    }
2080
2081    #[tokio::test]
2082    async fn control_relays_the_transition_and_brings_its_effects_back() {
2083        let (channel, frames) = agent_channel();
2084        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2085
2086        let relayed = tokio::spawn({
2087            let producer = Arc::clone(&producer);
2088            async move { producer.control(ViewControl::HolderDisconnected).await }
2089        });
2090        let request = answer_next_call(
2091            &producer.channel,
2092            &frames,
2093            json!({
2094                "presentation": presentation(WireOwner::User, "https://x.test/"),
2095                "effects": [{ "effect": "start_grace_period" }],
2096            }),
2097        )
2098        .await;
2099
2100        assert_eq!(request["method"], "agent.browser.control");
2101        assert_eq!(request["params"]["action"], "holder_disconnected");
2102        assert_eq!(
2103            relayed.await.unwrap(),
2104            Ok((ControlOwner::User, vec![ControlEffect::StartGracePeriod])),
2105            "the daemon owns the clock, so the effect has to cross back — and the owner \
2106             comes from THIS response, not from a re-read of the cache"
2107        );
2108        // And the answer updated the cache the input path reads.
2109        assert_eq!(
2110            producer.control_status().await.owner,
2111            crate::assistant::browser_control::ControlOwner::User
2112        );
2113    }
2114
2115    #[tokio::test]
2116    async fn a_dead_producer_refuses_input_instead_of_hanging_on_a_call() {
2117        let (channel, _frames) = agent_channel();
2118        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2119        producer
2120            .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
2121            .await;
2122
2123        producer.note_disconnected().await;
2124
2125        assert!(!producer.is_alive());
2126        let err = producer
2127            .input(ViewInput::Navigate {
2128                url: "https://y.test".into(),
2129            })
2130            .await
2131            .unwrap_err();
2132        assert_eq!(err, PRODUCER_GONE);
2133        let cleared = producer.presentation().await;
2134        assert_eq!(cleared.owner, WireOwner::None);
2135        assert_eq!(cleared.url, None);
2136        assert!(
2137            cleared.revision > 3,
2138            "the revision never moves backwards, even when the browser vanishes"
2139        );
2140    }
2141
2142    /// `take_control` used to decide whether to record the control holder
2143    /// from a SECOND, independent `control_status()` read — and on the relay
2144    /// path that projects the producer's CACHED presentation, which the
2145    /// agent's own presentation pump also writes, unordered against the
2146    /// transition. A push carrying a pre-take snapshot landing in that window
2147    /// made the daemon believe nobody held a browser a person had just taken,
2148    /// which re-opened the hand-back gate from the recording side.
2149    #[tokio::test]
2150    async fn a_control_transition_reports_the_owner_from_its_own_answer() {
2151        let (channel, frames) = agent_channel();
2152        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2153
2154        let relayed = {
2155            let producer = Arc::clone(&producer);
2156            tokio::spawn(async move { producer.control(ViewControl::TakeControl).await })
2157        };
2158        answer_next_call(
2159            &producer.channel,
2160            &frames,
2161            json!({
2162                "presentation": presentation(WireOwner::User, "https://x.test/"),
2163                "effects": [],
2164            }),
2165        )
2166        .await;
2167        let (owner, _) = relayed.await.unwrap().unwrap();
2168        assert_eq!(
2169            owner,
2170            ControlOwner::User,
2171            "the transition landed on User, and that is what the caller must act on"
2172        );
2173
2174        // The agent's presentation pump now overwrites the cache with a
2175        // pre-take snapshot — the race that used to decide the holder.
2176        producer
2177            .push_presentation(presentation(WireOwner::Agent, "https://x.test/"))
2178            .await;
2179        assert_eq!(
2180            producer.control_status().await.owner,
2181            ControlOwner::Agent,
2182            "the cache really can go backwards, which is why it cannot be the decider"
2183        );
2184    }
2185
2186    /// `note_disconnect` armed the grace timer only from an effect returned
2187    /// by the very process it has just concluded is not replying — and
2188    /// `control_best_effort` swallows a relayed transition that never landed,
2189    /// returning none. The view then sat on `owner: user` with the blackout
2190    /// up, the connection provably gone, and nothing left to clear it.
2191    ///
2192    /// Driven on the relay path because that is the only one where the
2193    /// transition can genuinely fail: a local reducer never does.
2194    #[tokio::test(start_paused = true)]
2195    async fn a_disconnect_arms_the_grace_timer_even_when_the_transition_never_lands() {
2196        let (channel, frames) = agent_channel();
2197        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2198        let registry = BrowserViewRegistry::new(std::env::temp_dir());
2199        let view = registry
2200            .register_relay("conv-1", Arc::clone(&producer))
2201            .await;
2202
2203        // The person takes control while the process is still answering.
2204        let taking = {
2205            let view = Arc::clone(&view);
2206            tokio::spawn(async move { view.take_control_for_test("host-1").await })
2207        };
2208        answer_next_call(
2209            &producer.channel,
2210            &frames,
2211            json!({
2212                "presentation": presentation(WireOwner::User, "https://x.test/"),
2213                "effects": [],
2214            }),
2215        )
2216        .await;
2217        taking.await.unwrap().expect("take control");
2218
2219        // The process then stops answering, and the holder's connection drops.
2220        producer.note_disconnected().await;
2221        let before = view.grace_generation_for_test().await;
2222        view.note_disconnect("host-1", false).await;
2223        let after = view.grace_generation_for_test().await;
2224
2225        assert!(
2226            view.control_holder_for_test().await.is_none(),
2227            "the holder is cleared at disconnect — the connection is provably gone"
2228        );
2229        // Two bumps: one clearing the holder, one ARMING the timer.
2230        // `control_best_effort` returned no effects here (the process is
2231        // gone), so a single bump means no timer was spawned and nothing
2232        // would ever have reverted ownership.
2233        assert_eq!(
2234            after - before,
2235            2,
2236            "the timer must be armed from what the daemon knows, not from the reply of a \
2237             process that is not answering"
2238        );
2239    }
2240
2241    /// The grace timer is armed before the bounded relay reconciliation. A
2242    /// `take_control` landing while that detached call is pending must remain
2243    /// the newer generation and survive the stale expiry.
2244    #[tokio::test(start_paused = true)]
2245    async fn a_take_control_inside_the_disconnect_window_is_not_revoked_by_the_grace_timer() {
2246        let (channel, frames) = agent_channel();
2247        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2248        let registry = BrowserViewRegistry::new(std::env::temp_dir());
2249        let view = registry
2250            .register_relay("conv-1", Arc::clone(&producer))
2251            .await;
2252
2253        // host-1 holds control.
2254        let taking = {
2255            let view = Arc::clone(&view);
2256            tokio::spawn(async move { view.take_control_for_test("host-1").await })
2257        };
2258        answer_next_call(
2259            &producer.channel,
2260            &frames,
2261            json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2262        )
2263        .await;
2264        taking.await.unwrap().expect("take control");
2265
2266        // Its connection drops. The agent process stops answering; teardown
2267        // returns after arming recovery while reconciliation stays bounded in
2268        // a detached task.
2269        let disconnecting = {
2270            let view = Arc::clone(&view);
2271            tokio::spawn(async move { view.note_disconnect("host-1", false).await })
2272        };
2273        // Its `HolderDisconnected` is left PENDING, but disconnect teardown is
2274        // no longer parked behind it.
2275        park_next_call(&frames).await;
2276        disconnecting.await.unwrap();
2277
2278        // The app reconnects and takes control again INSIDE that window.
2279        let retaking = {
2280            let view = Arc::clone(&view);
2281            tokio::spawn(async move { view.take_control_for_test("host-2").await })
2282        };
2283        answer_next_call(
2284            &producer.channel,
2285            &frames,
2286            json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2287        )
2288        .await;
2289        retaking.await.unwrap().expect("re-take control");
2290        assert_eq!(
2291            view.control_holder_for_test().await.as_deref(),
2292            Some("host-2")
2293        );
2294
2295        // The detached relayed call finally times out; then the whole grace
2296        // window elapses.
2297        tokio::time::sleep(RELAY_CALL_TIMEOUT + Duration::from_secs(1)).await;
2298        tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
2299
2300        assert_eq!(
2301            view.control_holder_for_test().await.as_deref(),
2302            Some("host-2"),
2303            "a holder who took control legitimately must not be revoked by a timer armed \
2304             for the connection they replaced"
2305        );
2306    }
2307
2308    /// How many `agent.browser.control` calls carrying `action` reached the
2309    /// wire. Unanswered calls stay in `frames`, so this counts attempts, not
2310    /// completions — which is what a duplicate reconciliation looks like.
2311    fn count_control_calls(
2312        frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2313        action: &str,
2314    ) -> usize {
2315        frames
2316            .lock()
2317            .unwrap()
2318            .iter()
2319            .filter_map(|text| serde_json::from_str::<Value>(text).ok())
2320            .filter(|value| {
2321                value["method"] == "agent.browser.control" && value["params"]["action"] == action
2322            })
2323            .count()
2324    }
2325
2326    /// One disconnect, one grace-timer semantics — and teardown that does not
2327    /// park on a silent process.
2328    ///
2329    /// The ordinary drawer is BOTH the control holder and a subscriber, so
2330    /// disconnect teardown used to run `note_disconnect` AND
2331    /// `note_watcher_disconnect` against the same view. Each relays its own
2332    /// `holder_disconnected` and arms a grace timer — one with
2333    /// `require_unwatched`, one without — and each bumps the generation that
2334    /// retires the other's. Which semantics survived was decided by which
2335    /// relay reply landed last. `note_watcher_disconnect`'s `holder.is_some()`
2336    /// guard cannot catch this: `note_disconnect` clears the holder before it
2337    /// runs.
2338    ///
2339    /// Driven on the relay path because that is where the duplicate is
2340    /// observable — a second reconciliation is a second call on the wire — and
2341    /// where the second one was still INLINE, so teardown inherited its bound.
2342    /// Nothing answers here: a silent supervised process is the case both
2343    /// halves are about.
2344    #[tokio::test]
2345    async fn a_holder_that_was_also_watching_reconciles_its_disconnect_exactly_once() {
2346        let (channel, frames) = agent_channel();
2347        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2348        let registry = BrowserViewRegistry::new(std::env::temp_dir());
2349        let view = registry
2350            .register_relay("conv-1", Arc::clone(&producer))
2351            .await;
2352
2353        let taking = {
2354            let view = Arc::clone(&view);
2355            tokio::spawn(async move { view.take_control_for_test("host-1").await })
2356        };
2357        answer_next_call(
2358            &producer.channel,
2359            &frames,
2360            json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2361        )
2362        .await;
2363        taking.await.unwrap().expect("take control");
2364
2365        // The same connection is also the drawer watching this view — the
2366        // shape that makes `was_watching` and "held control" both true.
2367        let (host_channel, _host_rx) = capture_channel();
2368        view.subscribe_for_test("host-1", host_channel).await;
2369        frames.lock().unwrap().clear();
2370
2371        tokio::time::timeout(
2372            Duration::from_secs(2),
2373            registry.drop_subscriptions_for_client("host-1"),
2374        )
2375        .await
2376        .expect("teardown must not park on a relay call nothing is going to answer");
2377
2378        // Let the detached reconciliation reach the wire, then leave a
2379        // duplicate every chance to follow it.
2380        for _ in 0..200 {
2381            if count_control_calls(&frames, "holder_disconnected") > 0 {
2382                break;
2383            }
2384            tokio::time::sleep(Duration::from_millis(5)).await;
2385        }
2386        tokio::time::sleep(Duration::from_millis(200)).await;
2387        assert_eq!(
2388            count_control_calls(&frames, "holder_disconnected"),
2389            1,
2390            "exactly one path owns a disconnect — two arm two grace timers with different \
2391             semantics and let the relay decide which one survives"
2392        );
2393    }
2394
2395    /// One disconnect, one timer — but it has to be the timer that carries the
2396    /// WATCHER semantics, or collapsing the two calls quietly costs a person
2397    /// their sign-in.
2398    ///
2399    /// The ordinary drawer is both the control holder and the only subscriber.
2400    /// `note_watcher_disconnect` used to run second and arm
2401    /// `require_unwatched = true` last, so a drawer that came back inside the
2402    /// window suppressed the expiry — which is the whole point: the person
2403    /// returning is what says their sign-in window is still theirs. Arming only
2404    /// the holder variant instead never consults the subscriber set, so at
2405    /// t=`CONTROL_GRACE` it relays `GraceExpired`, the reducer resolves
2406    /// `pending_signin` as `signed_in: false`, and the page goes back to the
2407    /// agent while the person is mid-credential-entry.
2408    ///
2409    /// Driven on the relay path so the expiry is observable as a call on the
2410    /// wire rather than as local state.
2411    #[tokio::test(start_paused = true)]
2412    async fn a_drawer_that_returns_inside_the_window_cancels_its_own_grace_expiry() {
2413        let (channel, frames) = agent_channel();
2414        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2415        let registry = BrowserViewRegistry::new(std::env::temp_dir());
2416        let view = registry
2417            .register_relay("conv-1", Arc::clone(&producer))
2418            .await;
2419
2420        // host-1 presses Take control at a credential form.
2421        let taking = {
2422            let view = Arc::clone(&view);
2423            tokio::spawn(async move { view.take_control_for_test("host-1").await })
2424        };
2425        answer_next_call(
2426            &producer.channel,
2427            &frames,
2428            json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2429        )
2430        .await;
2431        taking.await.unwrap().expect("take control");
2432
2433        // The same connection is the drawer watching it — holder AND watcher,
2434        // the shape the de-duplication is about.
2435        let (host_channel, _host_rx) = capture_channel();
2436        view.subscribe_for_test("host-1", host_channel).await;
2437
2438        // Its daemon connection blips.
2439        registry.drop_subscriptions_for_client("host-1").await;
2440
2441        // CarHost reconnects inside the window and re-subscribes. The person is
2442        // back at the form.
2443        let (again, _again_rx) = capture_channel();
2444        view.subscribe_for_test("host-2", again).await;
2445
2446        frames.lock().unwrap().clear();
2447        tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
2448
2449        assert_eq!(
2450            count_control_calls(&frames, "grace_expired"),
2451            0,
2452            "a drawer watching inside the window is the person coming back — expiring under \
2453             them resolves their pending sign-in as failed and hands the page to the agent"
2454        );
2455    }
2456
2457    /// The id of the first reverse call carrying `action`, left PENDING.
2458    ///
2459    /// Unlike `park_next_call` this does not forget the request, so the test
2460    /// can answer it later — after something else has landed in between.
2461    async fn pending_call_id(
2462        frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2463        action: &str,
2464    ) -> String {
2465        for _ in 0..200 {
2466            let found = frames
2467                .lock()
2468                .unwrap()
2469                .iter()
2470                .filter_map(|text| serde_json::from_str::<Value>(text).ok())
2471                .find(|value| {
2472                    value["method"] == "agent.browser.control"
2473                        && value["params"]["action"] == action
2474                        && value.get("id").and_then(Value::as_str).is_some()
2475                })
2476                .map(|value| value["id"].as_str().unwrap().to_string());
2477            if let Some(id) = found {
2478                return id;
2479            }
2480            tokio::time::sleep(Duration::from_millis(5)).await;
2481        }
2482        panic!("no '{action}' reverse call arrived on the agent channel");
2483    }
2484
2485    /// Resolve one specific parked reverse call by request id.
2486    async fn answer_call_by_id(channel: &Arc<WsChannel>, id: &str, result: Value) {
2487        let waiter = channel
2488            .pending
2489            .lock()
2490            .await
2491            .remove(id)
2492            .expect("the parked call is still pending");
2493        let _ = waiter.send(car_proto::ToolExecuteResponse {
2494            action_id: id.to_string(),
2495            output: Some(result),
2496            error: None,
2497            terminal: false,
2498        });
2499    }
2500
2501    /// The slow-but-alive agent process: its `holder_disconnected` reply lands
2502    /// AFTER a legitimate `take_control`, and asks for a grace period.
2503    ///
2504    /// `note_disconnect` arms the clock synchronously, before relaying, so its
2505    /// generation precedes any re-take. That only holds if the disconnect arms
2506    /// EXACTLY ONCE — a second arming from this detached reply would capture
2507    /// the re-taker's generation and leave nothing but the holder check between
2508    /// host-2 and having control revoked under them. Nothing covered that
2509    /// before: every other disconnect test leaves the relayed call parked
2510    /// forever, so the reducer never returns effects and this path never runs.
2511    #[tokio::test(start_paused = true)]
2512    async fn an_answered_holder_disconnect_does_not_re_arm_over_a_landed_take_control() {
2513        let (channel, frames) = agent_channel();
2514        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2515        let registry = BrowserViewRegistry::new(std::env::temp_dir());
2516        let view = registry
2517            .register_relay("conv-1", Arc::clone(&producer))
2518            .await;
2519
2520        let taking = {
2521            let view = Arc::clone(&view);
2522            tokio::spawn(async move { view.take_control_for_test("host-1").await })
2523        };
2524        answer_next_call(
2525            &producer.channel,
2526            &frames,
2527            json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2528        )
2529        .await;
2530        taking.await.unwrap().expect("take control");
2531
2532        // host-1 drops. Teardown arms the clock and detaches reconciliation, so
2533        // this returns without waiting for the process.
2534        view.note_disconnect("host-1", false).await;
2535        let disconnect_id = pending_call_id(&frames, "holder_disconnected").await;
2536        // Cleared so `answer_next_call` below cannot answer the disconnect by
2537        // mistake; the request stays pending on the channel.
2538        frames.lock().unwrap().clear();
2539
2540        // host-2 legitimately takes control inside the window.
2541        let retaking = {
2542            let view = Arc::clone(&view);
2543            tokio::spawn(async move { view.take_control_for_test("host-2").await })
2544        };
2545        answer_next_call(
2546            &producer.channel,
2547            &frames,
2548            json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2549        )
2550        .await;
2551        retaking.await.unwrap().expect("re-take control");
2552        assert_eq!(
2553            view.control_holder_for_test().await.as_deref(),
2554            Some("host-2")
2555        );
2556        let generation_after_retake = view.grace_generation_for_test().await;
2557
2558        // Only NOW does the disconnect's reply arrive, asking for the clock.
2559        answer_call_by_id(
2560            &producer.channel,
2561            &disconnect_id,
2562            json!({
2563                "presentation": presentation(WireOwner::User, "https://x.test/"),
2564                "effects": [{ "effect": "start_grace_period" }],
2565            }),
2566        )
2567        .await;
2568        for _ in 0..200 {
2569            if view.grace_generation_for_test().await != generation_after_retake {
2570                break;
2571            }
2572            tokio::time::sleep(Duration::from_millis(5)).await;
2573        }
2574        assert_eq!(
2575            view.grace_generation_for_test().await,
2576            generation_after_retake,
2577            "the disconnect already armed its clock before relaying; re-arming here would \
2578             capture host-2's generation and disarm the stale-expiry check"
2579        );
2580
2581        tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
2582        assert_eq!(
2583            view.control_holder_for_test().await.as_deref(),
2584            Some("host-2"),
2585            "a holder who took control legitimately must survive a timer armed for the \
2586             connection they replaced, however late that connection's process answers"
2587        );
2588    }
2589
2590    /// The cache the INPUT GATE reads had no monotonicity guard, unlike both
2591    /// its siblings. A `browser.producer.register` carrying a snapshot taken
2592    /// before its round trip could therefore rewind a newer push that landed
2593    /// during it — and a user who had just taken control would have `owner`
2594    /// read back as `Agent`, so every click came back "the agent holds
2595    /// control of this browser".
2596    #[tokio::test]
2597    async fn an_older_presentation_never_rewinds_the_cache_the_input_gate_reads() {
2598        let (channel, _frames) = agent_channel();
2599        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2600
2601        let mut taken = presentation(WireOwner::User, "https://x.test/");
2602        taken.revision = 9;
2603        producer.push_presentation(taken).await;
2604        assert_eq!(producer.control_status().await.owner, ControlOwner::User);
2605
2606        // The in-flight register's pre-take snapshot lands afterwards.
2607        let mut stale = presentation(WireOwner::Agent, "https://x.test/");
2608        stale.revision = 8;
2609        producer.set_presentation(stale).await;
2610
2611        assert_eq!(
2612            producer.control_status().await.owner,
2613            ControlOwner::User,
2614            "the person still holds control, so their input must still be admitted"
2615        );
2616
2617        // A genuinely newer one still lands.
2618        let mut newer = presentation(WireOwner::Agent, "https://x.test/");
2619        newer.revision = 10;
2620        producer.push_presentation(newer).await;
2621        assert_eq!(producer.control_status().await.owner, ControlOwner::Agent);
2622    }
2623
2624    /// The count and the signal were two unsynchronized steps, so an
2625    /// unsubscribe/resubscribe pair could settle as
2626    /// stop(1→0) … start(0→1, send true) … stop's send(false): the agent
2627    /// process told to stop capturing while a view still had a live
2628    /// subscriber and believed capture was on, which `ensure_streamer` then
2629    /// never restarts. Publishing the DERIVED state under the count's own
2630    /// lock makes the settled signal a function of the settled count,
2631    /// whatever order the two calls land in.
2632    #[tokio::test]
2633    async fn the_capture_signal_always_matches_the_settled_watcher_count() {
2634        let (channel, _frames) = agent_channel();
2635        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2636        let mut capture = producer.capture.subscribe();
2637
2638        // The interleaving, in the order that used to lose: the LAST call to
2639        // land is the decrement, but the count it settles on is 1.
2640        producer.start_capture(); // T2's subscribe wins the race …
2641        producer.stop_capture(); // … and T1's stale stop lands after it.
2642        assert!(
2643            !*capture.borrow_and_update(),
2644            "count is 0, so capture is off"
2645        );
2646
2647        producer.start_capture();
2648        assert!(*capture.borrow_and_update());
2649        producer.start_capture();
2650        producer.stop_capture();
2651        assert!(
2652            *capture.borrow_and_update(),
2653            "one watcher remains, so the process must still be capturing"
2654        );
2655        producer.stop_capture();
2656        assert!(!*capture.borrow_and_update());
2657    }
2658
2659    #[tokio::test]
2660    async fn capture_is_asked_for_only_while_somebody_is_watching() {
2661        let (channel, frames) = agent_channel();
2662        let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2663        let mut capture = producer.capture.subscribe();
2664
2665        producer.start_capture();
2666        capture
2667            .changed()
2668            .await
2669            .expect("the first watcher changes capture");
2670        capture.borrow_and_update();
2671        let request = answer_next_call(&producer.channel, &frames, json!({ "ok": true })).await;
2672        assert_eq!(request["method"], "agent.browser.capture");
2673        assert_eq!(request["params"]["enabled"], true);
2674
2675        // A second watcher does not ask again. Two assertions, deliberately:
2676        // the watch channel says no signal was PUBLISHED, and `frames` says
2677        // nothing reached the WIRE. The second is the one the agent process
2678        // actually experiences, and a regression that published nothing while
2679        // still relaying a call would pass the first alone.
2680        producer.start_capture();
2681        assert!(
2682            !capture.has_changed().expect("capture sender remains live"),
2683            "capture is a producer-level state, not a per-subscriber one; no signal was published"
2684        );
2685        tokio::time::sleep(Duration::from_millis(30)).await;
2686        assert!(
2687            frames.lock().unwrap().is_empty(),
2688            "capture is a producer-level state, not a per-subscriber one"
2689        );
2690
2691        producer.stop_capture();
2692        assert!(
2693            !capture.has_changed().expect("capture sender remains live"),
2694            "one watcher left, one remains — no stop signal was published"
2695        );
2696        tokio::time::sleep(Duration::from_millis(30)).await;
2697        assert!(
2698            frames.lock().unwrap().is_empty(),
2699            "one watcher left, one remains — the process keeps capturing"
2700        );
2701
2702        producer.stop_capture();
2703        capture
2704            .changed()
2705            .await
2706            .expect("the final watcher changes capture");
2707        assert!(!*capture.borrow_and_update());
2708        let request = answer_next_call(&producer.channel, &frames, json!({ "ok": true })).await;
2709        assert_eq!(request["params"]["enabled"], false);
2710    }
2711
2712    /// A channel whose write half is CLOSED — the socket a wedged or dead
2713    /// process leaves behind. Every send fails immediately, which is the fast
2714    /// stand-in for the 30s timeout the same code path takes against a process
2715    /// that is merely silent.
2716    fn broken_channel() -> Arc<WsChannel> {
2717        use futures::sink::SinkExt as _;
2718        let (tx, rx) = futures::channel::mpsc::channel::<Message>(1);
2719        drop(rx);
2720        let sink: WsSink =
2721            Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
2722        Arc::new(WsChannel {
2723            write: Mutex::new(sink),
2724            pending: Mutex::new(HashMap::new()),
2725            active_actions: Mutex::new(HashMap::new()),
2726            next_id: std::sync::atomic::AtomicU64::new(0),
2727        })
2728    }
2729
2730    /// FINDING 1. A transition that never reached the process did not happen.
2731    /// Reporting success would leave the daemon believing the host is driving
2732    /// a browser whose own reducer still says the agent is — every later input
2733    /// refused, and a person told the blackout is up when the process never
2734    /// entered it.
2735    #[tokio::test]
2736    async fn a_control_transition_that_never_reached_the_process_fails_and_changes_nothing() {
2737        let (state, _temp) = test_state().await;
2738        let (host, _rx) = host_session(&state, "host-1").await;
2739        let producer = state
2740            .browser_views
2741            .producer_for("agent-conn", "car-assistant", &broken_channel())
2742            .await;
2743        producer
2744            .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
2745            .await;
2746        let view = state
2747            .browser_views
2748            .register_relay("conv-1", Arc::clone(&producer))
2749            .await;
2750
2751        let err = crate::browser_view::handle_take_control(
2752            &request(
2753                "browser.view.take_control",
2754                json!({ "conversation_id": "conv-1" }),
2755            ),
2756            &host,
2757            &state,
2758        )
2759        .await
2760        .expect_err("a transition that did not land must not report success");
2761        assert!(err.contains("unreachable"), "got: {err}");
2762
2763        // The daemon did NOT record the host as the control holder, and the
2764        // view still says what the process says: the agent is driving.
2765        assert_eq!(
2766            view.snapshot_for_test().await.0.owner,
2767            WireOwner::Agent,
2768            "the drawer must not be told the user took control of a browser that never heard"
2769        );
2770        let err = crate::browser_view::handle_input(
2771            crate::browser_view::InputOp::Click,
2772            &request(
2773                "browser.view.click",
2774                json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
2775            ),
2776            &host,
2777            &state,
2778        )
2779        .await
2780        .unwrap_err();
2781        assert!(
2782            err.contains("take_control"),
2783            "both sides agree the agent still holds it; got: {err}"
2784        );
2785    }
2786
2787    /// FINDING 4, daemon half. `chat_sessions` is per TURN, but a published
2788    /// browser outlives its run. A process whose daemon session dropped
2789    /// BETWEEN turns must be able to republish its own conversation — without
2790    /// that, the drawer sits on "its browser is gone" pointing at a live
2791    /// browser until the user happens to send another message.
2792    #[tokio::test]
2793    async fn a_reconnecting_process_republishes_between_turns_without_a_new_user_turn() {
2794        let (state, _temp) = test_state().await;
2795        let (agent, _frames) = agent_session(&state, "conn-1", "car-assistant", "conv-1").await;
2796        let (host, mut host_rx) = host_session(&state, "host-1").await;
2797
2798        handle_producer_register(
2799            &request(
2800                "browser.producer.register",
2801                json!({ "conversation_id": "conv-1",
2802                        "presentation": presentation(WireOwner::Agent, "https://x.test/") }),
2803            ),
2804            &agent,
2805            &state,
2806        )
2807        .await
2808        .unwrap();
2809        let subscribed = crate::browser_view::handle_subscribe(
2810            &request(
2811                "browser.view.subscribe",
2812                json!({ "conversation_id": "conv-1" }),
2813            ),
2814            &host,
2815            &state,
2816        )
2817        .await
2818        .unwrap();
2819        let before = subscribed["cursor"].as_u64().unwrap();
2820
2821        // The turn ends — the daemon drops the chat-session routing entry —
2822        // and only THEN does the process's session drop.
2823        state.chat_sessions.lock().await.remove("conv-1");
2824        state.remove_session("conn-1").await;
2825        assert_eq!(
2826            state
2827                .browser_views
2828                .get(Some("conv-1"))
2829                .await
2830                .unwrap()
2831                .snapshot_for_test()
2832                .await
2833                .0
2834                .owner,
2835            WireOwner::None,
2836            "the view reports the browser as gone while the process is away"
2837        );
2838
2839        // The process reconnects on a NEW connection and republishes on its
2840        // own, with no chat session anywhere.
2841        let (channel, _frames) = agent_channel();
2842        let reconnected = state.create_session("conn-2", channel).await.unwrap();
2843        *reconnected.agent_id.lock().await = Some("car-assistant".to_string());
2844        handle_producer_register(
2845            &request(
2846                "browser.producer.register",
2847                json!({ "conversation_id": "conv-1",
2848                        "presentation": presentation(WireOwner::Agent, "https://back.test/") }),
2849            ),
2850            &reconnected,
2851            &state,
2852        )
2853        .await
2854        .expect("the agent that established this conversation may republish it");
2855
2856        let view = state.browser_views.get(Some("conv-1")).await.unwrap();
2857        assert_eq!(
2858            view.snapshot_for_test().await.0.url.as_deref(),
2859            Some("https://back.test/")
2860        );
2861        assert_eq!(
2862            view.subscriber_count_for_test().await,
2863            1,
2864            "the drawer came across without re-subscribing"
2865        );
2866        let mut seen = next_event(&mut host_rx).await;
2867        while seen.cursor <= before {
2868            seen = next_event(&mut host_rx).await;
2869        }
2870        assert!(seen.cursor > before, "the cursor never moves backwards");
2871    }
2872
2873    /// FINDING 4, the authorization half: the relaxation is about LIVENESS,
2874    /// not about who is entitled. A conversation nobody served, and one served
2875    /// by somebody else, are refused exactly as before.
2876    #[tokio::test]
2877    async fn republishing_is_still_refused_to_an_agent_that_never_served_the_conversation() {
2878        let (state, _temp) = test_state().await;
2879        let (agent, _frames) = agent_session(&state, "conn-1", "car-assistant", "conv-1").await;
2880        handle_producer_register(
2881            &request(
2882                "browser.producer.register",
2883                json!({ "conversation_id": "conv-1" }),
2884            ),
2885            &agent,
2886            &state,
2887        )
2888        .await
2889        .unwrap();
2890        state.chat_sessions.lock().await.remove("conv-1");
2891
2892        // A different agent, with the turn long over, cannot take the drawer.
2893        let (channel, _frames) = agent_channel();
2894        let impostor = state.create_session("conn-x", channel).await.unwrap();
2895        *impostor.agent_id.lock().await = Some("some-other-agent".to_string());
2896        let err = handle_producer_register(
2897            &request(
2898                "browser.producer.register",
2899                json!({ "conversation_id": "conv-1" }),
2900            ),
2901            &impostor,
2902            &state,
2903        )
2904        .await
2905        .unwrap_err();
2906        assert!(
2907            err.contains("is served by agent 'car-assistant'"),
2908            "got: {err}"
2909        );
2910
2911        // And a conversation nobody ever served is still not claimable.
2912        let err = handle_producer_register(
2913            &request(
2914                "browser.producer.register",
2915                json!({ "conversation_id": "never-seen" }),
2916            ),
2917            &impostor,
2918            &state,
2919        )
2920        .await
2921        .unwrap_err();
2922        assert!(err.contains("not an active chat session"), "got: {err}");
2923    }
2924
2925    #[test]
2926    fn a_conversation_claim_needs_a_live_turn_or_a_binding_this_agent_established() {
2927        // First registration: entitled by the live turn.
2928        assert!(
2929            authorize_conversation_claim("c", "a", Some("a"), None).is_ok(),
2930            "the agent the daemon dispatched the turn to"
2931        );
2932        // Re-registration between turns: entitled by its own binding.
2933        assert!(authorize_conversation_claim("c", "a", None, Some("a")).is_ok());
2934        // A live turn for somebody else beats a stale binding.
2935        assert!(authorize_conversation_claim("c", "a", Some("b"), Some("a")).is_err());
2936        // Somebody else's binding, no live turn.
2937        assert!(authorize_conversation_claim("c", "a", None, Some("b")).is_err());
2938        // Never served by anyone.
2939        assert!(authorize_conversation_claim("c", "a", None, None).is_err());
2940    }
2941
2942    // ---- the codec ------------------------------------------------------
2943
2944    #[test]
2945    fn every_input_round_trips_through_the_wire() {
2946        for input in [
2947            ViewInput::Navigate {
2948                url: "https://x.test/".into(),
2949            },
2950            ViewInput::Click { x: 1.5, y: 2.5 },
2951            ViewInput::Type {
2952                text: "hello".into(),
2953            },
2954            ViewInput::Keypress {
2955                key: "Enter".into(),
2956                modifiers: vec![Modifier::Meta, Modifier::Shift],
2957            },
2958            ViewInput::Scroll { delta_y: -120 },
2959            ViewInput::Paste {
2960                text: "pasted".into(),
2961            },
2962            ViewInput::Back,
2963            ViewInput::Forward,
2964            ViewInput::Reload,
2965            ViewInput::TabOpen,
2966            ViewInput::TabClose {
2967                tab_id: "tab-2".into(),
2968            },
2969            ViewInput::TabSwitch {
2970                tab_id: "tab-3".into(),
2971            },
2972        ] {
2973            let wire = input_to_wire(&input);
2974            assert_eq!(
2975                input_from_wire(&wire).expect("decodes"),
2976                input,
2977                "round trip failed for {wire}"
2978            );
2979        }
2980    }
2981
2982    #[test]
2983    fn every_control_action_and_effect_round_trips() {
2984        for control in [
2985            ViewControl::TakeControl,
2986            ViewControl::HandBack,
2987            ViewControl::RunEnded,
2988            ViewControl::HolderDisconnected,
2989            ViewControl::GraceExpired,
2990        ] {
2991            assert_eq!(
2992                control_from_wire(control_to_wire(control)).unwrap(),
2993                control
2994            );
2995        }
2996        let effects = vec![
2997            ControlEffect::StartGracePeriod,
2998            ControlEffect::SignInResolved { signed_in: true },
2999        ];
3000        assert_eq!(effects_from_wire(&effects_to_wire(&effects)), effects);
3001    }
3002
3003    #[test]
3004    fn an_unknown_effect_is_dropped_rather_than_failing_the_transition() {
3005        let wire = json!([{ "effect": "teleport" }, { "effect": "start_grace_period" }]);
3006        assert_eq!(
3007            effects_from_wire(&wire),
3008            vec![ControlEffect::StartGracePeriod]
3009        );
3010    }
3011
3012    #[test]
3013    fn an_unknown_input_op_is_a_clean_error() {
3014        let err = input_from_wire(&json!({ "op": "read_dom" })).unwrap_err();
3015        assert!(err.contains("unknown agent.browser.input op"), "got: {err}");
3016    }
3017
3018    // ---- registry -------------------------------------------------------
3019
3020    #[tokio::test]
3021    async fn a_registered_conversation_resolves_to_the_process_s_browser() {
3022        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3023        let (channel, _frames) = agent_channel();
3024        let producer = registry
3025            .producer_for("agent-conn", "car-assistant", &channel)
3026            .await;
3027        producer
3028            .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
3029            .await;
3030        let view = registry
3031            .register_relay("conv-1", Arc::clone(&producer))
3032            .await;
3033
3034        let found = registry.get(Some("conv-1")).await.expect("registered");
3035        assert!(Arc::ptr_eq(&view, &found));
3036        let (snapshot, _) = found.snapshot_for_test().await;
3037        assert_eq!(snapshot.url.as_deref(), Some("https://x.test/"));
3038        assert_eq!(snapshot.owner, WireOwner::Agent);
3039    }
3040
3041    #[tokio::test]
3042    async fn re_registering_the_same_conversation_is_a_no_op_for_the_drawer() {
3043        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3044        let (channel, _frames) = agent_channel();
3045        let producer = registry
3046            .producer_for("agent-conn", "car-assistant", &channel)
3047            .await;
3048        let first = registry
3049            .register_relay("conv-1", Arc::clone(&producer))
3050            .await;
3051        // Every turn registers again; the drawer must not be churned for it.
3052        let second = registry
3053            .register_relay("conv-1", Arc::clone(&producer))
3054            .await;
3055        assert!(
3056            Arc::ptr_eq(&first, &second),
3057            "the same process re-claiming its own conversation keeps the view"
3058        );
3059    }
3060
3061    #[tokio::test]
3062    async fn one_process_backs_every_conversation_it_registers() {
3063        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3064        let (channel, _frames) = agent_channel();
3065        let producer = registry
3066            .producer_for("agent-conn", "car-assistant", &channel)
3067            .await;
3068        registry
3069            .register_relay("conv-1", Arc::clone(&producer))
3070            .await;
3071        registry
3072            .register_relay("conv-2", Arc::clone(&producer))
3073            .await;
3074
3075        producer
3076            .push_presentation(presentation(WireOwner::Agent, "https://shared.test/"))
3077            .await;
3078
3079        for key in ["conv-1", "conv-2"] {
3080            let view = registry.get(Some(key)).await.expect("registered");
3081            assert_eq!(
3082                view.snapshot_for_test().await.0.url.as_deref(),
3083                Some("https://shared.test/"),
3084                "a supervised process has ONE browser; both of its conversations show it"
3085            );
3086        }
3087    }
3088
3089    /// The isolation the outcomes ask for: two conversations served by two
3090    /// DIFFERENT agents are two processes with two browsers, and neither
3091    /// drawer ever sees the other's page or frames.
3092    #[tokio::test]
3093    async fn two_processes_two_conversations_never_see_each_other() {
3094        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3095        let (channel_a, _frames_a) = agent_channel();
3096        let (channel_b, _frames_b) = agent_channel();
3097        let alpha = registry
3098            .producer_for("conn-a", "agent-alpha", &channel_a)
3099            .await;
3100        let beta = registry
3101            .producer_for("conn-b", "agent-beta", &channel_b)
3102            .await;
3103        let view_a = registry.register_relay("conv-a", Arc::clone(&alpha)).await;
3104        let view_b = registry.register_relay("conv-b", Arc::clone(&beta)).await;
3105
3106        let (host_a, mut rx_a) = capture_channel();
3107        let (host_b, mut rx_b) = capture_channel();
3108        view_a.subscribe_for_test("host-1", host_a).await;
3109        view_b.subscribe_for_test("host-1", host_b).await;
3110
3111        alpha
3112            .push_presentation(presentation(WireOwner::Agent, "https://alpha.test/"))
3113            .await;
3114        beta.push_presentation(presentation(WireOwner::User, "https://beta.test/"))
3115            .await;
3116        alpha
3117            .push_frame(WireFrame {
3118                jpeg_base64: "QQ==".into(),
3119                width: 800,
3120                height: 600,
3121                device_pixel_ratio: 1.0,
3122                captured_at: 0.0,
3123            })
3124            .await;
3125
3126        assert_eq!(
3127            view_a.snapshot_for_test().await.0.url.as_deref(),
3128            Some("https://alpha.test/")
3129        );
3130        assert_eq!(
3131            view_b.snapshot_for_test().await.0.url.as_deref(),
3132            Some("https://beta.test/")
3133        );
3134
3135        // Alpha's drawer saw alpha's page and alpha's frame, in that order.
3136        match next_event(&mut rx_a).await.payload {
3137            crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
3138                assert_eq!(presentation.url.as_deref(), Some("https://alpha.test/"));
3139            }
3140            crate::browser_view::BrowserViewPayload::Frame { .. } => {
3141                panic!("expected alpha's presentation first")
3142            }
3143        }
3144        match next_event(&mut rx_a).await.payload {
3145            crate::browser_view::BrowserViewPayload::Frame { frame } => {
3146                assert_eq!(frame.jpeg_base64, "QQ==");
3147            }
3148            crate::browser_view::BrowserViewPayload::Presentation { .. } => {
3149                panic!("expected alpha's frame")
3150            }
3151        }
3152        // Beta's drawer saw exactly one event — its own presentation. Alpha's
3153        // frame never reached it.
3154        match next_event(&mut rx_b).await.payload {
3155            crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
3156                assert_eq!(presentation.url.as_deref(), Some("https://beta.test/"));
3157                assert_eq!(presentation.owner, WireOwner::User);
3158            }
3159            crate::browser_view::BrowserViewPayload::Frame { .. } => {
3160                panic!("beta's drawer must never receive alpha's frame")
3161            }
3162        }
3163        assert!(
3164            tokio::time::timeout(Duration::from_millis(100), rx_b.next())
3165                .await
3166                .is_err(),
3167            "nothing else crossed between the two conversations"
3168        );
3169    }
3170
3171    #[tokio::test]
3172    async fn a_restarted_process_replaces_the_view_and_carries_the_drawer_across() {
3173        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3174        let (first_channel, _first_frames) = agent_channel();
3175        let first = registry
3176            .producer_for("agent-conn-1", "car-assistant", &first_channel)
3177            .await;
3178        let view = registry.register_relay("conv-1", Arc::clone(&first)).await;
3179        let (host, mut host_rx) = capture_channel();
3180        view.subscribe_for_test("host-1", host).await;
3181        first
3182            .push_presentation(presentation(WireOwner::Agent, "https://x.test/"))
3183            .await;
3184        let before = next_event(&mut host_rx).await.cursor;
3185
3186        // The process dies and the supervisor restarts it: a NEW connection,
3187        // so a new producer, claiming the same conversation.
3188        registry.note_producer_disconnected("agent-conn-1").await;
3189        let (second_channel, _second_frames) = agent_channel();
3190        let second = registry
3191            .producer_for("agent-conn-2", "car-assistant", &second_channel)
3192            .await;
3193        second
3194            .set_presentation(presentation(
3195                WireOwner::Agent,
3196                "https://after-restart.test/",
3197            ))
3198            .await;
3199        let replacement = registry.register_relay("conv-1", Arc::clone(&second)).await;
3200
3201        assert!(
3202            !Arc::ptr_eq(&view, &replacement),
3203            "a different process is a different producer, so a different view"
3204        );
3205        assert_eq!(
3206            replacement.subscriber_count_for_test().await,
3207            1,
3208            "the drawer came across without re-subscribing"
3209        );
3210        // Assert every intermediate cursor instead of fast-forwarding until
3211        // one happens to exceed `before`.
3212        let mut previous_cursor = before;
3213        loop {
3214            let seen = next_event(&mut host_rx).await;
3215            assert_eq!(
3216                seen.cursor,
3217                previous_cursor + 1,
3218                "the cursor sequence must be contiguous and monotonic"
3219            );
3220            previous_cursor = seen.cursor;
3221            if matches!(
3222                seen.payload,
3223                crate::browser_view::BrowserViewPayload::Presentation { ref presentation }
3224                    if presentation.url.as_deref() == Some("https://after-restart.test/")
3225            ) {
3226                break;
3227            }
3228        }
3229        assert_eq!(
3230            replacement.snapshot_for_test().await.0.url.as_deref(),
3231            Some("https://after-restart.test/")
3232        );
3233    }
3234
3235    #[tokio::test]
3236    async fn a_pushed_frame_reaches_the_drawer_with_the_next_cursor() {
3237        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3238        let (channel, _frames) = agent_channel();
3239        let producer = registry
3240            .producer_for("agent-conn", "car-assistant", &channel)
3241            .await;
3242        let view = registry
3243            .register_relay("conv-1", Arc::clone(&producer))
3244            .await;
3245        let (host, mut host_rx) = capture_channel();
3246        let (_, cursor) = view.subscribe_for_test("host-1", host).await;
3247
3248        producer
3249            .push_frame(WireFrame {
3250                jpeg_base64: "AQID".into(),
3251                width: 1920,
3252                height: 1080,
3253                device_pixel_ratio: 2.0,
3254                captured_at: 1.5,
3255            })
3256            .await;
3257
3258        let event = next_event(&mut host_rx).await;
3259        assert_eq!(event.cursor, cursor + 1);
3260        assert_eq!(event.conversation_id.as_deref(), Some("conv-1"));
3261        match event.payload {
3262            crate::browser_view::BrowserViewPayload::Frame { frame } => {
3263                assert_eq!(frame.jpeg_base64, "AQID");
3264                assert_eq!(frame.width, 1920);
3265                assert_eq!(frame.device_pixel_ratio, 2.0);
3266            }
3267            crate::browser_view::BrowserViewPayload::Presentation { .. } => {
3268                panic!("expected a frame event")
3269            }
3270        }
3271    }
3272
3273    /// `agents.chat`'s `session_id` is minted fresh PER TURN, so "one view per
3274    /// conversation" was in practice one view per turn: a long-lived
3275    /// `car do --serve` process accumulated a `BrowserView`, a registry entry
3276    /// and a binding for every turn it had ever served, and every screencast
3277    /// frame was cloned into all of them. A registration retires the older
3278    /// ones — replacement, at the same recency the agent side republishes at.
3279    #[tokio::test]
3280    async fn a_registration_retires_this_producer_s_oldest_views() {
3281        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3282        let (channel, _frames) = agent_channel();
3283        let producer = registry
3284            .producer_for("agent-conn", "car-assistant", &channel)
3285            .await;
3286
3287        // One turn per key, exactly as `register_conversation` does.
3288        for turn in 0..MAX_VIEWS_PER_PRODUCER {
3289            let key = format!("turn-{turn}");
3290            registry.bind_conversation(&key, "car-assistant").await;
3291            registry.register_relay(key, Arc::clone(&producer)).await;
3292        }
3293        assert!(
3294            registry.get(Some("turn-0")).await.is_some(),
3295            "precondition: nothing is retired while the producer is within its cap"
3296        );
3297
3298        // One more turn is one too many.
3299        registry.bind_conversation("turn-8", "car-assistant").await;
3300        registry
3301            .register_relay("turn-8", Arc::clone(&producer))
3302            .await;
3303
3304        assert!(
3305            registry.get(Some("turn-0")).await.is_none(),
3306            "the oldest turn's view must be retired, not accumulated"
3307        );
3308        assert!(
3309            registry.conversation_owner("turn-0").await.is_none(),
3310            "and its binding with it — nothing can re-register a key past the cap"
3311        );
3312        assert!(
3313            registry.get(Some("turn-1")).await.is_some(),
3314            "only what is PAST the cap goes"
3315        );
3316        assert!(registry.get(Some("turn-8")).await.is_some());
3317    }
3318
3319    /// Retiring is not deleting. A drawer still watching an older key keeps it
3320    /// — `release_if_idle` refuses to drop a view anybody is subscribed to —
3321    /// which is what preserves the candidate chain's one-turn fallback and the
3322    /// restart-adoption behaviour.
3323    #[tokio::test]
3324    async fn a_retired_view_somebody_is_watching_survives() {
3325        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3326        let (channel, _frames) = agent_channel();
3327        let producer = registry
3328            .producer_for("agent-conn", "car-assistant", &channel)
3329            .await;
3330
3331        let watched = registry
3332            .register_relay("turn-0", Arc::clone(&producer))
3333            .await;
3334        let (host, _rx) = capture_channel();
3335        watched.subscribe_for_test("host-1", host).await;
3336
3337        for turn in 1..=MAX_VIEWS_PER_PRODUCER {
3338            registry
3339                .register_relay(format!("turn-{turn}"), Arc::clone(&producer))
3340                .await;
3341        }
3342
3343        assert!(
3344            registry.get(Some("turn-0")).await.is_some(),
3345            "a view the drawer is subscribed to is never taken out from under it"
3346        );
3347    }
3348
3349    /// A frame is a base64 full-viewport JPEG, so every fan-out clone is a
3350    /// memcpy of a few hundred KB — and it went to every view the producer had
3351    /// ever registered, watched or not.
3352    #[tokio::test]
3353    async fn a_frame_only_reaches_views_somebody_is_watching() {
3354        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3355        let (channel, _frames) = agent_channel();
3356        let producer = registry
3357            .producer_for("agent-conn", "car-assistant", &channel)
3358            .await;
3359        let unwatched = registry
3360            .register_relay("turn-0", Arc::clone(&producer))
3361            .await;
3362        let watched = registry
3363            .register_relay("turn-1", Arc::clone(&producer))
3364            .await;
3365        let (host, mut host_rx) = capture_channel();
3366        let (_snapshot, cursor) = watched.subscribe_for_test("host-1", host).await;
3367        let (_, unwatched_cursor) = unwatched.snapshot_for_test().await;
3368
3369        producer
3370            .push_frame(WireFrame {
3371                jpeg_base64: "AQID".into(),
3372                width: 8,
3373                height: 8,
3374                device_pixel_ratio: 1.0,
3375                captured_at: 0.5,
3376            })
3377            .await;
3378
3379        let event = next_event(&mut host_rx).await;
3380        assert_eq!(event.cursor, cursor + 1, "the watched view is served");
3381        let (_, after) = unwatched.snapshot_for_test().await;
3382        assert_eq!(
3383            after, unwatched_cursor,
3384            "a view nobody is watching pays nothing — not even a cursor bump"
3385        );
3386    }
3387
3388    /// The restart-adoption guarantee, which is about a view somebody is
3389    /// WATCHING: it stays registered so a restarted process replaces it in
3390    /// place and the drawer follows across without re-subscribing.
3391    #[tokio::test]
3392    async fn a_disconnected_producer_is_dropped_from_the_registry_and_clears_its_views() {
3393        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3394        let (channel, _frames) = agent_channel();
3395        let producer = registry
3396            .producer_for("agent-conn", "car-assistant", &channel)
3397            .await;
3398        producer
3399            .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
3400            .await;
3401        let view = registry
3402            .register_relay("conv-1", Arc::clone(&producer))
3403            .await;
3404        let (host, _rx) = capture_channel();
3405        view.subscribe_for_test("host-1", host).await;
3406
3407        registry.note_producer_disconnected("agent-conn").await;
3408
3409        assert!(registry.producer("agent-conn").await.is_none());
3410        let view = registry
3411            .get(Some("conv-1"))
3412            .await
3413            .expect("the view stays so a restarted process can replace it");
3414        let (snapshot, _) = view.snapshot_for_test().await;
3415        assert_eq!(snapshot.owner, WireOwner::None);
3416        assert_eq!(snapshot.url, None);
3417    }
3418
3419    /// The other half, and the leak. `release_if_idle` returns early on
3420    /// `!run_ended`, and `run_ended` was set only by the IN-DAEMON run-end
3421    /// guard — so a relay view could never be released by any path. Each
3422    /// retired conversation permanently retained a map entry, its
3423    /// `RelayProducer`, and that producer's `Arc<WsChannel>`: the write half
3424    /// of a dead socket, whose file descriptor could then never close.
3425    ///
3426    /// Nobody is watching this one, so nothing is owed to a restart.
3427    #[tokio::test]
3428    async fn a_disconnected_producer_s_unwatched_views_are_released_with_their_socket() {
3429        let registry = BrowserViewRegistry::new(std::env::temp_dir());
3430        let (channel, _frames) = agent_channel();
3431        let producer = registry
3432            .producer_for("agent-conn", "car-assistant", &channel)
3433            .await;
3434        let weak = Arc::downgrade(&producer);
3435        registry
3436            .register_relay("conv-1", Arc::clone(&producer))
3437            .await;
3438        drop(producer);
3439
3440        registry.note_producer_disconnected("agent-conn").await;
3441
3442        assert!(
3443            registry.get(Some("conv-1")).await.is_none(),
3444            "an unwatched view for a process that is gone must not stay registered"
3445        );
3446        assert!(
3447            weak.upgrade().is_none(),
3448            "and the producer — with the dead connection's WsChannel — must actually be released"
3449        );
3450    }
3451
3452    // ---- the dispatcher-facing handlers, end to end --------------------
3453
3454    async fn test_state() -> (Arc<ServerState>, tempfile::TempDir) {
3455        let temp = tempfile::tempdir().unwrap();
3456        let state = Arc::new(ServerState::with_config(
3457            crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
3458        ));
3459        (state, temp)
3460    }
3461
3462    fn request(method: &str, params: Value) -> JsonRpcMessage {
3463        JsonRpcMessage {
3464            jsonrpc: "2.0".to_string(),
3465            id: json!(1),
3466            method: Some(method.to_string()),
3467            params,
3468            result: None,
3469            error: None,
3470        }
3471    }
3472
3473    fn notification(method: &str, params: Value) -> JsonRpcMessage {
3474        JsonRpcMessage {
3475            jsonrpc: "2.0".to_string(),
3476            id: Value::Null,
3477            method: Some(method.to_string()),
3478            params,
3479            result: None,
3480            error: None,
3481        }
3482    }
3483
3484    /// An attached supervised agent mid-turn on `conversation`: the session
3485    /// binding `session.auth { agent_id }` makes, plus the `chat_sessions`
3486    /// routing entry `agents.chat` creates before it dispatches the turn.
3487    async fn agent_session(
3488        state: &Arc<ServerState>,
3489        client_id: &str,
3490        agent_id: &str,
3491        conversation: &str,
3492    ) -> (
3493        Arc<ClientSession>,
3494        std::sync::Arc<std::sync::Mutex<Vec<String>>>,
3495    ) {
3496        let (channel, frames) = agent_channel();
3497        let session = state.create_session(client_id, channel).await.unwrap();
3498        *session.agent_id.lock().await = Some(agent_id.to_string());
3499        state.chat_sessions.lock().await.insert(
3500            conversation.to_string(),
3501            crate::session::ChatSession {
3502                agent_id: agent_id.to_string(),
3503                host_client_id: "host-1".to_string(),
3504                created_at: 0,
3505                local_cancel: None,
3506            },
3507        );
3508        (session, frames)
3509    }
3510
3511    async fn host_session(
3512        state: &Arc<ServerState>,
3513        client_id: &str,
3514    ) -> (
3515        Arc<ClientSession>,
3516        futures::channel::mpsc::UnboundedReceiver<Message>,
3517    ) {
3518        let (channel, rx) = capture_channel();
3519        let session = state.create_session(client_id, channel).await.unwrap();
3520        session
3521            .is_host
3522            .store(true, std::sync::atomic::Ordering::Release);
3523        (session, rx)
3524    }
3525
3526    #[tokio::test]
3527    async fn a_connection_that_is_not_a_supervised_agent_cannot_publish_a_browser() {
3528        let (state, _temp) = test_state().await;
3529        let (session, _rx) = host_session(&state, "host-1").await;
3530
3531        let err = handle_producer_register(
3532            &request(
3533                "browser.producer.register",
3534                json!({ "conversation_id": "conv-1" }),
3535            ),
3536            &session,
3537            &state,
3538        )
3539        .await
3540        .unwrap_err();
3541        assert!(err.contains("not a supervised agent"), "got: {err}");
3542        assert!(state.browser_views.get(Some("conv-1")).await.is_none());
3543    }
3544
3545    #[tokio::test]
3546    async fn an_agent_cannot_claim_a_conversation_it_is_not_serving() {
3547        let (state, _temp) = test_state().await;
3548        let (session, _frames) =
3549            agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3550
3551        // Someone else's conversation.
3552        state.chat_sessions.lock().await.insert(
3553            "conv-other".to_string(),
3554            crate::session::ChatSession {
3555                agent_id: "some-other-agent".to_string(),
3556                host_client_id: "host-1".to_string(),
3557                created_at: 0,
3558                local_cancel: None,
3559            },
3560        );
3561        let err = handle_producer_register(
3562            &request(
3563                "browser.producer.register",
3564                json!({ "conversation_id": "conv-other" }),
3565            ),
3566            &session,
3567            &state,
3568        )
3569        .await
3570        .unwrap_err();
3571        assert!(
3572            err.contains("is served by agent 'some-other-agent'"),
3573            "got: {err}"
3574        );
3575
3576        // And a conversation nobody is serving.
3577        let err = handle_producer_register(
3578            &request(
3579                "browser.producer.register",
3580                json!({ "conversation_id": "ghost" }),
3581            ),
3582            &session,
3583            &state,
3584        )
3585        .await
3586        .unwrap_err();
3587        assert!(err.contains("not an active chat session"), "got: {err}");
3588        assert!(state.browser_views.get(Some("ghost")).await.is_none());
3589    }
3590
3591    /// The whole hop, end to end through the real handlers: a supervised
3592    /// process registers its browser for the conversation it is serving, a
3593    /// Command Deck subscribes by that conversation id, the process pushes,
3594    /// and the drawer receives it.
3595    #[tokio::test]
3596    async fn the_drawer_subscribes_by_conversation_and_receives_the_process_s_pushes() {
3597        let (state, _temp) = test_state().await;
3598        let (agent, agent_frames) =
3599            agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3600        let (host, mut host_rx) = host_session(&state, "host-1").await;
3601
3602        let out = handle_producer_register(
3603            &request(
3604                "browser.producer.register",
3605                json!({
3606                    "conversation_id": "conv-1",
3607                    "presentation": presentation(WireOwner::Agent, "https://x.test/"),
3608                }),
3609            ),
3610            &agent,
3611            &state,
3612        )
3613        .await
3614        .expect("the agent may publish the conversation it serves");
3615        assert_eq!(out["ok"], true);
3616
3617        // The drawer's own surface, unchanged, now reaches into the process.
3618        let snapshot = crate::browser_view::handle_subscribe(
3619            &request(
3620                "browser.view.subscribe",
3621                json!({ "conversation_id": "conv-1" }),
3622            ),
3623            &host,
3624            &state,
3625        )
3626        .await
3627        .expect("a supervised agent's browser is subscribable by conversation");
3628        assert_eq!(snapshot["standing_session"], false);
3629        assert_eq!(snapshot["presentation"]["url"], "https://x.test/");
3630        assert_eq!(snapshot["presentation"]["owner"], "agent");
3631        let cursor = snapshot["cursor"].as_u64().unwrap();
3632
3633        // Somebody is watching now — and only now does the process get asked
3634        // to capture, so a browser nobody has open pays for no screencast and
3635        // no WS traffic.
3636        let capture = answer_next_call(&agent.channel, &agent_frames, json!({ "ok": true })).await;
3637        assert_eq!(capture["method"], "agent.browser.capture");
3638        assert_eq!(capture["params"]["enabled"], true);
3639
3640        // The process pushes a presentation delta and a frame.
3641        assert!(
3642            try_handle_producer_push(
3643                &notification(
3644                    "browser.producer.presentation",
3645                    json!({ "presentation": presentation(WireOwner::Agent, "https://moved.test/") }),
3646                ),
3647                &state,
3648                &agent,
3649            )
3650            .await
3651        );
3652        let event = next_event(&mut host_rx).await;
3653        assert_eq!(event.cursor, cursor + 1);
3654        match event.payload {
3655            crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
3656                assert_eq!(presentation.url.as_deref(), Some("https://moved.test/"));
3657            }
3658            crate::browser_view::BrowserViewPayload::Frame { .. } => {
3659                panic!("expected a presentation event")
3660            }
3661        }
3662    }
3663
3664    /// Task 7: the supervised process has no direct read of the daemon's
3665    /// session set, so `browser.producer.register`'s acknowledgment is the
3666    /// channel it learns "is a host connected" through. This binds that this
3667    /// existing round trip actually carries the real, live answer both ways
3668    /// — not a stale or hardcoded one.
3669    #[tokio::test]
3670    async fn producer_register_reports_whether_a_host_is_currently_connected() {
3671        let (state, _temp) = test_state().await;
3672        let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3673
3674        // No host connected yet.
3675        let out = handle_producer_register(
3676            &request(
3677                "browser.producer.register",
3678                json!({ "conversation_id": "conv-1" }),
3679            ),
3680            &agent,
3681            &state,
3682        )
3683        .await
3684        .unwrap();
3685        assert_eq!(out["host_connected"], false);
3686
3687        // A host connects — the DAEMON's own answer changes immediately
3688        // (this is `handle_producer_register`, driven directly; the process
3689        // side's own freshness bound — refreshed only when it actually
3690        // calls this again — is documented and tested separately in
3691        // `assistant::browser_producer`).
3692        let (_host, _host_rx) = host_session(&state, "host-1").await;
3693        let out = handle_producer_register(
3694            &request(
3695                "browser.producer.register",
3696                json!({ "conversation_id": "conv-1" }),
3697            ),
3698            &agent,
3699            &state,
3700        )
3701        .await
3702        .unwrap();
3703        assert_eq!(out["host_connected"], true);
3704    }
3705
3706    /// How many `agent.browser.host_connected` pushes reached the wire, and
3707    /// what the last one said.
3708    fn host_connected_calls(
3709        frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
3710    ) -> (usize, Option<Value>) {
3711        let seen: Vec<Value> = frames
3712            .lock()
3713            .unwrap()
3714            .iter()
3715            .filter_map(|text| serde_json::from_str::<Value>(text).ok())
3716            .filter(|value| value["method"] == "agent.browser.host_connected")
3717            .collect();
3718        let last = seen
3719            .last()
3720            .map(|value| value["params"]["connected"].clone());
3721        (seen.len(), last)
3722    }
3723
3724    /// `remove_session` fanned a `host_connected` reverse call to EVERY
3725    /// producer on every disconnect — agent and CLI connections included,
3726    /// none of which can change host connectivity. Gating that on the removed
3727    /// session having actually held the host role is only correct if a real
3728    /// host removal still broadcasts, so both halves are asserted here.
3729    #[tokio::test]
3730    async fn only_a_host_removal_tells_the_producers_that_host_connectivity_changed() {
3731        let (state, _temp) = test_state().await;
3732        let (agent, frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3733        handle_producer_register(
3734            &request(
3735                "browser.producer.register",
3736                json!({ "conversation_id": "conv-1" }),
3737            ),
3738            &agent,
3739            &state,
3740        )
3741        .await
3742        .unwrap();
3743
3744        let (_host, _host_rx) = host_session(&state, "host-1").await;
3745        let (other_channel, _other_rx) = capture_channel();
3746        state
3747            .create_session("other-1", other_channel)
3748            .await
3749            .unwrap();
3750        frames.lock().unwrap().clear();
3751
3752        // A non-host connection going away cannot have changed the answer.
3753        state
3754            .remove_session("other-1")
3755            .await
3756            .expect("the non-host session was registered");
3757        tokio::time::sleep(Duration::from_millis(100)).await;
3758        assert_eq!(
3759            host_connected_calls(&frames).0,
3760            0,
3761            "a non-host disconnect must not fan a reverse call to every producer"
3762        );
3763
3764        // The last host going away does — and carries the post-removal truth.
3765        state
3766            .remove_session("host-1")
3767            .await
3768            .expect("the host session was registered");
3769        for _ in 0..200 {
3770            if host_connected_calls(&frames).0 > 0 {
3771                break;
3772            }
3773            tokio::time::sleep(Duration::from_millis(5)).await;
3774        }
3775        let (count, connected) = host_connected_calls(&frames);
3776        assert_eq!(
3777            count, 1,
3778            "removing the last host must still tell the producers"
3779        );
3780        assert_eq!(
3781            connected,
3782            Some(json!(false)),
3783            "the session is already out of `sessions`, so the broadcast reads the \
3784             post-removal truth"
3785        );
3786    }
3787
3788    #[tokio::test]
3789    async fn a_push_from_a_connection_with_no_producer_is_consumed_and_dropped() {
3790        let (state, _temp) = test_state().await;
3791        let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3792
3793        // Recognized (so the dispatcher does not answer a notification with
3794        // method-not-found) but nothing happens: no producer, no view.
3795        assert!(
3796            try_handle_producer_push(
3797                &notification(
3798                    "browser.producer.frame",
3799                    json!({ "frame": { "jpeg_base64": "AQ==", "width": 1, "height": 1,
3800                                       "device_pixel_ratio": 1.0, "captured_at": 0.0 } }),
3801                ),
3802                &state,
3803                &agent,
3804            )
3805            .await
3806        );
3807        assert!(state.browser_views.get(Some("conv-1")).await.is_none());
3808    }
3809
3810    #[tokio::test]
3811    async fn a_producer_request_with_an_id_is_left_to_the_dispatcher() {
3812        let (state, _temp) = test_state().await;
3813        let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3814        assert!(
3815            !try_handle_producer_push(
3816                &request("browser.producer.presentation", json!({})),
3817                &state,
3818                &agent,
3819            )
3820            .await,
3821            "a frame with an id is a request; it must get a real reply, not be swallowed"
3822        );
3823    }
3824
3825    /// The drawer's input path, on a relayed browser: the control gate is
3826    /// applied in the daemon against the pushed presentation, and only then
3827    /// does the call cross to the process.
3828    #[tokio::test]
3829    async fn input_reaches_the_process_only_after_the_control_gate_passes() {
3830        let (state, _temp) = test_state().await;
3831        let (agent, agent_frames) =
3832            agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3833        let (host, _host_rx) = host_session(&state, "host-1").await;
3834
3835        handle_producer_register(
3836            &request(
3837                "browser.producer.register",
3838                json!({
3839                    "conversation_id": "conv-1",
3840                    "presentation": presentation(WireOwner::Agent, "https://x.test/"),
3841                }),
3842            ),
3843            &agent,
3844            &state,
3845        )
3846        .await
3847        .unwrap();
3848
3849        // The agent is driving: refused in the daemon, nothing crosses.
3850        let click = request(
3851            "browser.view.click",
3852            json!({ "conversation_id": "conv-1", "x": 4.0, "y": 5.0 }),
3853        );
3854        let err = crate::browser_view::handle_input(
3855            crate::browser_view::InputOp::Click,
3856            &click,
3857            &host,
3858            &state,
3859        )
3860        .await
3861        .unwrap_err();
3862        assert!(err.contains("take_control"), "got: {err}");
3863        assert!(
3864            agent_frames.lock().unwrap().is_empty(),
3865            "a refused input must never reach the agent process"
3866        );
3867
3868        // Take control — which itself is a relayed transition — then click.
3869        let taking = tokio::spawn({
3870            let state = Arc::clone(&state);
3871            let host = Arc::clone(&host);
3872            async move {
3873                crate::browser_view::handle_take_control(
3874                    &request(
3875                        "browser.view.take_control",
3876                        json!({ "conversation_id": "conv-1" }),
3877                    ),
3878                    &host,
3879                    &state,
3880                )
3881                .await
3882            }
3883        });
3884        let request_frame = answer_next_call(
3885            &agent.channel,
3886            &agent_frames,
3887            json!({
3888                "presentation": presentation(WireOwner::User, "https://x.test/"),
3889                "effects": [],
3890            }),
3891        )
3892        .await;
3893        assert_eq!(request_frame["method"], "agent.browser.control");
3894        assert_eq!(request_frame["params"]["action"], "take_control");
3895        assert_eq!(
3896            taking.await.unwrap().unwrap()["presentation"]["owner"],
3897            "user"
3898        );
3899
3900        let clicking = tokio::spawn({
3901            let state = Arc::clone(&state);
3902            let host = Arc::clone(&host);
3903            async move {
3904                crate::browser_view::handle_input(
3905                    crate::browser_view::InputOp::Click,
3906                    &click,
3907                    &host,
3908                    &state,
3909                )
3910                .await
3911            }
3912        });
3913        let request_frame = answer_next_call(&agent.channel, &agent_frames, json!({})).await;
3914        assert_eq!(request_frame["method"], "agent.browser.input");
3915        assert_eq!(request_frame["params"]["op"], "click");
3916        assert_eq!(request_frame["params"]["x"], 4.0);
3917        assert_eq!(clicking.await.unwrap().unwrap()["ok"], true);
3918    }
3919}