use std::collections::{HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
use car_browser::{FrameReceiver, ScreencastFrame};
use car_ffi_common::proxy::DaemonClient;
use serde_json::{json, Value};
use tokio::sync::{watch, Mutex};
use crate::assistant::browser_tools::{BrowserTools, SharedHostConnected};
use crate::browser_relay::{control_from_wire, effects_to_wire, input_from_wire};
use crate::browser_view::{WireFrame, WirePresentation};
pub const MAX_KNOWN_CONVERSATIONS: usize = 8;
pub const REPUBLISH_INTERVAL: Duration = Duration::from_secs(10);
const FRAME_PUSH_TIMEOUT: Duration = Duration::from_secs(5);
const INPUT_QUEUE_TIMEOUT: Duration =
Duration::from_secs(crate::browser_relay::RELAY_CALL_TIMEOUT.as_secs() / 2);
pub struct BrowserProducer {
client: Arc<DaemonClient>,
tools: Arc<BrowserTools>,
registered: Mutex<HashSet<String>>,
known: Mutex<VecDeque<String>>,
input_order: Mutex<()>,
turns: AtomicUsize,
capture: watch::Sender<bool>,
capture_pushes: AtomicUsize,
host_connected: Arc<AtomicBool>,
}
impl BrowserProducer {
pub fn install(client: Arc<DaemonClient>, tools: Arc<BrowserTools>) -> Arc<Self> {
let (capture, capture_rx) = watch::channel(false);
let host_connected = Arc::new(AtomicBool::new(false));
tools.set_host_connectivity(Arc::new(SharedHostConnected(Arc::clone(&host_connected))));
let producer = Arc::new(Self {
client,
tools: Arc::clone(&tools),
input_order: Mutex::new(()),
registered: Mutex::new(HashSet::new()),
known: Mutex::new(VecDeque::new()),
turns: AtomicUsize::new(0),
capture,
capture_pushes: AtomicUsize::new(0),
host_connected,
});
let client = Arc::clone(&producer.client);
for (method, kind) in [
("agent.browser.input", Relayed::Input),
("agent.browser.control", Relayed::Control),
("agent.browser.capture", Relayed::Capture),
("agent.browser.host_connected", Relayed::HostConnected),
] {
let producer = Arc::clone(&producer);
client.register_handler(method, move |params: Value| {
let producer = Arc::clone(&producer);
async move { producer.handle(kind, ¶ms).await }
});
}
tokio::spawn(presentation_pump(
Arc::downgrade(&producer),
Arc::clone(&tools),
));
tokio::spawn(frame_pump(Arc::downgrade(&producer), tools, capture_rx));
tokio::spawn(republish_pump(Arc::downgrade(&producer)));
producer
}
pub async fn register_conversation(&self, conversation_id: &str) {
{
let mut known = self.known.lock().await;
if let Some(at) = known.iter().position(|id| id == conversation_id) {
known.remove(at);
}
known.push_back(conversation_id.to_string());
while known.len() > MAX_KNOWN_CONVERSATIONS {
known.pop_front();
}
}
let presentation = self.presentation().await;
self.publish_conversation(conversation_id, presentation)
.await;
}
async fn publish_conversation(&self, conversation_id: &str, presentation: WirePresentation) {
self.registered.lock().await.remove(conversation_id);
let capture_pushes = self.capture_pushes.load(Ordering::Acquire);
let result = self
.client
.call(
"browser.producer.register",
json!({
"conversation_id": conversation_id,
"presentation": presentation,
}),
)
.await;
match result {
Ok(ack) => {
self.registered
.lock()
.await
.insert(conversation_id.to_string());
self.apply_register_ack(&ack, capture_pushes);
}
Err(e) => tracing::debug!(
conversation_id,
error = %e,
"browser producer: could not publish this browser to the drawer"
),
}
}
fn apply_register_ack(&self, ack: &Value, capture_pushes_before: usize) {
if let Some(host_connected) = ack.get("host_connected").and_then(Value::as_bool) {
self.host_connected.store(host_connected, Ordering::Release);
}
if self.capture_pushes.load(Ordering::Acquire) != capture_pushes_before {
return;
}
if let Some(capture) = ack.get("capture").and_then(Value::as_bool) {
let _ = self.capture.send(capture);
}
}
pub async fn resync(&self) {
if !self.host_connected.load(Ordering::Acquire) && !self.registered.lock().await.is_empty()
{
return;
}
let known = self.known.lock().await.clone();
if known.is_empty() {
return;
}
let presentation = self.presentation().await;
for conversation_id in &known {
self.publish_conversation(conversation_id, presentation.clone())
.await;
}
}
pub fn note_turn_started(&self) {
self.turns.fetch_add(1, Ordering::AcqRel);
}
pub async fn note_turn_ended(&self) {
let previous = self
.turns
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
Some(n.saturating_sub(1))
})
.unwrap_or(0);
if previous != 1 {
return;
}
let (presentation, _effects) = crate::browser_view::ViewControl::RunEnded
.apply(&self.tools)
.await;
self.push_presentation(WirePresentation::from(&presentation))
.await;
}
async fn presentation(&self) -> WirePresentation {
WirePresentation::from(&self.tools.presentation().await)
}
async fn is_registered(&self) -> bool {
!self.registered.lock().await.is_empty()
}
async fn push_presentation(&self, presentation: WirePresentation) {
let sent = self
.client
.notify(
"browser.producer.presentation",
json!({ "presentation": presentation }),
)
.await;
self.note_push(sent).await;
}
async fn push_frame(&self, frame: WireFrame) {
let push = self
.client
.notify("browser.producer.frame", json!({ "frame": frame }));
match tokio::time::timeout(FRAME_PUSH_TIMEOUT, push).await {
Ok(sent) => self.note_push(sent).await,
Err(_) => tracing::debug!(
"browser producer: dropped a frame the daemon session did not accept in time"
),
}
}
async fn note_push(&self, sent: Result<(), String>) {
let Err(error) = sent else {
return;
};
let dropped = {
let mut registered = self.registered.lock().await;
let dropped = !registered.is_empty();
registered.clear();
dropped
};
if dropped {
tracing::debug!(
%error,
"browser producer: lost the daemon session; will republish on the next turn"
);
}
}
pub async fn handle(&self, kind: Relayed, params: &Value) -> Result<Value, String> {
match kind {
Relayed::Input => {
let input = input_from_wire(params)?;
let _serial = match tokio::time::timeout(
INPUT_QUEUE_TIMEOUT,
self.input_order.lock(),
)
.await
{
Ok(guard) => guard,
Err(_) => {
return Err(
"the browser is still applying earlier input — the drawer gave up \
waiting for this one"
.to_string(),
)
}
};
let opened = input.apply(&self.tools).await?;
let mut out = json!({ "ok": true });
if let (Some(out), Some(tab_id)) = (out.as_object_mut(), opened) {
out.insert("tab_id".to_string(), json!(tab_id));
}
Ok(out)
}
Relayed::HostConnected => {
let connected = params
.get("connected")
.and_then(Value::as_bool)
.ok_or("agent.browser.host_connected requires { connected }")?;
self.host_connected.store(connected, Ordering::Release);
Ok(json!({ "ok": true }))
}
Relayed::Control => {
let action = params
.get("action")
.and_then(Value::as_str)
.ok_or("agent.browser.control requires { action }")?;
let (presentation, effects) = control_from_wire(action)?.apply(&self.tools).await;
Ok(json!({
"presentation": WirePresentation::from(&presentation),
"effects": effects_to_wire(&effects),
}))
}
Relayed::Capture => {
let enabled = params
.get("enabled")
.and_then(Value::as_bool)
.ok_or("agent.browser.capture requires { enabled }")?;
self.capture_pushes.fetch_add(1, Ordering::AcqRel);
let _ = self.capture.send(enabled);
Ok(json!({ "ok": true }))
}
}
}
}
pub struct TurnGuard {
producer: Arc<BrowserProducer>,
}
impl TurnGuard {
pub fn new(producer: Arc<BrowserProducer>) -> Self {
Self { producer }
}
}
impl Drop for TurnGuard {
fn drop(&mut self) {
let producer = Arc::clone(&self.producer);
tokio::spawn(async move {
producer.note_turn_ended().await;
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Relayed {
Input,
Control,
Capture,
HostConnected,
}
async fn presentation_pump(producer: Weak<BrowserProducer>, tools: Arc<BrowserTools>) {
let mut changes = tools.subscribe_changes();
let mut tabs = tools.subscribe_tabs().await;
let mut last: Option<WirePresentation> = None;
loop {
tokio::select! {
changed = changes.changed() => {
if changed.is_err() {
return;
}
if tabs.is_none() {
tabs = tools.subscribe_tabs().await;
}
}
tabs_changed = async {
match tabs.as_mut() {
Some(rx) => rx.changed().await.is_ok(),
None => std::future::pending().await,
}
} => {
if !tabs_changed {
tabs = None;
continue;
}
}
}
let Some(producer) = producer.upgrade() else {
return;
};
if !producer.is_registered().await {
continue;
}
let presentation = producer.presentation().await;
if last.as_ref() == Some(&presentation) {
continue;
}
last = Some(presentation.clone());
producer.push_presentation(presentation).await;
}
}
fn coalesce_newest(first: ScreencastFrame, frames: &mut FrameReceiver) -> ScreencastFrame {
let mut newest = first;
let mut dropped = 0usize;
while let Ok(next) = frames.try_recv() {
newest = next;
dropped += 1;
}
if dropped > 0 {
tracing::debug!(
dropped,
"browser producer: coalesced a screencast backlog to the newest frame"
);
}
newest
}
async fn frame_pump(
producer: Weak<BrowserProducer>,
tools: Arc<BrowserTools>,
mut capture: watch::Receiver<bool>,
) {
loop {
while !*capture.borrow_and_update() {
if capture.changed().await.is_err() {
return;
}
}
let (mut frames, _epoch) = tools.subscribe_frames().await;
loop {
tokio::select! {
changed = capture.changed() => {
if changed.is_err() {
return;
}
if !*capture.borrow_and_update() {
break;
}
}
frame = frames.recv() => {
match frame {
Some(frame) => {
let Some(producer) = producer.upgrade() else {
return;
};
let newest = coalesce_newest(frame, &mut frames);
producer.push_frame(WireFrame::from(newest)).await;
}
None => {
let (rx, _epoch) = tools.subscribe_frames().await;
frames = rx;
}
}
}
}
}
drop(frames);
tools.release_frames().await;
}
}
async fn republish_pump(producer: Weak<BrowserProducer>) {
loop {
tokio::time::sleep(REPUBLISH_INTERVAL).await;
let Some(producer) = producer.upgrade() else {
return;
};
producer.resync().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assistant::browser_control::ControlOwner;
use crate::browser_view::WireOwner;
fn offline_producer() -> Arc<BrowserProducer> {
BrowserProducer::install(
DaemonClient::with_url("ws://127.0.0.1:1"),
Arc::new(BrowserTools::new(std::env::temp_dir())),
)
}
#[tokio::test]
async fn a_relayed_input_runs_against_this_process_s_own_browser() {
let producer = offline_producer();
let err = producer
.handle(
Relayed::Input,
&json!({ "op": "click", "x": 1.0, "y": 2.0 }),
)
.await
.unwrap_err();
assert!(err.contains("no browser is running"), "got: {err}");
}
#[tokio::test]
async fn a_malformed_relayed_input_is_refused_before_the_browser() {
let producer = offline_producer();
for (params, needle) in [
(json!({ "op": "click", "x": 1.0 }), "requires { x, y }"),
(json!({ "op": "navigate" }), "requires { url }"),
(
json!({ "op": "keypress", "modifiers": ["hyper"] }),
"unknown modifier",
),
(
json!({ "op": "read_dom" }),
"unknown agent.browser.input op",
),
(json!({}), "requires { op }"),
] {
let err = producer.handle(Relayed::Input, ¶ms).await.unwrap_err();
assert!(err.contains(needle), "{params}: got {err}");
}
}
#[tokio::test]
async fn a_relayed_control_transition_drives_the_reducer_and_reports_it_back() {
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
let out = producer
.handle(Relayed::Control, &json!({ "action": "take_control" }))
.await
.unwrap();
assert_eq!(out["presentation"]["owner"], "user");
assert_eq!(out["presentation"]["blackout_active"], true);
assert_eq!(out["effects"], json!([]));
let out = producer
.handle(
Relayed::Control,
&json!({ "action": "holder_disconnected" }),
)
.await
.unwrap();
assert_eq!(out["effects"][0]["effect"], "start_grace_period");
let out = producer
.handle(Relayed::Control, &json!({ "action": "hand_back" }))
.await
.unwrap();
assert_eq!(out["presentation"]["owner"], "agent");
assert_eq!(out["presentation"]["blackout_active"], false);
}
#[tokio::test]
async fn an_unknown_control_action_is_refused_rather_than_guessed() {
let producer = offline_producer();
let err = producer
.handle(Relayed::Control, &json!({ "action": "seize" }))
.await
.unwrap_err();
assert!(
err.contains("unknown agent.browser.control action"),
"got: {err}"
);
}
#[tokio::test]
async fn capture_toggles_the_frame_pump_and_nothing_else() {
let producer = offline_producer();
assert!(!*producer.capture.borrow());
producer
.handle(Relayed::Capture, &json!({ "enabled": true }))
.await
.unwrap();
assert!(*producer.capture.borrow());
producer
.handle(Relayed::Capture, &json!({ "enabled": false }))
.await
.unwrap();
assert!(!*producer.capture.borrow());
let err = producer
.handle(Relayed::Capture, &json!({}))
.await
.unwrap_err();
assert!(err.contains("requires { enabled }"), "got: {err}");
}
#[tokio::test]
async fn the_run_ends_when_the_last_turn_does_not_the_first() {
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
producer.note_turn_started();
producer.note_turn_started();
producer.note_turn_ended().await;
assert_eq!(
producer.tools.control_status().await.owner,
ControlOwner::Agent,
"one conversation finished; the other is still driving this browser"
);
producer.note_turn_ended().await;
assert_eq!(
producer.tools.control_status().await.owner,
ControlOwner::NoAgent,
"the last turn ended: the user's browser again, no ceremony"
);
}
#[tokio::test]
async fn turn_guard_ends_the_turn_when_it_drops_normally() {
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
producer.note_turn_started();
{
let _guard = TurnGuard::new(Arc::clone(&producer));
}
for _ in 0..50 {
if producer.tools.control_status().await.owner == ControlOwner::NoAgent {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
producer.tools.control_status().await.owner,
ControlOwner::NoAgent,
"the guard must end the turn on normal drop"
);
}
#[tokio::test]
async fn turn_guard_ends_the_turn_even_if_the_task_panics() {
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
producer.note_turn_started();
let p = Arc::clone(&producer);
let handle = tokio::spawn(async move {
let _guard = TurnGuard::new(Arc::clone(&p));
panic!("simulated turn panic between start and the old trailing note_turn_ended()");
});
let outcome = handle.await;
assert!(outcome.is_err(), "the spawned task should have panicked");
for _ in 0..50 {
if producer.tools.control_status().await.owner == ControlOwner::NoAgent {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
producer.tools.control_status().await.owner,
ControlOwner::NoAgent,
"the guard's Drop must still end the turn after a panic — this is the fix"
);
}
#[tokio::test]
async fn the_browser_outlives_the_run_that_used_it() {
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
producer.note_turn_started();
producer.note_turn_ended().await;
let presentation = producer.presentation().await;
assert_eq!(presentation.owner, WireOwner::None);
assert!(!presentation.blackout_active);
let err = producer
.handle(
Relayed::Input,
&json!({ "op": "click", "x": 1.0, "y": 2.0 }),
)
.await
.unwrap_err();
assert!(err.contains("no browser is running"), "got: {err}");
}
#[tokio::test]
async fn a_failed_registration_is_retried_on_the_next_turn() {
let producer = offline_producer();
producer.register_conversation("conv-1").await;
assert!(
!producer.is_registered().await,
"a registration that never landed must not be remembered as landed"
);
assert!(!producer.is_registered().await);
assert!(!producer.tools.host_connected_for_test().await);
}
#[tokio::test]
async fn a_successful_registration_ack_updates_the_shared_host_connected_flag() {
let producer = offline_producer();
assert!(!producer.tools.host_connected_for_test().await);
producer.apply_register_ack(
&json!({ "ok": true, "host_connected": true }),
producer.capture_pushes.load(Ordering::Acquire),
);
assert!(
producer.tools.host_connected_for_test().await,
"BrowserTools reads the SAME flag this ack updates"
);
producer.apply_register_ack(
&json!({ "ok": true, "host_connected": false }),
producer.capture_pushes.load(Ordering::Acquire),
);
assert!(!producer.tools.host_connected_for_test().await);
}
#[tokio::test]
async fn a_pushed_host_transition_updates_the_flag_live() {
let producer = offline_producer();
producer.apply_register_ack(
&json!({ "ok": true, "host_connected": true }),
producer.capture_pushes.load(Ordering::Acquire),
);
assert!(producer.tools.host_connected_for_test().await);
let out = producer
.handle(Relayed::HostConnected, &json!({ "connected": false }))
.await
.expect("the process accepts the transition");
assert_eq!(out["ok"], true);
assert!(
!producer.tools.host_connected_for_test().await,
"the sign-in gate must see the host is gone without waiting for a re-registration"
);
producer
.handle(Relayed::HostConnected, &json!({ "connected": true }))
.await
.unwrap();
assert!(producer.tools.host_connected_for_test().await);
}
#[tokio::test]
async fn a_host_transition_without_the_field_is_a_clean_error_not_a_silent_flip() {
let producer = offline_producer();
producer.apply_register_ack(
&json!({ "ok": true, "host_connected": true }),
producer.capture_pushes.load(Ordering::Acquire),
);
let err = producer
.handle(Relayed::HostConnected, &json!({}))
.await
.unwrap_err();
assert!(err.contains("requires { connected }"), "got: {err}");
assert!(
producer.tools.host_connected_for_test().await,
"a malformed push must not change what the process believes"
);
}
#[tokio::test]
async fn an_ack_with_no_host_connected_field_leaves_the_flag_unchanged() {
let producer = offline_producer();
producer.apply_register_ack(
&json!({ "ok": true, "host_connected": true }),
producer.capture_pushes.load(Ordering::Acquire),
);
assert!(producer.tools.host_connected_for_test().await);
producer.apply_register_ack(
&json!({ "ok": true }),
producer.capture_pushes.load(Ordering::Acquire),
);
assert!(
producer.tools.host_connected_for_test().await,
"a missing field must not silently reset a known-true flag to false"
);
}
#[tokio::test]
async fn losing_the_daemon_session_makes_the_next_turn_republish() {
let producer = offline_producer();
producer
.registered
.lock()
.await
.insert("conv-1".to_string());
assert!(producer.is_registered().await);
producer
.push_presentation(producer.presentation().await)
.await;
assert!(
!producer.is_registered().await,
"what was published on a session that is gone is not published any more"
);
}
#[tokio::test]
async fn a_turn_re_registers_even_when_this_process_believes_it_already_did() {
let producer = offline_producer();
producer
.registered
.lock()
.await
.insert("conv-1".to_string());
assert!(producer.is_registered().await);
producer.register_conversation("conv-1").await;
assert!(
!producer.is_registered().await,
"an already-registered conversation must still go to the wire, and a round \
trip that fails must not leave this process believing it is published"
);
assert!(
producer.known.lock().await.iter().any(|id| id == "conv-1"),
"and resync must have something to retry from"
);
}
#[tokio::test]
async fn what_this_process_published_outlives_the_session_it_published_on() {
let producer = offline_producer();
producer.register_conversation("conv-1").await;
assert!(!producer.is_registered().await);
assert!(producer.known.lock().await.iter().any(|id| id == "conv-1"));
producer.resync().await;
assert!(producer.known.lock().await.iter().any(|id| id == "conv-1"));
assert_eq!(producer.known.lock().await.len(), 1);
}
#[tokio::test]
async fn resync_republishes_even_when_this_process_believes_it_is_still_published() {
let producer = offline_producer();
producer.host_connected.store(true, Ordering::Release);
producer.known.lock().await.push_back("conv-1".to_string());
producer
.registered
.lock()
.await
.insert("conv-1".to_string());
assert!(producer.is_registered().await);
producer.resync().await;
assert!(
!producer.is_registered().await,
"resync must re-register everything known, not only what it thinks is missing"
);
assert!(producer.known.lock().await.iter().any(|id| id == "conv-1"));
}
#[tokio::test]
async fn the_republish_set_keeps_only_the_recent_tail() {
let producer = offline_producer();
for turn in 0..(MAX_KNOWN_CONVERSATIONS + 5) {
producer
.known
.lock()
.await
.push_back(format!("turn-{turn}"));
}
producer.register_conversation("newest").await;
let known = producer.known.lock().await;
assert_eq!(known.len(), MAX_KNOWN_CONVERSATIONS);
assert_eq!(
known.back().map(String::as_str),
Some("newest"),
"the most recent turn is what a republish can actually restore"
);
assert!(
!known.iter().any(|id| id == "turn-0"),
"and the oldest is evicted rather than re-registered forever"
);
}
#[tokio::test]
async fn re_registering_refreshes_recency_instead_of_duplicating() {
let producer = offline_producer();
producer.register_conversation("a").await;
producer.register_conversation("b").await;
producer.register_conversation("a").await;
let known = producer.known.lock().await;
assert_eq!(known.len(), 2);
assert_eq!(known.back().map(String::as_str), Some("a"));
}
#[tokio::test]
async fn resync_does_nothing_while_no_host_is_connected() {
let producer = offline_producer();
producer.known.lock().await.push_back("conv-1".to_string());
producer
.registered
.lock()
.await
.insert("conv-1".to_string());
producer.resync().await;
assert!(
producer.is_registered().await,
"nothing was attempted, so nothing was un-registered"
);
}
#[tokio::test]
async fn a_stalled_push_keeps_only_the_newest_frame_and_releases_the_rest() {
let (tx, mut rx) =
tokio::sync::mpsc::channel::<ScreencastFrame>(car_browser::FRAME_CHANNEL_CAP);
let frame = |byte: u8| ScreencastFrame {
jpeg: vec![byte; 64].into(),
viewport: car_browser::Viewport {
width: 1920,
height: 1080,
device_pixel_ratio: 1.0,
},
captured_at: byte as f64,
};
for byte in 1..=9u8 {
tx.try_send(frame(byte)).unwrap();
}
let first = rx.recv().await.unwrap();
let newest = coalesce_newest(first, &mut rx);
assert_eq!(
newest.captured_at, 9.0,
"the drawer gets the current page, not the oldest picture of it"
);
assert!(
rx.try_recv().is_err(),
"and the eight stale JPEGs are released, not held"
);
tx.try_send(frame(11)).unwrap();
let only = rx.recv().await.unwrap();
assert_eq!(coalesce_newest(only, &mut rx).captured_at, 11.0);
}
#[tokio::test]
async fn a_frame_push_returns_rather_than_parking_the_shared_write() {
let producer = offline_producer();
producer
.registered
.lock()
.await
.insert("conv-1".to_string());
producer
.push_frame(WireFrame {
jpeg_base64: "AQID".into(),
width: 1920,
height: 1080,
device_pixel_ratio: 1.0,
captured_at: 0.0,
})
.await;
assert!(
!producer.is_registered().await,
"the push returned AND took the lost-session path, rather than parking"
);
}
#[tokio::test]
async fn relayed_input_is_refused_when_this_process_s_reducer_says_the_agent_is_driving() {
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
let err = producer
.handle(
Relayed::Input,
&json!({ "op": "click", "x": 1.0, "y": 2.0 }),
)
.await
.unwrap_err();
assert_eq!(
err,
crate::browser_view::AGENT_HOLDS_CONTROL,
"the same words the daemon's own gate uses"
);
assert!(
!err.contains("no browser is running"),
"refused BEFORE the browser, not by it"
);
producer
.handle(Relayed::Control, &json!({ "action": "take_control" }))
.await
.unwrap();
let err = producer
.handle(
Relayed::Input,
&json!({ "op": "click", "x": 1.0, "y": 2.0 }),
)
.await
.unwrap_err();
assert!(
err.contains("no browser is running"),
"now it reaches the browser and fails there; got: {err}"
);
}
#[tokio::test]
async fn a_pending_signin_lets_relayed_input_through_without_taking_control() {
use crate::assistant::browser_control::ControlEvent;
let producer = offline_producer();
producer.tools.attach_agent_for_test().await;
producer
.tools
.apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
.await;
let err = producer
.handle(Relayed::Input, &json!({ "op": "type", "text": "hunter2" }))
.await
.unwrap_err();
assert!(
err.contains("no browser is running"),
"the gate let it through to the browser; got: {err}"
);
}
}