use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
use car_browser::Modifier;
use futures::SinkExt;
use serde_json::{json, Value};
use tokio::sync::{watch, Mutex, MutexGuard};
use tokio_tungstenite::tungstenite::Message;
use crate::assistant::browser_control::{ControlEffect, ControlOwner};
use crate::assistant::browser_tools::ControlStatus;
use crate::browser_attention::{notify_signin_transition, BrowserSignInSnapshot, SignInAttention};
use crate::browser_view::{BrowserView, ViewControl, ViewInput, WireFrame, WirePresentation};
use crate::handler::JsonRpcMessage;
use crate::session::{ClientSession, ServerState, WsChannel};
pub const RELAY_CALL_TIMEOUT: Duration = Duration::from_secs(30);
const CAPTURE_RETRY_BACKOFF: Duration = Duration::from_secs(2);
pub const MAX_VIEWS_PER_PRODUCER: usize =
crate::assistant::browser_producer::MAX_KNOWN_CONVERSATIONS;
const MAX_PRODUCER_FRAME_BYTES: usize = 8 * 1024 * 1024;
pub fn modifier_name(modifier: Modifier) -> &'static str {
match modifier {
Modifier::Shift => "shift",
Modifier::Control => "control",
Modifier::Alt => "alt",
Modifier::Meta => "meta",
}
}
pub fn input_to_wire(input: &ViewInput) -> Value {
match input {
ViewInput::Navigate { url } => json!({ "op": "navigate", "url": url }),
ViewInput::Click { x, y } => json!({ "op": "click", "x": x, "y": y }),
ViewInput::Type { text } => json!({ "op": "type", "text": text }),
ViewInput::Keypress { key, modifiers } => json!({
"op": "keypress",
"key": key,
"modifiers": modifiers.iter().map(|m| modifier_name(*m)).collect::<Vec<_>>(),
}),
ViewInput::Scroll { delta_y } => json!({ "op": "scroll", "delta_y": delta_y }),
ViewInput::Paste { text } => json!({ "op": "paste", "text": text }),
ViewInput::Back => json!({ "op": "back" }),
ViewInput::Forward => json!({ "op": "forward" }),
ViewInput::Reload => json!({ "op": "reload" }),
ViewInput::TabOpen => json!({ "op": "tab_open" }),
ViewInput::TabClose { tab_id } => json!({ "op": "tab_close", "tab_id": tab_id }),
ViewInput::TabSwitch { tab_id } => json!({ "op": "tab_switch", "tab_id": tab_id }),
}
}
pub fn input_from_wire(params: &Value) -> Result<ViewInput, String> {
let op = params
.get("op")
.and_then(Value::as_str)
.ok_or("agent.browser.input requires { op }")?;
let string = |field: &str| -> Result<String, String> {
params
.get(field)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| format!("agent.browser.input `{op}` requires {{ {field} }}"))
};
match op {
"navigate" => Ok(ViewInput::Navigate {
url: string("url")?,
}),
"click" => {
let (x, y) = match (
params.get("x").and_then(Value::as_f64),
params.get("y").and_then(Value::as_f64),
) {
(Some(x), Some(y)) => (x, y),
_ => return Err("agent.browser.input `click` requires { x, y }".to_string()),
};
Ok(ViewInput::Click { x, y })
}
"type" => Ok(ViewInput::Type {
text: string("text")?,
}),
"keypress" => {
let mut modifiers = Vec::new();
for name in params
.get("modifiers")
.and_then(Value::as_array)
.unwrap_or(&Vec::new())
{
let name = name
.as_str()
.ok_or("agent.browser.input `keypress` modifiers must be strings")?;
modifiers.push(crate::browser_view::parse_modifier(name)?);
}
Ok(ViewInput::Keypress {
key: string("key")?,
modifiers,
})
}
"scroll" => Ok(ViewInput::Scroll {
delta_y: params
.get("delta_y")
.and_then(Value::as_i64)
.and_then(|n| i32::try_from(n).ok())
.ok_or("agent.browser.input `scroll` requires { delta_y }")?,
}),
"paste" => Ok(ViewInput::Paste {
text: string("text")?,
}),
"back" => Ok(ViewInput::Back),
"forward" => Ok(ViewInput::Forward),
"reload" => Ok(ViewInput::Reload),
"tab_open" => Ok(ViewInput::TabOpen),
"tab_close" => Ok(ViewInput::TabClose {
tab_id: string("tab_id")?,
}),
"tab_switch" => Ok(ViewInput::TabSwitch {
tab_id: string("tab_id")?,
}),
other => Err(format!("unknown agent.browser.input op '{other}'")),
}
}
pub fn control_to_wire(control: ViewControl) -> &'static str {
match control {
ViewControl::TakeControl => "take_control",
ViewControl::HandBack => "hand_back",
ViewControl::RunEnded => "run_ended",
ViewControl::HolderDisconnected => "holder_disconnected",
ViewControl::GraceExpired => "grace_expired",
}
}
pub fn control_from_wire(action: &str) -> Result<ViewControl, String> {
match action {
"take_control" => Ok(ViewControl::TakeControl),
"hand_back" => Ok(ViewControl::HandBack),
"run_ended" => Ok(ViewControl::RunEnded),
"holder_disconnected" => Ok(ViewControl::HolderDisconnected),
"grace_expired" => Ok(ViewControl::GraceExpired),
other => Err(format!(
"unknown agent.browser.control action '{other}' — use take_control, hand_back, \
run_ended, holder_disconnected or grace_expired"
)),
}
}
pub fn effects_to_wire(effects: &[ControlEffect]) -> Value {
Value::Array(
effects
.iter()
.map(|effect| match effect {
ControlEffect::StartGracePeriod => json!({ "effect": "start_grace_period" }),
ControlEffect::SignInResolved { signed_in } => json!({
"effect": "sign_in_resolved",
"signed_in": signed_in,
}),
})
.collect(),
)
}
pub fn effects_from_wire(value: &Value) -> Vec<ControlEffect> {
let Some(items) = value.as_array() else {
return Vec::new();
};
items
.iter()
.filter_map(|item| match item.get("effect").and_then(Value::as_str) {
Some("start_grace_period") => Some(ControlEffect::StartGracePeriod),
Some("sign_in_resolved") => Some(ControlEffect::SignInResolved {
signed_in: item
.get("signed_in")
.and_then(Value::as_bool)
.unwrap_or(false),
}),
_ => {
tracing::debug!(effect = ?item, "browser relay: ignoring an unknown control effect");
None
}
})
.collect()
}
async fn call_agent(
channel: &Arc<WsChannel>,
method: &str,
params: Value,
) -> Result<Value, String> {
let request_id = channel.next_request_id();
let (tx, rx) = tokio::sync::oneshot::channel();
channel.pending.lock().await.insert(request_id.clone(), tx);
let frame = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": request_id,
});
let text = match serde_json::to_string(&frame) {
Ok(text) => text,
Err(e) => {
channel.pending.lock().await.remove(&request_id);
return Err(format!("serialize {method}: {e}"));
}
};
let sent = tokio::time::timeout(RELAY_CALL_TIMEOUT, async {
channel
.write
.lock()
.await
.send(Message::Text(text.into()))
.await
})
.await;
match sent {
Ok(Ok(())) => {}
Ok(Err(e)) => {
channel.pending.lock().await.remove(&request_id);
return Err(format!(
"the agent process serving this browser is unreachable: {e}"
));
}
Err(_) => {
channel.pending.lock().await.remove(&request_id);
return Err(format!(
"the agent process serving this browser is unreachable: its connection did not \
accept `{method}` within {}s",
RELAY_CALL_TIMEOUT.as_secs()
));
}
}
match tokio::time::timeout(RELAY_CALL_TIMEOUT, rx).await {
Ok(Ok(response)) => match (response.error, response.output) {
(Some(error), _) => Err(error),
(None, Some(output)) => Ok(output),
(None, None) => Ok(Value::Null),
},
Ok(Err(_)) => {
Err("the agent process serving this browser disconnected before answering".to_string())
}
Err(_) => {
channel.pending.lock().await.remove(&request_id);
Err(format!(
"the agent process serving this browser did not answer `{method}` within {}s",
RELAY_CALL_TIMEOUT.as_secs()
))
}
}
}
pub const PRODUCER_GONE: &str =
"the agent process that owns this browser has disconnected — its browser is gone";
pub struct RelayProducer {
client_id: String,
agent_id: String,
channel: Arc<WsChannel>,
last: Mutex<WirePresentation>,
signin_attention: Mutex<RelaySignInAttention>,
views: Mutex<Vec<Weak<BrowserView>>>,
alive: AtomicBool,
watchers: std::sync::Mutex<usize>,
capture: watch::Sender<bool>,
host_desired: AtomicBool,
host_push: Mutex<Option<bool>>,
announce_order: Mutex<()>,
}
struct PendingSignInAnnouncement {
attention: Arc<dyn SignInAttention>,
conversation_id: Option<String>,
before: Option<String>,
after: Option<String>,
}
#[derive(Default)]
struct RelaySignInAttention {
attention: Option<Arc<dyn SignInAttention>>,
conversation_id: Option<String>,
latest_conversation_id: Option<String>,
announced: Option<String>,
}
impl RelayProducer {
pub fn new(client_id: String, agent_id: String, channel: Arc<WsChannel>) -> Arc<Self> {
let (capture, rx) = watch::channel(false);
let producer = Arc::new(Self {
client_id,
agent_id,
channel,
last: Mutex::new(WirePresentation::empty()),
signin_attention: Mutex::new(RelaySignInAttention::default()),
views: Mutex::new(Vec::new()),
alive: AtomicBool::new(true),
watchers: std::sync::Mutex::new(0),
capture,
host_desired: AtomicBool::new(false),
host_push: Mutex::new(None),
announce_order: Mutex::new(()),
});
producer.spawn_capture_pump(rx);
producer
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn agent_id(&self) -> &str {
&self.agent_id
}
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
pub async fn presentation(&self) -> WirePresentation {
self.last.lock().await.clone()
}
pub async fn set_signin_attention(
&self,
attention: Option<Arc<dyn SignInAttention>>,
conversation_id: Option<String>,
) {
let mut binding = self.signin_attention.lock().await;
binding.latest_conversation_id = conversation_id.clone();
if binding.announced.is_none() {
binding.conversation_id = conversation_id;
}
binding.attention = attention;
let pending = self.decide_signin_transition(&mut binding).await;
self.announce(binding, Vec::from_iter(pending)).await;
}
pub async fn detach_signin_attention(&self, conversation_id: Option<&str>) {
let mut binding = self.signin_attention.lock().await;
if binding.conversation_id.as_deref() != conversation_id {
return;
}
let live = self.live_view_keys().await;
let retiring_was_latest = binding.latest_conversation_id.as_deref() == conversation_id;
let latest_is_live = live
.iter()
.any(|key| key.as_deref() == binding.latest_conversation_id.as_deref());
let successor = if !retiring_was_latest && latest_is_live {
Some(binding.latest_conversation_id.clone())
} else {
live.into_iter()
.rev()
.find(|key| key.as_deref() != conversation_id)
};
let mut pending = Vec::new();
if let Some(before) = binding.announced.take() {
if let Some(attention) = binding.attention.as_ref() {
pending.push(PendingSignInAnnouncement {
attention: Arc::clone(attention),
conversation_id: conversation_id.map(str::to_string),
before: Some(before),
after: None,
});
}
}
match successor {
Some(key) => {
if retiring_was_latest || !latest_is_live {
binding.latest_conversation_id = key.clone();
}
binding.conversation_id = key;
pending.extend(self.decide_signin_transition(&mut binding).await);
}
None => {
binding.attention = None;
binding.conversation_id = None;
binding.latest_conversation_id = None;
}
}
self.announce(binding, pending).await;
}
pub async fn signin_snapshot(&self) -> Option<BrowserSignInSnapshot> {
let binding = self.signin_attention.lock().await;
binding.announced.as_ref().map(|message| {
BrowserSignInSnapshot::new(binding.conversation_id.as_deref(), message.clone())
})
}
async fn sync_signin_attention(&self) {
let mut binding = self.signin_attention.lock().await;
let pending = self.decide_signin_transition(&mut binding).await;
self.announce(binding, Vec::from_iter(pending)).await;
}
async fn decide_signin_transition(
&self,
binding: &mut RelaySignInAttention,
) -> Option<PendingSignInAnnouncement> {
let attention = Arc::clone(binding.attention.as_ref()?);
let current = self.last.lock().await.pending_signin.clone();
if binding.announced == current {
return None;
}
let before = std::mem::replace(&mut binding.announced, current.clone());
let conversation_id = binding.conversation_id.clone();
if current.is_none() {
binding.conversation_id = binding.latest_conversation_id.clone();
}
Some(PendingSignInAnnouncement {
attention,
conversation_id,
before,
after: current,
})
}
async fn announce(
&self,
binding: MutexGuard<'_, RelaySignInAttention>,
pending: Vec<PendingSignInAnnouncement>,
) {
if pending.is_empty() {
return;
}
let _order = self.announce_order.lock().await;
drop(binding);
for announcement in pending {
notify_signin_transition(
&announcement.attention,
announcement.conversation_id.as_deref(),
announcement.before.as_deref(),
announcement.after.as_deref(),
)
.await;
}
}
async fn live_view_keys(&self) -> Vec<Option<String>> {
self.live_views()
.await
.into_iter()
.map(|view| view.key().map(str::to_string))
.collect()
}
pub async fn control_status(&self) -> ControlStatus {
let last = self.last.lock().await;
ControlStatus {
owner: last.owner.into(),
signin_pending: last.pending_signin.is_some(),
blackout_active: last.blackout_active,
}
}
pub async fn control(
&self,
control: ViewControl,
) -> Result<(ControlOwner, Vec<ControlEffect>), String> {
if !self.is_alive() {
return Err(PRODUCER_GONE.to_string());
}
let value = call_agent(
&self.channel,
"agent.browser.control",
json!({ "action": control_to_wire(control) }),
)
.await
.map_err(|error| {
tracing::warn!(
agent_id = %self.agent_id,
action = control_to_wire(control),
%error,
"browser relay: control transition did not reach the agent process"
);
error
})?;
let mut owner: Option<ControlOwner> = None;
if let Some(presentation) = value.get("presentation") {
match serde_json::from_value::<WirePresentation>(presentation.clone()) {
Ok(presentation) => {
owner = Some(presentation.owner.into());
self.cache(presentation).await;
}
Err(e) => tracing::warn!(
error = %e,
"browser relay: agent returned an unparseable presentation"
),
}
}
let owner = match owner {
Some(owner) => owner,
None => self.last.lock().await.owner.into(),
};
Ok((
owner,
effects_from_wire(value.get("effects").unwrap_or(&Value::Null)),
))
}
pub async fn input(&self, input: ViewInput) -> Result<Option<String>, String> {
if !self.is_alive() {
return Err(PRODUCER_GONE.to_string());
}
let out = call_agent(&self.channel, "agent.browser.input", input_to_wire(&input)).await?;
Ok(out
.get("tab_id")
.and_then(Value::as_str)
.map(str::to_string))
}
pub fn desired_capture(&self) -> bool {
match self.watchers.lock() {
Ok(watchers) => *watchers > 0,
Err(poisoned) => *poisoned.into_inner() > 0,
}
}
pub fn start_capture(&self) {
self.set_watchers(|n| n + 1);
}
pub fn stop_capture(&self) {
self.set_watchers(|n| n.saturating_sub(1));
}
fn set_watchers(&self, f: impl Fn(usize) -> usize) {
let mut watchers = match self.watchers.lock() {
Ok(watchers) => watchers,
Err(poisoned) => poisoned.into_inner(),
};
*watchers = f(*watchers);
let desired = *watchers > 0;
self.capture.send_if_modified(|current| {
let changed = *current != desired;
*current = desired;
changed
});
}
pub async fn attach_view(&self, view: &Arc<BrowserView>) {
let mut views = self.views.lock().await;
views.retain(|existing| existing.strong_count() > 0);
views.push(Arc::downgrade(view));
}
pub async fn views_past_the_cap(&self) -> Vec<Arc<BrowserView>> {
let mut views = self.views.lock().await;
views.retain(|view| view.strong_count() > 0);
if views.len() <= MAX_VIEWS_PER_PRODUCER {
return Vec::new();
}
let stale = views.len() - MAX_VIEWS_PER_PRODUCER;
views.iter().take(stale).filter_map(Weak::upgrade).collect()
}
pub async fn set_presentation(&self, presentation: WirePresentation) {
self.cache(presentation).await;
self.sync_signin_attention().await;
}
pub async fn push_presentation(&self, presentation: WirePresentation) {
self.cache(presentation).await;
self.sync_signin_attention().await;
for view in self.live_views().await {
view.refresh_presentation().await;
}
}
async fn cache(&self, presentation: WirePresentation) {
let mut last = self.last.lock().await;
if presentation.revision < last.revision {
return;
}
*last = presentation;
}
pub async fn push_frame(&self, frame: WireFrame) {
let mut watched = Vec::new();
for view in self.live_views().await {
if view.has_subscribers().await {
watched.push(view);
}
}
let Some(last) = watched.pop() else { return };
for view in watched {
view.emit_wire_frame(frame.clone()).await;
}
last.emit_wire_frame(frame).await;
}
pub async fn push_host_connected(&self, connected: bool) {
if !self.is_alive() {
return;
}
self.host_desired.store(connected, Ordering::Release);
let mut last = self.host_push.lock().await;
let connected = self.host_desired.load(Ordering::Acquire);
if *last == Some(connected) {
return;
}
match call_agent(
&self.channel,
"agent.browser.host_connected",
json!({ "connected": connected }),
)
.await
{
Ok(_) => *last = Some(connected),
Err(error) => tracing::debug!(
agent_id = %self.agent_id,
%error,
"browser relay: could not tell the agent process about a host transition"
),
}
}
pub async fn note_disconnected(&self) {
self.alive.store(false, Ordering::Release);
let _ = self.capture.send(false);
{
let mut last = self.last.lock().await;
let revision = last.revision.saturating_add(1);
*last = WirePresentation::empty();
last.revision = revision;
}
self.sync_signin_attention().await;
for view in self.live_views().await {
view.refresh_presentation().await;
}
}
async fn live_views(&self) -> Vec<Arc<BrowserView>> {
let mut views = self.views.lock().await;
views.retain(|view| view.strong_count() > 0);
views.iter().filter_map(Weak::upgrade).collect()
}
fn spawn_capture_pump(self: &Arc<Self>, mut rx: watch::Receiver<bool>) {
let producer = Arc::downgrade(self);
tokio::spawn(async move {
let mut undelivered = false;
loop {
if !undelivered && rx.changed().await.is_err() {
return;
}
let enabled = *rx.borrow_and_update();
let Some(producer) = producer.upgrade() else {
return;
};
if !producer.is_alive() {
return;
}
let sent = call_agent(
&producer.channel,
"agent.browser.capture",
json!({ "enabled": enabled }),
)
.await;
undelivered = match sent {
Ok(_) => false,
Err(e) => {
tracing::debug!(
agent_id = %producer.agent_id,
enabled,
error = %e,
"browser relay: capture request did not reach the agent process; retrying"
);
true
}
};
drop(producer);
if undelivered {
tokio::select! {
changed = rx.changed() => {
if changed.is_err() {
return;
}
}
_ = tokio::time::sleep(CAPTURE_RETRY_BACKOFF) => {}
}
}
}
});
}
}
async fn authorize_producer(session: &ClientSession) -> Result<String, String> {
session.agent_id.lock().await.clone().ok_or_else(|| {
"not authorized to use browser.producer.*: this connection is not a supervised agent \
(session.auth { token, agent_id })"
.to_string()
})
}
pub fn authorize_conversation_claim(
conversation_id: &str,
agent_id: &str,
live_owner: Option<&str>,
bound_owner: Option<&str>,
) -> Result<(), String> {
match live_owner.or(bound_owner) {
Some(owner) if owner == agent_id => Ok(()),
Some(owner) => Err(format!(
"conversation '{conversation_id}' is served by agent '{owner}', not '{agent_id}'"
)),
None => Err(format!(
"conversation '{conversation_id}' is not an active chat session for agent \
'{agent_id}' — register from inside the turn that serves it"
)),
}
}
pub async fn handle_producer_register(
req: &JsonRpcMessage,
session: &Arc<ClientSession>,
state: &Arc<ServerState>,
) -> Result<Value, String> {
let agent_id = authorize_producer(session).await?;
let conversation_id = req
.params
.get("conversation_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.ok_or("browser.producer.register requires a non-empty { conversation_id }")?
.to_string();
let live_owner = state
.chat_sessions
.lock()
.await
.get(&conversation_id)
.map(|chat| chat.agent_id.clone());
let bound_owner = state
.browser_views
.conversation_owner(&conversation_id)
.await;
authorize_conversation_claim(
&conversation_id,
&agent_id,
live_owner.as_deref(),
bound_owner.as_deref(),
)?;
state
.browser_views
.bind_conversation(&conversation_id, &agent_id)
.await;
let producer = state
.browser_views
.producer_for(&session.client_id, &agent_id, &session.channel)
.await;
if let Some(presentation) = req.params.get("presentation") {
match serde_json::from_value::<WirePresentation>(presentation.clone()) {
Ok(presentation) => producer.set_presentation(presentation).await,
Err(e) => {
return Err(format!(
"browser.producer.register `presentation` is not a presentation object: {e}"
))
}
}
}
state
.browser_views
.register_relay(conversation_id.clone(), Arc::clone(&producer))
.await;
Ok(json!({
"ok": true,
"conversation_id": conversation_id,
"host_connected": state.any_host_connected().await,
"capture": producer.desired_capture(),
}))
}
pub(crate) async fn try_handle_producer_push(
parsed: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> bool {
let Some(method) = parsed.method.as_deref() else {
return false;
};
if method != "browser.producer.presentation" && method != "browser.producer.frame" {
return false;
}
if !parsed.id.is_null() {
return false;
}
if state.auth_token.get().is_some()
&& !session
.authenticated
.load(std::sync::atomic::Ordering::Acquire)
{
return false;
}
let Some(producer) = state.browser_views.producer(&session.client_id).await else {
tracing::debug!(
client_id = %session.client_id,
method,
"browser relay: push from a connection with no registered producer"
);
return true;
};
if method == "browser.producer.presentation" {
match serde_json::from_value::<WirePresentation>(
parsed
.params
.get("presentation")
.cloned()
.unwrap_or(Value::Null),
) {
Ok(presentation) => producer.push_presentation(presentation).await,
Err(e) => tracing::debug!(
error = %e,
"browser relay: unparseable presentation push"
),
}
} else {
match serde_json::from_value::<WireFrame>(
parsed.params.get("frame").cloned().unwrap_or(Value::Null),
) {
Ok(frame) if frame.jpeg_base64.len() > MAX_PRODUCER_FRAME_BYTES => {
tracing::debug!(
client_id = %session.client_id,
bytes = frame.jpeg_base64.len(),
"browser relay: dropped an oversized producer frame"
);
}
Ok(frame) => producer.push_frame(frame).await,
Err(e) => tracing::debug!(error = %e, "browser relay: unparseable frame push"),
}
}
true
}
#[derive(Default)]
pub struct ProducerRegistry {
producers: Mutex<HashMap<String, Arc<RelayProducer>>>,
bindings: Mutex<HashMap<String, String>>,
}
impl ProducerRegistry {
pub async fn get(&self, client_id: &str) -> Option<Arc<RelayProducer>> {
self.producers.lock().await.get(client_id).cloned()
}
pub async fn conversation_owner(&self, conversation_id: &str) -> Option<String> {
self.bindings.lock().await.get(conversation_id).cloned()
}
pub async fn bind_conversation(&self, conversation_id: &str, agent_id: &str) {
self.bindings
.lock()
.await
.insert(conversation_id.to_string(), agent_id.to_string());
}
pub async fn forget_binding(&self, conversation_id: &str) {
self.bindings.lock().await.remove(conversation_id);
}
pub async fn get_or_create(
&self,
client_id: &str,
agent_id: &str,
channel: &Arc<WsChannel>,
) -> Arc<RelayProducer> {
let mut producers = self.producers.lock().await;
Arc::clone(producers.entry(client_id.to_string()).or_insert_with(|| {
RelayProducer::new(
client_id.to_string(),
agent_id.to_string(),
Arc::clone(channel),
)
}))
}
pub async fn broadcast_host_connected(&self, connected: bool) {
let producers: Vec<Arc<RelayProducer>> =
self.producers.lock().await.values().cloned().collect();
for producer in producers {
tokio::spawn(async move { producer.push_host_connected(connected).await });
}
}
pub async fn note_disconnected(&self, client_id: &str) {
let producer = self.producers.lock().await.remove(client_id);
if let Some(producer) = producer {
producer.note_disconnected().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser_view::{BrowserViewRegistry, WireOwner};
use crate::browser_view::BrowserViewEvent;
use crate::session::WsSink;
use futures::StreamExt;
fn agent_channel() -> (
Arc<WsChannel>,
std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) {
let (channel, frames) = WsChannel::test_capture();
(Arc::new(channel), frames)
}
fn capture_channel() -> (
Arc<WsChannel>,
futures::channel::mpsc::UnboundedReceiver<Message>,
) {
use futures::sink::SinkExt as _;
let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
let sink: WsSink =
Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
let channel = Arc::new(WsChannel {
write: Mutex::new(sink),
pending: Mutex::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(0),
});
(channel, rx)
}
async fn next_event(
rx: &mut futures::channel::mpsc::UnboundedReceiver<Message>,
) -> BrowserViewEvent {
let frame = tokio::time::timeout(Duration::from_secs(2), rx.next())
.await
.expect("an event within the deadline")
.expect("a frame");
let text = match frame {
Message::Text(text) => text.to_string(),
other => panic!("expected a text frame, got {other:?}"),
};
let json: Value = serde_json::from_str(&text).unwrap();
assert_eq!(json["method"], "browser.view.event");
serde_json::from_value(json["params"].clone()).expect("a browser.view.event payload")
}
async fn answer_next_call(
channel: &Arc<WsChannel>,
frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
result: Value,
) -> Value {
for _ in 0..200 {
let request = frames
.lock()
.unwrap()
.iter()
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.find(|value| value.get("id").and_then(Value::as_str).is_some());
if let Some(request) = request {
let id = request["id"].as_str().unwrap().to_string();
let waiter = channel.pending.lock().await.remove(&id);
if let Some(waiter) = waiter {
let _ = waiter.send(car_proto::ToolExecuteResponse {
action_id: id,
output: Some(result),
error: None,
});
frames.lock().unwrap().clear();
return request;
}
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("no reverse call arrived on the agent channel");
}
async fn park_next_call(frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
for _ in 0..200 {
let seen = frames
.lock()
.unwrap()
.iter()
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.any(|value| value.get("id").and_then(Value::as_str).is_some());
if seen {
frames.lock().unwrap().clear();
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("no reverse call arrived on the agent channel");
}
fn presentation(owner: WireOwner, url: &str) -> WirePresentation {
let mut wire = WirePresentation::empty();
wire.revision = 3;
wire.owner = owner;
wire.url = Some(url.to_string());
wire
}
fn presentation_at(revision: u64, pending_signin: Option<&str>) -> WirePresentation {
let mut wire = WirePresentation::empty();
wire.revision = revision;
wire.owner = WireOwner::Agent;
wire.pending_signin = pending_signin.map(str::to_string);
wire.blackout_active = pending_signin.is_some();
wire
}
async fn relayed_view_watching_signin() -> (
Arc<RelayProducer>,
Arc<crate::browser_view::BrowserViewRegistry>,
Arc<crate::browser_attention::RecordingAttention>,
) {
let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
let recorder = Arc::new(crate::browser_attention::RecordingAttention::default());
registry.set_signin_attention(recorder.clone());
let (channel, _frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
(producer, registry, recorder)
}
#[tokio::test]
async fn a_relayed_sign_in_notifies_from_the_presentation_push_alone() {
let (producer, _registry, recorder) = relayed_view_watching_signin().await;
producer.push_presentation(presentation_at(1, None)).await;
assert!(
recorder.kinds().is_empty(),
"an ordinary presentation is not news"
);
producer
.push_presentation(presentation_at(
2,
Some("Sign in at https://example.com/login"),
))
.await;
assert_eq!(
recorder.calls(),
vec![(
crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
Some("conv-1".to_string()),
Some("Sign in at https://example.com/login".to_string()),
)],
"the view key and the agent's own prompt both travel"
);
producer.push_presentation(presentation_at(3, None)).await;
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_RESOLVED
],
"and the agent process resolving it clears the badge"
);
}
#[tokio::test]
async fn republishing_the_same_pending_sign_in_says_nothing() {
let (producer, _registry, recorder) = relayed_view_watching_signin().await;
let pending = presentation_at(2, Some("Sign in at https://example.com/login"));
for _ in 0..4 {
producer.push_presentation(pending.clone()).await;
}
assert_eq!(
recorder.kinds(),
vec![crate::browser_attention::BROWSER_SIGNIN_NEEDED],
"one wait is one notification, however many times it is republished"
);
let mut moved = presentation_at(3, Some("Sign in at https://example.com/login"));
moved.url = Some("https://example.com/login?step=2".into());
producer.push_presentation(moved).await;
assert_eq!(
recorder.kinds(),
vec![crate::browser_attention::BROWSER_SIGNIN_NEEDED]
);
}
#[tokio::test]
async fn one_process_with_two_views_emits_one_needed_event() {
let (producer, registry, recorder) = relayed_view_watching_signin().await;
registry
.register_relay("conv-2", Arc::clone(&producer))
.await;
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await;
assert_eq!(
recorder.calls(),
vec![(
crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
Some("conv-2".to_string()),
Some("Sign in".to_string()),
)],
"attention is process-owned and routed through the newest view"
);
assert_eq!(registry.pending_signins().await.len(), 1);
}
#[tokio::test]
async fn a_new_conversation_does_not_steal_an_announced_wait() {
let (producer, registry, recorder) = relayed_view_watching_signin().await;
producer
.push_presentation(presentation_at(2, Some("Sign in for chat one")))
.await;
registry
.register_relay("conv-2", Arc::clone(&producer))
.await;
assert_eq!(
recorder.calls(),
vec![(
crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
Some("conv-1".to_string()),
Some("Sign in for chat one".to_string()),
)],
"a later turn cannot move an active badge off the blocked chat"
);
assert_eq!(
registry.pending_signins().await[0].conversation_id,
"conv-1"
);
producer.push_presentation(presentation_at(3, None)).await;
producer
.push_presentation(presentation_at(4, Some("Sign in for chat two")))
.await;
assert_eq!(
recorder.calls().last().unwrap().1.as_deref(),
Some("conv-2"),
"after resolution the newest registered chat owns the next wait"
);
}
#[tokio::test]
async fn changing_the_pending_prompt_refreshes_operator_attention() {
let (producer, registry, recorder) = relayed_view_watching_signin().await;
producer
.push_presentation(presentation_at(2, Some("Sign in at A")))
.await;
producer
.push_presentation(presentation_at(3, Some("Sign in at B")))
.await;
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
]
);
assert_eq!(registry.pending_signins().await[0].message, "Sign in at B");
}
#[tokio::test]
async fn the_agent_process_going_away_resolves_its_pending_sign_in() {
let (producer, _registry, recorder) = relayed_view_watching_signin().await;
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await;
producer.note_disconnected().await;
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_RESOLVED
]
);
}
#[tokio::test]
async fn a_registry_with_no_attention_sink_relays_but_announces_nothing() {
let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
let (channel, _frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await;
assert_eq!(
view.snapshot_for_test().await.0.pending_signin.as_deref(),
Some("Sign in")
);
assert!(
producer.signin_snapshot().await.is_none(),
"no sink means nothing was ever announced"
);
assert!(registry.pending_signins().await.is_empty());
}
#[tokio::test]
async fn retiring_the_routed_view_keeps_the_sink_for_the_views_that_remain() {
let (producer, registry, recorder) = relayed_view_watching_signin().await;
registry
.register_relay("conv-2", Arc::clone(&producer))
.await;
producer.detach_signin_attention(Some("conv-2")).await;
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await;
assert_eq!(
recorder.calls(),
vec![(
crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
Some("conv-1".to_string()),
Some("Sign in".to_string()),
)],
"the route moves to a surviving view instead of nulling the sink"
);
}
#[tokio::test]
async fn retiring_the_routed_view_hands_the_route_to_the_newest_turn() {
let (producer, registry, recorder) = relayed_view_watching_signin().await;
producer
.push_presentation(presentation_at(
2,
Some("Sign in at https://example.com/login"),
))
.await;
for turn in 2..=9 {
registry
.register_relay(format!("conv-{turn}"), Arc::clone(&producer))
.await;
}
producer.detach_signin_attention(Some("conv-1")).await;
assert_eq!(
recorder.calls(),
vec![
(
crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
Some("conv-1".to_string()),
Some("Sign in at https://example.com/login".to_string()),
),
(
crate::browser_attention::BROWSER_SIGNIN_RESOLVED.to_string(),
Some("conv-1".to_string()),
None,
),
(
crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
Some("conv-9".to_string()),
Some("Sign in at https://example.com/login".to_string()),
),
],
"the badge moves to the newest turn, not the oldest survivor, and \
the still-blocked browser is re-raised rather than left cleared"
);
assert_eq!(
producer
.signin_snapshot()
.await
.expect("the browser is still blocked")
.conversation_id,
"conv-9"
);
assert_eq!(
registry.pending_signins().await[0].conversation_id,
"conv-9",
"and a host reconnecting mid-wait is pointed at the same turn"
);
}
#[tokio::test]
async fn a_registration_landing_during_a_detach_still_leaves_a_live_sink() {
let (producer, _registry, recorder) = relayed_view_watching_signin().await;
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await;
let (scratch_channel, _scratch_frames) = agent_channel();
let scratch_producer =
RelayProducer::new("other-conn".into(), "car-assistant".into(), scratch_channel);
let scratch_registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
let view_two = scratch_registry
.register_relay("conv-2", Arc::clone(&scratch_producer))
.await;
let guard = producer.signin_attention.lock().await;
let detach = tokio::spawn({
let producer = Arc::clone(&producer);
async move { producer.detach_signin_attention(Some("conv-1")).await }
});
tokio::task::yield_now().await;
producer.attach_view(&view_two).await;
drop(guard);
detach.await.unwrap();
assert_eq!(
producer
.signin_snapshot()
.await
.expect("a live view remains, so the wait keeps a route")
.conversation_id,
"conv-2"
);
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_RESOLVED,
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
],
"the badge moves to the surviving view instead of the sink being nulled"
);
}
#[tokio::test]
async fn retiring_the_last_view_detaches_the_sink() {
let (producer, _registry, recorder) = relayed_view_watching_signin().await;
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await;
producer.detach_signin_attention(Some("conv-1")).await;
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_RESOLVED
],
"the wait it owned is resolved on the way out"
);
producer
.push_presentation(presentation_at(4, Some("Sign in again")))
.await;
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_RESOLVED
],
"nothing left to serve, so nothing left to announce"
);
}
#[tokio::test]
async fn a_stalled_broadcast_does_not_block_the_next_presentation() {
struct BlockingAttention {
entered: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}
#[async_trait::async_trait]
impl SignInAttention for BlockingAttention {
async fn signin_needed(&self, _conversation_id: Option<&str>, _message: &str) {
self.entered.notify_one();
self.release.notified().await;
}
async fn signin_resolved(&self, _conversation_id: Option<&str>) {}
}
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
registry.set_signin_attention(Arc::new(BlockingAttention {
entered: Arc::clone(&entered),
release: Arc::clone(&release),
}));
let (channel, _frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let blocked = tokio::spawn({
let producer = Arc::clone(&producer);
async move {
producer
.push_presentation(presentation_at(2, Some("Sign in")))
.await
}
});
entered.notified().await;
let mut moved = presentation_at(3, Some("Sign in"));
moved.url = Some("https://example.com/login?step=2".into());
tokio::time::timeout(Duration::from_secs(5), producer.push_presentation(moved))
.await
.expect("a non-transition push must not queue behind a stalled broadcast");
release.notify_one();
blocked.await.unwrap();
}
#[tokio::test]
async fn input_crosses_to_the_agent_process_as_a_reverse_call_and_returns_its_answer() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let relayed = tokio::spawn({
let producer = Arc::clone(&producer);
async move { producer.input(ViewInput::TabOpen).await }
});
let request =
answer_next_call(&producer.channel, &frames, json!({ "tab_id": "tab-4" })).await;
assert_eq!(request["method"], "agent.browser.input");
assert_eq!(request["params"]["op"], "tab_open");
assert_eq!(relayed.await.unwrap().unwrap().as_deref(), Some("tab-4"));
}
#[tokio::test]
async fn the_agent_s_error_reaches_the_caller_verbatim() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let relayed = tokio::spawn({
let producer = Arc::clone(&producer);
async move { producer.input(ViewInput::Click { x: 1.0, y: 2.0 }).await }
});
for _ in 0..200 {
let id = frames
.lock()
.unwrap()
.iter()
.filter_map(|t| serde_json::from_str::<Value>(t).ok())
.find_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string));
if let Some(id) = id {
if let Some(waiter) = producer.channel.pending.lock().await.remove(&id) {
let _ = waiter.send(car_proto::ToolExecuteResponse {
action_id: id,
output: None,
error: Some("no browser is running for this view".into()),
});
break;
}
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(
relayed.await.unwrap().unwrap_err(),
"no browser is running for this view"
);
}
#[tokio::test]
async fn control_relays_the_transition_and_brings_its_effects_back() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let relayed = tokio::spawn({
let producer = Arc::clone(&producer);
async move { producer.control(ViewControl::HolderDisconnected).await }
});
let request = answer_next_call(
&producer.channel,
&frames,
json!({
"presentation": presentation(WireOwner::User, "https://x.test/"),
"effects": [{ "effect": "start_grace_period" }],
}),
)
.await;
assert_eq!(request["method"], "agent.browser.control");
assert_eq!(request["params"]["action"], "holder_disconnected");
assert_eq!(
relayed.await.unwrap(),
Ok((ControlOwner::User, vec![ControlEffect::StartGracePeriod])),
"the daemon owns the clock, so the effect has to cross back — and the owner \
comes from THIS response, not from a re-read of the cache"
);
assert_eq!(
producer.control_status().await.owner,
crate::assistant::browser_control::ControlOwner::User
);
}
#[tokio::test]
async fn a_dead_producer_refuses_input_instead_of_hanging_on_a_call() {
let (channel, _frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
producer
.set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
.await;
producer.note_disconnected().await;
assert!(!producer.is_alive());
let err = producer
.input(ViewInput::Navigate {
url: "https://y.test".into(),
})
.await
.unwrap_err();
assert_eq!(err, PRODUCER_GONE);
let cleared = producer.presentation().await;
assert_eq!(cleared.owner, WireOwner::None);
assert_eq!(cleared.url, None);
assert!(
cleared.revision > 3,
"the revision never moves backwards, even when the browser vanishes"
);
}
#[tokio::test]
async fn a_control_transition_reports_the_owner_from_its_own_answer() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let relayed = {
let producer = Arc::clone(&producer);
tokio::spawn(async move { producer.control(ViewControl::TakeControl).await })
};
answer_next_call(
&producer.channel,
&frames,
json!({
"presentation": presentation(WireOwner::User, "https://x.test/"),
"effects": [],
}),
)
.await;
let (owner, _) = relayed.await.unwrap().unwrap();
assert_eq!(
owner,
ControlOwner::User,
"the transition landed on User, and that is what the caller must act on"
);
producer
.push_presentation(presentation(WireOwner::Agent, "https://x.test/"))
.await;
assert_eq!(
producer.control_status().await.owner,
ControlOwner::Agent,
"the cache really can go backwards, which is why it cannot be the decider"
);
}
#[tokio::test(start_paused = true)]
async fn a_disconnect_arms_the_grace_timer_even_when_the_transition_never_lands() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let taking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-1").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({
"presentation": presentation(WireOwner::User, "https://x.test/"),
"effects": [],
}),
)
.await;
taking.await.unwrap().expect("take control");
producer.note_disconnected().await;
let before = view.grace_generation_for_test().await;
view.note_disconnect("host-1", false).await;
let after = view.grace_generation_for_test().await;
assert!(
view.control_holder_for_test().await.is_none(),
"the holder is cleared at disconnect — the connection is provably gone"
);
assert_eq!(
after - before,
2,
"the timer must be armed from what the daemon knows, not from the reply of a \
process that is not answering"
);
}
#[tokio::test(start_paused = true)]
async fn a_take_control_inside_the_disconnect_window_is_not_revoked_by_the_grace_timer() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let taking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-1").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
)
.await;
taking.await.unwrap().expect("take control");
let disconnecting = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.note_disconnect("host-1", false).await })
};
park_next_call(&frames).await;
disconnecting.await.unwrap();
let retaking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-2").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
)
.await;
retaking.await.unwrap().expect("re-take control");
assert_eq!(
view.control_holder_for_test().await.as_deref(),
Some("host-2")
);
tokio::time::sleep(RELAY_CALL_TIMEOUT + Duration::from_secs(1)).await;
tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
assert_eq!(
view.control_holder_for_test().await.as_deref(),
Some("host-2"),
"a holder who took control legitimately must not be revoked by a timer armed \
for the connection they replaced"
);
}
fn count_control_calls(
frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
action: &str,
) -> usize {
frames
.lock()
.unwrap()
.iter()
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.filter(|value| {
value["method"] == "agent.browser.control" && value["params"]["action"] == action
})
.count()
}
#[tokio::test]
async fn a_holder_that_was_also_watching_reconciles_its_disconnect_exactly_once() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let taking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-1").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
)
.await;
taking.await.unwrap().expect("take control");
let (host_channel, _host_rx) = capture_channel();
view.subscribe_for_test("host-1", host_channel).await;
frames.lock().unwrap().clear();
tokio::time::timeout(
Duration::from_secs(2),
registry.drop_subscriptions_for_client("host-1"),
)
.await
.expect("teardown must not park on a relay call nothing is going to answer");
for _ in 0..200 {
if count_control_calls(&frames, "holder_disconnected") > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
count_control_calls(&frames, "holder_disconnected"),
1,
"exactly one path owns a disconnect — two arm two grace timers with different \
semantics and let the relay decide which one survives"
);
}
#[tokio::test(start_paused = true)]
async fn a_drawer_that_returns_inside_the_window_cancels_its_own_grace_expiry() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let taking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-1").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
)
.await;
taking.await.unwrap().expect("take control");
let (host_channel, _host_rx) = capture_channel();
view.subscribe_for_test("host-1", host_channel).await;
registry.drop_subscriptions_for_client("host-1").await;
let (again, _again_rx) = capture_channel();
view.subscribe_for_test("host-2", again).await;
frames.lock().unwrap().clear();
tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
assert_eq!(
count_control_calls(&frames, "grace_expired"),
0,
"a drawer watching inside the window is the person coming back — expiring under \
them resolves their pending sign-in as failed and hands the page to the agent"
);
}
async fn pending_call_id(
frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
action: &str,
) -> String {
for _ in 0..200 {
let found = frames
.lock()
.unwrap()
.iter()
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.find(|value| {
value["method"] == "agent.browser.control"
&& value["params"]["action"] == action
&& value.get("id").and_then(Value::as_str).is_some()
})
.map(|value| value["id"].as_str().unwrap().to_string());
if let Some(id) = found {
return id;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("no '{action}' reverse call arrived on the agent channel");
}
async fn answer_call_by_id(channel: &Arc<WsChannel>, id: &str, result: Value) {
let waiter = channel
.pending
.lock()
.await
.remove(id)
.expect("the parked call is still pending");
let _ = waiter.send(car_proto::ToolExecuteResponse {
action_id: id.to_string(),
output: Some(result),
error: None,
});
}
#[tokio::test(start_paused = true)]
async fn an_answered_holder_disconnect_does_not_re_arm_over_a_landed_take_control() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let taking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-1").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
)
.await;
taking.await.unwrap().expect("take control");
view.note_disconnect("host-1", false).await;
let disconnect_id = pending_call_id(&frames, "holder_disconnected").await;
frames.lock().unwrap().clear();
let retaking = {
let view = Arc::clone(&view);
tokio::spawn(async move { view.take_control_for_test("host-2").await })
};
answer_next_call(
&producer.channel,
&frames,
json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
)
.await;
retaking.await.unwrap().expect("re-take control");
assert_eq!(
view.control_holder_for_test().await.as_deref(),
Some("host-2")
);
let generation_after_retake = view.grace_generation_for_test().await;
answer_call_by_id(
&producer.channel,
&disconnect_id,
json!({
"presentation": presentation(WireOwner::User, "https://x.test/"),
"effects": [{ "effect": "start_grace_period" }],
}),
)
.await;
for _ in 0..200 {
if view.grace_generation_for_test().await != generation_after_retake {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(
view.grace_generation_for_test().await,
generation_after_retake,
"the disconnect already armed its clock before relaying; re-arming here would \
capture host-2's generation and disarm the stale-expiry check"
);
tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
assert_eq!(
view.control_holder_for_test().await.as_deref(),
Some("host-2"),
"a holder who took control legitimately must survive a timer armed for the \
connection they replaced, however late that connection's process answers"
);
}
#[tokio::test]
async fn an_older_presentation_never_rewinds_the_cache_the_input_gate_reads() {
let (channel, _frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let mut taken = presentation(WireOwner::User, "https://x.test/");
taken.revision = 9;
producer.push_presentation(taken).await;
assert_eq!(producer.control_status().await.owner, ControlOwner::User);
let mut stale = presentation(WireOwner::Agent, "https://x.test/");
stale.revision = 8;
producer.set_presentation(stale).await;
assert_eq!(
producer.control_status().await.owner,
ControlOwner::User,
"the person still holds control, so their input must still be admitted"
);
let mut newer = presentation(WireOwner::Agent, "https://x.test/");
newer.revision = 10;
producer.push_presentation(newer).await;
assert_eq!(producer.control_status().await.owner, ControlOwner::Agent);
}
#[tokio::test]
async fn the_capture_signal_always_matches_the_settled_watcher_count() {
let (channel, _frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let mut capture = producer.capture.subscribe();
producer.start_capture(); producer.stop_capture(); assert!(
!*capture.borrow_and_update(),
"count is 0, so capture is off"
);
producer.start_capture();
assert!(*capture.borrow_and_update());
producer.start_capture();
producer.stop_capture();
assert!(
*capture.borrow_and_update(),
"one watcher remains, so the process must still be capturing"
);
producer.stop_capture();
assert!(!*capture.borrow_and_update());
}
#[tokio::test]
async fn capture_is_asked_for_only_while_somebody_is_watching() {
let (channel, frames) = agent_channel();
let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
let mut capture = producer.capture.subscribe();
producer.start_capture();
capture
.changed()
.await
.expect("the first watcher changes capture");
capture.borrow_and_update();
let request = answer_next_call(&producer.channel, &frames, json!({ "ok": true })).await;
assert_eq!(request["method"], "agent.browser.capture");
assert_eq!(request["params"]["enabled"], true);
producer.start_capture();
assert!(
!capture.has_changed().expect("capture sender remains live"),
"capture is a producer-level state, not a per-subscriber one; no signal was published"
);
tokio::time::sleep(Duration::from_millis(30)).await;
assert!(
frames.lock().unwrap().is_empty(),
"capture is a producer-level state, not a per-subscriber one"
);
producer.stop_capture();
assert!(
!capture.has_changed().expect("capture sender remains live"),
"one watcher left, one remains — no stop signal was published"
);
tokio::time::sleep(Duration::from_millis(30)).await;
assert!(
frames.lock().unwrap().is_empty(),
"one watcher left, one remains — the process keeps capturing"
);
producer.stop_capture();
capture
.changed()
.await
.expect("the final watcher changes capture");
assert!(!*capture.borrow_and_update());
let request = answer_next_call(&producer.channel, &frames, json!({ "ok": true })).await;
assert_eq!(request["params"]["enabled"], false);
}
fn broken_channel() -> Arc<WsChannel> {
use futures::sink::SinkExt as _;
let (tx, rx) = futures::channel::mpsc::channel::<Message>(1);
drop(rx);
let sink: WsSink =
Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
Arc::new(WsChannel {
write: Mutex::new(sink),
pending: Mutex::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(0),
})
}
#[tokio::test]
async fn a_control_transition_that_never_reached_the_process_fails_and_changes_nothing() {
let (state, _temp) = test_state().await;
let (host, _rx) = host_session(&state, "host-1").await;
let producer = state
.browser_views
.producer_for("agent-conn", "car-assistant", &broken_channel())
.await;
producer
.set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
.await;
let view = state
.browser_views
.register_relay("conv-1", Arc::clone(&producer))
.await;
let err = crate::browser_view::handle_take_control(
&request(
"browser.view.take_control",
json!({ "conversation_id": "conv-1" }),
),
&host,
&state,
)
.await
.expect_err("a transition that did not land must not report success");
assert!(err.contains("unreachable"), "got: {err}");
assert_eq!(
view.snapshot_for_test().await.0.owner,
WireOwner::Agent,
"the drawer must not be told the user took control of a browser that never heard"
);
let err = crate::browser_view::handle_input(
crate::browser_view::InputOp::Click,
&request(
"browser.view.click",
json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
),
&host,
&state,
)
.await
.unwrap_err();
assert!(
err.contains("take_control"),
"both sides agree the agent still holds it; got: {err}"
);
}
#[tokio::test]
async fn a_reconnecting_process_republishes_between_turns_without_a_new_user_turn() {
let (state, _temp) = test_state().await;
let (agent, _frames) = agent_session(&state, "conn-1", "car-assistant", "conv-1").await;
let (host, mut host_rx) = host_session(&state, "host-1").await;
handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1",
"presentation": presentation(WireOwner::Agent, "https://x.test/") }),
),
&agent,
&state,
)
.await
.unwrap();
let subscribed = crate::browser_view::handle_subscribe(
&request(
"browser.view.subscribe",
json!({ "conversation_id": "conv-1" }),
),
&host,
&state,
)
.await
.unwrap();
let before = subscribed["cursor"].as_u64().unwrap();
state.chat_sessions.lock().await.remove("conv-1");
state.remove_session("conn-1").await;
assert_eq!(
state
.browser_views
.get(Some("conv-1"))
.await
.unwrap()
.snapshot_for_test()
.await
.0
.owner,
WireOwner::None,
"the view reports the browser as gone while the process is away"
);
let (channel, _frames) = agent_channel();
let reconnected = state.create_session("conn-2", channel).await.unwrap();
*reconnected.agent_id.lock().await = Some("car-assistant".to_string());
handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1",
"presentation": presentation(WireOwner::Agent, "https://back.test/") }),
),
&reconnected,
&state,
)
.await
.expect("the agent that established this conversation may republish it");
let view = state.browser_views.get(Some("conv-1")).await.unwrap();
assert_eq!(
view.snapshot_for_test().await.0.url.as_deref(),
Some("https://back.test/")
);
assert_eq!(
view.subscriber_count_for_test().await,
1,
"the drawer came across without re-subscribing"
);
let mut seen = next_event(&mut host_rx).await;
while seen.cursor <= before {
seen = next_event(&mut host_rx).await;
}
assert!(seen.cursor > before, "the cursor never moves backwards");
}
#[tokio::test]
async fn republishing_is_still_refused_to_an_agent_that_never_served_the_conversation() {
let (state, _temp) = test_state().await;
let (agent, _frames) = agent_session(&state, "conn-1", "car-assistant", "conv-1").await;
handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1" }),
),
&agent,
&state,
)
.await
.unwrap();
state.chat_sessions.lock().await.remove("conv-1");
let (channel, _frames) = agent_channel();
let impostor = state.create_session("conn-x", channel).await.unwrap();
*impostor.agent_id.lock().await = Some("some-other-agent".to_string());
let err = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1" }),
),
&impostor,
&state,
)
.await
.unwrap_err();
assert!(
err.contains("is served by agent 'car-assistant'"),
"got: {err}"
);
let err = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "never-seen" }),
),
&impostor,
&state,
)
.await
.unwrap_err();
assert!(err.contains("not an active chat session"), "got: {err}");
}
#[test]
fn a_conversation_claim_needs_a_live_turn_or_a_binding_this_agent_established() {
assert!(
authorize_conversation_claim("c", "a", Some("a"), None).is_ok(),
"the agent the daemon dispatched the turn to"
);
assert!(authorize_conversation_claim("c", "a", None, Some("a")).is_ok());
assert!(authorize_conversation_claim("c", "a", Some("b"), Some("a")).is_err());
assert!(authorize_conversation_claim("c", "a", None, Some("b")).is_err());
assert!(authorize_conversation_claim("c", "a", None, None).is_err());
}
#[test]
fn every_input_round_trips_through_the_wire() {
for input in [
ViewInput::Navigate {
url: "https://x.test/".into(),
},
ViewInput::Click { x: 1.5, y: 2.5 },
ViewInput::Type {
text: "hello".into(),
},
ViewInput::Keypress {
key: "Enter".into(),
modifiers: vec![Modifier::Meta, Modifier::Shift],
},
ViewInput::Scroll { delta_y: -120 },
ViewInput::Paste {
text: "pasted".into(),
},
ViewInput::Back,
ViewInput::Forward,
ViewInput::Reload,
ViewInput::TabOpen,
ViewInput::TabClose {
tab_id: "tab-2".into(),
},
ViewInput::TabSwitch {
tab_id: "tab-3".into(),
},
] {
let wire = input_to_wire(&input);
assert_eq!(
input_from_wire(&wire).expect("decodes"),
input,
"round trip failed for {wire}"
);
}
}
#[test]
fn every_control_action_and_effect_round_trips() {
for control in [
ViewControl::TakeControl,
ViewControl::HandBack,
ViewControl::RunEnded,
ViewControl::HolderDisconnected,
ViewControl::GraceExpired,
] {
assert_eq!(
control_from_wire(control_to_wire(control)).unwrap(),
control
);
}
let effects = vec![
ControlEffect::StartGracePeriod,
ControlEffect::SignInResolved { signed_in: true },
];
assert_eq!(effects_from_wire(&effects_to_wire(&effects)), effects);
}
#[test]
fn an_unknown_effect_is_dropped_rather_than_failing_the_transition() {
let wire = json!([{ "effect": "teleport" }, { "effect": "start_grace_period" }]);
assert_eq!(
effects_from_wire(&wire),
vec![ControlEffect::StartGracePeriod]
);
}
#[test]
fn an_unknown_input_op_is_a_clean_error() {
let err = input_from_wire(&json!({ "op": "read_dom" })).unwrap_err();
assert!(err.contains("unknown agent.browser.input op"), "got: {err}");
}
#[tokio::test]
async fn a_registered_conversation_resolves_to_the_process_s_browser() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
producer
.set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
.await;
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let found = registry.get(Some("conv-1")).await.expect("registered");
assert!(Arc::ptr_eq(&view, &found));
let (snapshot, _) = found.snapshot_for_test().await;
assert_eq!(snapshot.url.as_deref(), Some("https://x.test/"));
assert_eq!(snapshot.owner, WireOwner::Agent);
}
#[tokio::test]
async fn re_registering_the_same_conversation_is_a_no_op_for_the_drawer() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
let first = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let second = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
assert!(
Arc::ptr_eq(&first, &second),
"the same process re-claiming its own conversation keeps the view"
);
}
#[tokio::test]
async fn one_process_backs_every_conversation_it_registers() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
registry
.register_relay("conv-2", Arc::clone(&producer))
.await;
producer
.push_presentation(presentation(WireOwner::Agent, "https://shared.test/"))
.await;
for key in ["conv-1", "conv-2"] {
let view = registry.get(Some(key)).await.expect("registered");
assert_eq!(
view.snapshot_for_test().await.0.url.as_deref(),
Some("https://shared.test/"),
"a supervised process has ONE browser; both of its conversations show it"
);
}
}
#[tokio::test]
async fn two_processes_two_conversations_never_see_each_other() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel_a, _frames_a) = agent_channel();
let (channel_b, _frames_b) = agent_channel();
let alpha = registry
.producer_for("conn-a", "agent-alpha", &channel_a)
.await;
let beta = registry
.producer_for("conn-b", "agent-beta", &channel_b)
.await;
let view_a = registry.register_relay("conv-a", Arc::clone(&alpha)).await;
let view_b = registry.register_relay("conv-b", Arc::clone(&beta)).await;
let (host_a, mut rx_a) = capture_channel();
let (host_b, mut rx_b) = capture_channel();
view_a.subscribe_for_test("host-1", host_a).await;
view_b.subscribe_for_test("host-1", host_b).await;
alpha
.push_presentation(presentation(WireOwner::Agent, "https://alpha.test/"))
.await;
beta.push_presentation(presentation(WireOwner::User, "https://beta.test/"))
.await;
alpha
.push_frame(WireFrame {
jpeg_base64: "QQ==".into(),
width: 800,
height: 600,
device_pixel_ratio: 1.0,
captured_at: 0.0,
})
.await;
assert_eq!(
view_a.snapshot_for_test().await.0.url.as_deref(),
Some("https://alpha.test/")
);
assert_eq!(
view_b.snapshot_for_test().await.0.url.as_deref(),
Some("https://beta.test/")
);
match next_event(&mut rx_a).await.payload {
crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
assert_eq!(presentation.url.as_deref(), Some("https://alpha.test/"));
}
crate::browser_view::BrowserViewPayload::Frame { .. } => {
panic!("expected alpha's presentation first")
}
}
match next_event(&mut rx_a).await.payload {
crate::browser_view::BrowserViewPayload::Frame { frame } => {
assert_eq!(frame.jpeg_base64, "QQ==");
}
crate::browser_view::BrowserViewPayload::Presentation { .. } => {
panic!("expected alpha's frame")
}
}
match next_event(&mut rx_b).await.payload {
crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
assert_eq!(presentation.url.as_deref(), Some("https://beta.test/"));
assert_eq!(presentation.owner, WireOwner::User);
}
crate::browser_view::BrowserViewPayload::Frame { .. } => {
panic!("beta's drawer must never receive alpha's frame")
}
}
assert!(
tokio::time::timeout(Duration::from_millis(100), rx_b.next())
.await
.is_err(),
"nothing else crossed between the two conversations"
);
}
#[tokio::test]
async fn a_restarted_process_replaces_the_view_and_carries_the_drawer_across() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (first_channel, _first_frames) = agent_channel();
let first = registry
.producer_for("agent-conn-1", "car-assistant", &first_channel)
.await;
let view = registry.register_relay("conv-1", Arc::clone(&first)).await;
let (host, mut host_rx) = capture_channel();
view.subscribe_for_test("host-1", host).await;
first
.push_presentation(presentation(WireOwner::Agent, "https://x.test/"))
.await;
let before = next_event(&mut host_rx).await.cursor;
registry.note_producer_disconnected("agent-conn-1").await;
let (second_channel, _second_frames) = agent_channel();
let second = registry
.producer_for("agent-conn-2", "car-assistant", &second_channel)
.await;
second
.set_presentation(presentation(
WireOwner::Agent,
"https://after-restart.test/",
))
.await;
let replacement = registry.register_relay("conv-1", Arc::clone(&second)).await;
assert!(
!Arc::ptr_eq(&view, &replacement),
"a different process is a different producer, so a different view"
);
assert_eq!(
replacement.subscriber_count_for_test().await,
1,
"the drawer came across without re-subscribing"
);
let mut previous_cursor = before;
loop {
let seen = next_event(&mut host_rx).await;
assert_eq!(
seen.cursor,
previous_cursor + 1,
"the cursor sequence must be contiguous and monotonic"
);
previous_cursor = seen.cursor;
if matches!(
seen.payload,
crate::browser_view::BrowserViewPayload::Presentation { ref presentation }
if presentation.url.as_deref() == Some("https://after-restart.test/")
) {
break;
}
}
assert_eq!(
replacement.snapshot_for_test().await.0.url.as_deref(),
Some("https://after-restart.test/")
);
}
#[tokio::test]
async fn a_pushed_frame_reaches_the_drawer_with_the_next_cursor() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let (host, mut host_rx) = capture_channel();
let (_, cursor) = view.subscribe_for_test("host-1", host).await;
producer
.push_frame(WireFrame {
jpeg_base64: "AQID".into(),
width: 1920,
height: 1080,
device_pixel_ratio: 2.0,
captured_at: 1.5,
})
.await;
let event = next_event(&mut host_rx).await;
assert_eq!(event.cursor, cursor + 1);
assert_eq!(event.conversation_id.as_deref(), Some("conv-1"));
match event.payload {
crate::browser_view::BrowserViewPayload::Frame { frame } => {
assert_eq!(frame.jpeg_base64, "AQID");
assert_eq!(frame.width, 1920);
assert_eq!(frame.device_pixel_ratio, 2.0);
}
crate::browser_view::BrowserViewPayload::Presentation { .. } => {
panic!("expected a frame event")
}
}
}
#[tokio::test]
async fn a_registration_retires_this_producer_s_oldest_views() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
for turn in 0..MAX_VIEWS_PER_PRODUCER {
let key = format!("turn-{turn}");
registry.bind_conversation(&key, "car-assistant").await;
registry.register_relay(key, Arc::clone(&producer)).await;
}
assert!(
registry.get(Some("turn-0")).await.is_some(),
"precondition: nothing is retired while the producer is within its cap"
);
registry.bind_conversation("turn-8", "car-assistant").await;
registry
.register_relay("turn-8", Arc::clone(&producer))
.await;
assert!(
registry.get(Some("turn-0")).await.is_none(),
"the oldest turn's view must be retired, not accumulated"
);
assert!(
registry.conversation_owner("turn-0").await.is_none(),
"and its binding with it — nothing can re-register a key past the cap"
);
assert!(
registry.get(Some("turn-1")).await.is_some(),
"only what is PAST the cap goes"
);
assert!(registry.get(Some("turn-8")).await.is_some());
}
#[tokio::test]
async fn a_retired_view_somebody_is_watching_survives() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
let watched = registry
.register_relay("turn-0", Arc::clone(&producer))
.await;
let (host, _rx) = capture_channel();
watched.subscribe_for_test("host-1", host).await;
for turn in 1..=MAX_VIEWS_PER_PRODUCER {
registry
.register_relay(format!("turn-{turn}"), Arc::clone(&producer))
.await;
}
assert!(
registry.get(Some("turn-0")).await.is_some(),
"a view the drawer is subscribed to is never taken out from under it"
);
}
#[tokio::test]
async fn a_frame_only_reaches_views_somebody_is_watching() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
let unwatched = registry
.register_relay("turn-0", Arc::clone(&producer))
.await;
let watched = registry
.register_relay("turn-1", Arc::clone(&producer))
.await;
let (host, mut host_rx) = capture_channel();
let (_snapshot, cursor) = watched.subscribe_for_test("host-1", host).await;
let (_, unwatched_cursor) = unwatched.snapshot_for_test().await;
producer
.push_frame(WireFrame {
jpeg_base64: "AQID".into(),
width: 8,
height: 8,
device_pixel_ratio: 1.0,
captured_at: 0.5,
})
.await;
let event = next_event(&mut host_rx).await;
assert_eq!(event.cursor, cursor + 1, "the watched view is served");
let (_, after) = unwatched.snapshot_for_test().await;
assert_eq!(
after, unwatched_cursor,
"a view nobody is watching pays nothing — not even a cursor bump"
);
}
#[tokio::test]
async fn a_disconnected_producer_is_dropped_from_the_registry_and_clears_its_views() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
producer
.set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
.await;
let view = registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
let (host, _rx) = capture_channel();
view.subscribe_for_test("host-1", host).await;
registry.note_producer_disconnected("agent-conn").await;
assert!(registry.producer("agent-conn").await.is_none());
let view = registry
.get(Some("conv-1"))
.await
.expect("the view stays so a restarted process can replace it");
let (snapshot, _) = view.snapshot_for_test().await;
assert_eq!(snapshot.owner, WireOwner::None);
assert_eq!(snapshot.url, None);
}
#[tokio::test]
async fn a_disconnected_producer_s_unwatched_views_are_released_with_their_socket() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let (channel, _frames) = agent_channel();
let producer = registry
.producer_for("agent-conn", "car-assistant", &channel)
.await;
let weak = Arc::downgrade(&producer);
registry
.register_relay("conv-1", Arc::clone(&producer))
.await;
drop(producer);
registry.note_producer_disconnected("agent-conn").await;
assert!(
registry.get(Some("conv-1")).await.is_none(),
"an unwatched view for a process that is gone must not stay registered"
);
assert!(
weak.upgrade().is_none(),
"and the producer — with the dead connection's WsChannel — must actually be released"
);
}
async fn test_state() -> (Arc<ServerState>, tempfile::TempDir) {
let temp = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::with_config(
crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
));
(state, temp)
}
fn request(method: &str, params: Value) -> JsonRpcMessage {
JsonRpcMessage {
jsonrpc: "2.0".to_string(),
id: json!(1),
method: Some(method.to_string()),
params,
result: None,
error: None,
}
}
fn notification(method: &str, params: Value) -> JsonRpcMessage {
JsonRpcMessage {
jsonrpc: "2.0".to_string(),
id: Value::Null,
method: Some(method.to_string()),
params,
result: None,
error: None,
}
}
async fn agent_session(
state: &Arc<ServerState>,
client_id: &str,
agent_id: &str,
conversation: &str,
) -> (
Arc<ClientSession>,
std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) {
let (channel, frames) = agent_channel();
let session = state.create_session(client_id, channel).await.unwrap();
*session.agent_id.lock().await = Some(agent_id.to_string());
state.chat_sessions.lock().await.insert(
conversation.to_string(),
crate::session::ChatSession {
agent_id: agent_id.to_string(),
host_client_id: "host-1".to_string(),
created_at: 0,
local_cancel: None,
},
);
(session, frames)
}
async fn host_session(
state: &Arc<ServerState>,
client_id: &str,
) -> (
Arc<ClientSession>,
futures::channel::mpsc::UnboundedReceiver<Message>,
) {
let (channel, rx) = capture_channel();
let session = state.create_session(client_id, channel).await.unwrap();
session
.is_host
.store(true, std::sync::atomic::Ordering::Release);
(session, rx)
}
#[tokio::test]
async fn a_connection_that_is_not_a_supervised_agent_cannot_publish_a_browser() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let err = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1" }),
),
&session,
&state,
)
.await
.unwrap_err();
assert!(err.contains("not a supervised agent"), "got: {err}");
assert!(state.browser_views.get(Some("conv-1")).await.is_none());
}
#[tokio::test]
async fn an_agent_cannot_claim_a_conversation_it_is_not_serving() {
let (state, _temp) = test_state().await;
let (session, _frames) =
agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
state.chat_sessions.lock().await.insert(
"conv-other".to_string(),
crate::session::ChatSession {
agent_id: "some-other-agent".to_string(),
host_client_id: "host-1".to_string(),
created_at: 0,
local_cancel: None,
},
);
let err = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-other" }),
),
&session,
&state,
)
.await
.unwrap_err();
assert!(
err.contains("is served by agent 'some-other-agent'"),
"got: {err}"
);
let err = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "ghost" }),
),
&session,
&state,
)
.await
.unwrap_err();
assert!(err.contains("not an active chat session"), "got: {err}");
assert!(state.browser_views.get(Some("ghost")).await.is_none());
}
#[tokio::test]
async fn the_drawer_subscribes_by_conversation_and_receives_the_process_s_pushes() {
let (state, _temp) = test_state().await;
let (agent, agent_frames) =
agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
let (host, mut host_rx) = host_session(&state, "host-1").await;
let out = handle_producer_register(
&request(
"browser.producer.register",
json!({
"conversation_id": "conv-1",
"presentation": presentation(WireOwner::Agent, "https://x.test/"),
}),
),
&agent,
&state,
)
.await
.expect("the agent may publish the conversation it serves");
assert_eq!(out["ok"], true);
let snapshot = crate::browser_view::handle_subscribe(
&request(
"browser.view.subscribe",
json!({ "conversation_id": "conv-1" }),
),
&host,
&state,
)
.await
.expect("a supervised agent's browser is subscribable by conversation");
assert_eq!(snapshot["standing_session"], false);
assert_eq!(snapshot["presentation"]["url"], "https://x.test/");
assert_eq!(snapshot["presentation"]["owner"], "agent");
let cursor = snapshot["cursor"].as_u64().unwrap();
let capture = answer_next_call(&agent.channel, &agent_frames, json!({ "ok": true })).await;
assert_eq!(capture["method"], "agent.browser.capture");
assert_eq!(capture["params"]["enabled"], true);
assert!(
try_handle_producer_push(
¬ification(
"browser.producer.presentation",
json!({ "presentation": presentation(WireOwner::Agent, "https://moved.test/") }),
),
&state,
&agent,
)
.await
);
let event = next_event(&mut host_rx).await;
assert_eq!(event.cursor, cursor + 1);
match event.payload {
crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
assert_eq!(presentation.url.as_deref(), Some("https://moved.test/"));
}
crate::browser_view::BrowserViewPayload::Frame { .. } => {
panic!("expected a presentation event")
}
}
}
#[tokio::test]
async fn producer_register_reports_whether_a_host_is_currently_connected() {
let (state, _temp) = test_state().await;
let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
let out = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1" }),
),
&agent,
&state,
)
.await
.unwrap();
assert_eq!(out["host_connected"], false);
let (_host, _host_rx) = host_session(&state, "host-1").await;
let out = handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1" }),
),
&agent,
&state,
)
.await
.unwrap();
assert_eq!(out["host_connected"], true);
}
fn host_connected_calls(
frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) -> (usize, Option<Value>) {
let seen: Vec<Value> = frames
.lock()
.unwrap()
.iter()
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.filter(|value| value["method"] == "agent.browser.host_connected")
.collect();
let last = seen
.last()
.map(|value| value["params"]["connected"].clone());
(seen.len(), last)
}
#[tokio::test]
async fn only_a_host_removal_tells_the_producers_that_host_connectivity_changed() {
let (state, _temp) = test_state().await;
let (agent, frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
handle_producer_register(
&request(
"browser.producer.register",
json!({ "conversation_id": "conv-1" }),
),
&agent,
&state,
)
.await
.unwrap();
let (_host, _host_rx) = host_session(&state, "host-1").await;
let (other_channel, _other_rx) = capture_channel();
state
.create_session("other-1", other_channel)
.await
.unwrap();
frames.lock().unwrap().clear();
state
.remove_session("other-1")
.await
.expect("the non-host session was registered");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
host_connected_calls(&frames).0,
0,
"a non-host disconnect must not fan a reverse call to every producer"
);
state
.remove_session("host-1")
.await
.expect("the host session was registered");
for _ in 0..200 {
if host_connected_calls(&frames).0 > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
let (count, connected) = host_connected_calls(&frames);
assert_eq!(
count, 1,
"removing the last host must still tell the producers"
);
assert_eq!(
connected,
Some(json!(false)),
"the session is already out of `sessions`, so the broadcast reads the \
post-removal truth"
);
}
#[tokio::test]
async fn a_push_from_a_connection_with_no_producer_is_consumed_and_dropped() {
let (state, _temp) = test_state().await;
let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
assert!(
try_handle_producer_push(
¬ification(
"browser.producer.frame",
json!({ "frame": { "jpeg_base64": "AQ==", "width": 1, "height": 1,
"device_pixel_ratio": 1.0, "captured_at": 0.0 } }),
),
&state,
&agent,
)
.await
);
assert!(state.browser_views.get(Some("conv-1")).await.is_none());
}
#[tokio::test]
async fn a_producer_request_with_an_id_is_left_to_the_dispatcher() {
let (state, _temp) = test_state().await;
let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
assert!(
!try_handle_producer_push(
&request("browser.producer.presentation", json!({})),
&state,
&agent,
)
.await,
"a frame with an id is a request; it must get a real reply, not be swallowed"
);
}
#[tokio::test]
async fn input_reaches_the_process_only_after_the_control_gate_passes() {
let (state, _temp) = test_state().await;
let (agent, agent_frames) =
agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
let (host, _host_rx) = host_session(&state, "host-1").await;
handle_producer_register(
&request(
"browser.producer.register",
json!({
"conversation_id": "conv-1",
"presentation": presentation(WireOwner::Agent, "https://x.test/"),
}),
),
&agent,
&state,
)
.await
.unwrap();
let click = request(
"browser.view.click",
json!({ "conversation_id": "conv-1", "x": 4.0, "y": 5.0 }),
);
let err = crate::browser_view::handle_input(
crate::browser_view::InputOp::Click,
&click,
&host,
&state,
)
.await
.unwrap_err();
assert!(err.contains("take_control"), "got: {err}");
assert!(
agent_frames.lock().unwrap().is_empty(),
"a refused input must never reach the agent process"
);
let taking = tokio::spawn({
let state = Arc::clone(&state);
let host = Arc::clone(&host);
async move {
crate::browser_view::handle_take_control(
&request(
"browser.view.take_control",
json!({ "conversation_id": "conv-1" }),
),
&host,
&state,
)
.await
}
});
let request_frame = answer_next_call(
&agent.channel,
&agent_frames,
json!({
"presentation": presentation(WireOwner::User, "https://x.test/"),
"effects": [],
}),
)
.await;
assert_eq!(request_frame["method"], "agent.browser.control");
assert_eq!(request_frame["params"]["action"], "take_control");
assert_eq!(
taking.await.unwrap().unwrap()["presentation"]["owner"],
"user"
);
let clicking = tokio::spawn({
let state = Arc::clone(&state);
let host = Arc::clone(&host);
async move {
crate::browser_view::handle_input(
crate::browser_view::InputOp::Click,
&click,
&host,
&state,
)
.await
}
});
let request_frame = answer_next_call(&agent.channel, &agent_frames, json!({})).await;
assert_eq!(request_frame["method"], "agent.browser.input");
assert_eq!(request_frame["params"]["op"], "click");
assert_eq!(request_frame["params"]["x"], 4.0);
assert_eq!(clicking.await.unwrap().unwrap()["ok"], true);
}
}