1use std::collections::HashMap;
54use std::sync::atomic::{AtomicBool, Ordering};
55use std::sync::{Arc, Weak};
56use std::time::Duration;
57
58use car_browser::Modifier;
59use futures::SinkExt;
60use serde_json::{json, Value};
61use tokio::sync::{watch, Mutex, MutexGuard};
62use tokio_tungstenite::tungstenite::Message;
63
64use crate::assistant::browser_control::{ControlEffect, ControlOwner};
65use crate::assistant::browser_tools::ControlStatus;
66use crate::browser_attention::{notify_signin_transition, BrowserSignInSnapshot, SignInAttention};
67use crate::browser_view::{BrowserView, ViewControl, ViewInput, WireFrame, WirePresentation};
68use crate::handler::JsonRpcMessage;
69use crate::session::{ClientSession, ServerState, WsChannel};
70
71pub const RELAY_CALL_TIMEOUT: Duration = Duration::from_secs(30);
75
76const CAPTURE_RETRY_BACKOFF: Duration = Duration::from_secs(2);
80
81pub const MAX_VIEWS_PER_PRODUCER: usize =
105 crate::assistant::browser_producer::MAX_KNOWN_CONVERSATIONS;
106
107const MAX_PRODUCER_FRAME_BYTES: usize = 8 * 1024 * 1024;
117
118pub fn modifier_name(modifier: Modifier) -> &'static str {
131 match modifier {
132 Modifier::Shift => "shift",
133 Modifier::Control => "control",
134 Modifier::Alt => "alt",
135 Modifier::Meta => "meta",
136 }
137}
138
139pub fn input_to_wire(input: &ViewInput) -> Value {
143 match input {
144 ViewInput::Navigate { url } => json!({ "op": "navigate", "url": url }),
145 ViewInput::Click { x, y } => json!({ "op": "click", "x": x, "y": y }),
146 ViewInput::Type { text } => json!({ "op": "type", "text": text }),
147 ViewInput::Keypress { key, modifiers } => json!({
148 "op": "keypress",
149 "key": key,
150 "modifiers": modifiers.iter().map(|m| modifier_name(*m)).collect::<Vec<_>>(),
151 }),
152 ViewInput::Scroll { delta_y } => json!({ "op": "scroll", "delta_y": delta_y }),
153 ViewInput::Paste { text } => json!({ "op": "paste", "text": text }),
154 ViewInput::Back => json!({ "op": "back" }),
155 ViewInput::Forward => json!({ "op": "forward" }),
156 ViewInput::Reload => json!({ "op": "reload" }),
157 ViewInput::TabOpen => json!({ "op": "tab_open" }),
158 ViewInput::TabClose { tab_id } => json!({ "op": "tab_close", "tab_id": tab_id }),
159 ViewInput::TabSwitch { tab_id } => json!({ "op": "tab_switch", "tab_id": tab_id }),
160 }
161}
162
163pub fn input_from_wire(params: &Value) -> Result<ViewInput, String> {
165 let op = params
166 .get("op")
167 .and_then(Value::as_str)
168 .ok_or("agent.browser.input requires { op }")?;
169 let string = |field: &str| -> Result<String, String> {
170 params
171 .get(field)
172 .and_then(Value::as_str)
173 .map(str::to_string)
174 .ok_or_else(|| format!("agent.browser.input `{op}` requires {{ {field} }}"))
175 };
176 match op {
177 "navigate" => Ok(ViewInput::Navigate {
178 url: string("url")?,
179 }),
180 "click" => {
181 let (x, y) = match (
182 params.get("x").and_then(Value::as_f64),
183 params.get("y").and_then(Value::as_f64),
184 ) {
185 (Some(x), Some(y)) => (x, y),
186 _ => return Err("agent.browser.input `click` requires { x, y }".to_string()),
187 };
188 Ok(ViewInput::Click { x, y })
189 }
190 "type" => Ok(ViewInput::Type {
191 text: string("text")?,
192 }),
193 "keypress" => {
194 let mut modifiers = Vec::new();
195 for name in params
196 .get("modifiers")
197 .and_then(Value::as_array)
198 .unwrap_or(&Vec::new())
199 {
200 let name = name
201 .as_str()
202 .ok_or("agent.browser.input `keypress` modifiers must be strings")?;
203 modifiers.push(crate::browser_view::parse_modifier(name)?);
204 }
205 Ok(ViewInput::Keypress {
206 key: string("key")?,
207 modifiers,
208 })
209 }
210 "scroll" => Ok(ViewInput::Scroll {
211 delta_y: params
212 .get("delta_y")
213 .and_then(Value::as_i64)
214 .and_then(|n| i32::try_from(n).ok())
215 .ok_or("agent.browser.input `scroll` requires { delta_y }")?,
216 }),
217 "paste" => Ok(ViewInput::Paste {
218 text: string("text")?,
219 }),
220 "back" => Ok(ViewInput::Back),
221 "forward" => Ok(ViewInput::Forward),
222 "reload" => Ok(ViewInput::Reload),
223 "tab_open" => Ok(ViewInput::TabOpen),
224 "tab_close" => Ok(ViewInput::TabClose {
225 tab_id: string("tab_id")?,
226 }),
227 "tab_switch" => Ok(ViewInput::TabSwitch {
228 tab_id: string("tab_id")?,
229 }),
230 other => Err(format!("unknown agent.browser.input op '{other}'")),
231 }
232}
233
234pub fn control_to_wire(control: ViewControl) -> &'static str {
236 match control {
237 ViewControl::TakeControl => "take_control",
238 ViewControl::HandBack => "hand_back",
239 ViewControl::RunEnded => "run_ended",
240 ViewControl::HolderDisconnected => "holder_disconnected",
241 ViewControl::GraceExpired => "grace_expired",
242 }
243}
244
245pub fn control_from_wire(action: &str) -> Result<ViewControl, String> {
247 match action {
248 "take_control" => Ok(ViewControl::TakeControl),
249 "hand_back" => Ok(ViewControl::HandBack),
250 "run_ended" => Ok(ViewControl::RunEnded),
251 "holder_disconnected" => Ok(ViewControl::HolderDisconnected),
252 "grace_expired" => Ok(ViewControl::GraceExpired),
253 other => Err(format!(
254 "unknown agent.browser.control action '{other}' — use take_control, hand_back, \
255 run_ended, holder_disconnected or grace_expired"
256 )),
257 }
258}
259
260pub fn effects_to_wire(effects: &[ControlEffect]) -> Value {
264 Value::Array(
265 effects
266 .iter()
267 .map(|effect| match effect {
268 ControlEffect::StartGracePeriod => json!({ "effect": "start_grace_period" }),
269 ControlEffect::SignInResolved { signed_in } => json!({
270 "effect": "sign_in_resolved",
271 "signed_in": signed_in,
272 }),
273 })
274 .collect(),
275 )
276}
277
278pub fn effects_from_wire(value: &Value) -> Vec<ControlEffect> {
282 let Some(items) = value.as_array() else {
283 return Vec::new();
284 };
285 items
286 .iter()
287 .filter_map(|item| match item.get("effect").and_then(Value::as_str) {
288 Some("start_grace_period") => Some(ControlEffect::StartGracePeriod),
289 Some("sign_in_resolved") => Some(ControlEffect::SignInResolved {
290 signed_in: item
291 .get("signed_in")
292 .and_then(Value::as_bool)
293 .unwrap_or(false),
294 }),
295 _ => {
296 tracing::debug!(effect = ?item, "browser relay: ignoring an unknown control effect");
297 None
298 }
299 })
300 .collect()
301}
302
303async fn call_agent(
313 channel: &Arc<WsChannel>,
314 method: &str,
315 params: Value,
316) -> Result<Value, String> {
317 let request_id = channel.next_request_id();
318 let (tx, rx) = tokio::sync::oneshot::channel();
319 channel.pending.lock().await.insert(request_id.clone(), tx);
320
321 let frame = json!({
322 "jsonrpc": "2.0",
323 "method": method,
324 "params": params,
325 "id": request_id,
326 });
327 let text = match serde_json::to_string(&frame) {
328 Ok(text) => text,
329 Err(e) => {
330 channel.pending.lock().await.remove(&request_id);
331 return Err(format!("serialize {method}: {e}"));
332 }
333 };
334 let sent = tokio::time::timeout(RELAY_CALL_TIMEOUT, async {
346 channel
347 .write
348 .lock()
349 .await
350 .send(Message::Text(text.into()))
351 .await
352 })
353 .await;
354 match sent {
355 Ok(Ok(())) => {}
356 Ok(Err(e)) => {
357 channel.pending.lock().await.remove(&request_id);
358 return Err(format!(
359 "the agent process serving this browser is unreachable: {e}"
360 ));
361 }
362 Err(_) => {
363 channel.pending.lock().await.remove(&request_id);
364 return Err(format!(
365 "the agent process serving this browser is unreachable: its connection did not \
366 accept `{method}` within {}s",
367 RELAY_CALL_TIMEOUT.as_secs()
368 ));
369 }
370 }
371
372 match tokio::time::timeout(RELAY_CALL_TIMEOUT, rx).await {
373 Ok(Ok(response)) => match (response.error, response.output) {
374 (Some(error), _) => Err(error),
375 (None, Some(output)) => Ok(output),
376 (None, None) => Ok(Value::Null),
377 },
378 Ok(Err(_)) => {
379 Err("the agent process serving this browser disconnected before answering".to_string())
380 }
381 Err(_) => {
382 channel.pending.lock().await.remove(&request_id);
383 Err(format!(
384 "the agent process serving this browser did not answer `{method}` within {}s",
385 RELAY_CALL_TIMEOUT.as_secs()
386 ))
387 }
388 }
389}
390
391pub const PRODUCER_GONE: &str =
393 "the agent process that owns this browser has disconnected — its browser is gone";
394
395pub struct RelayProducer {
407 client_id: String,
411 agent_id: String,
413 channel: Arc<WsChannel>,
414 last: Mutex<WirePresentation>,
418 signin_attention: Mutex<RelaySignInAttention>,
426 views: Mutex<Vec<Weak<BrowserView>>>,
428 alive: AtomicBool,
429 watchers: std::sync::Mutex<usize>,
437 capture: watch::Sender<bool>,
438 host_desired: AtomicBool,
453 host_push: Mutex<Option<bool>>,
454
455 announce_order: Mutex<()>,
484}
485
486struct PendingSignInAnnouncement {
495 attention: Arc<dyn SignInAttention>,
496 conversation_id: Option<String>,
497 before: Option<String>,
498 after: Option<String>,
499}
500
501#[derive(Default)]
502struct RelaySignInAttention {
503 attention: Option<Arc<dyn SignInAttention>>,
504 conversation_id: Option<String>,
506 latest_conversation_id: Option<String>,
508 announced: Option<String>,
509}
510
511impl RelayProducer {
512 pub fn new(client_id: String, agent_id: String, channel: Arc<WsChannel>) -> Arc<Self> {
513 let (capture, rx) = watch::channel(false);
514 let producer = Arc::new(Self {
515 client_id,
516 agent_id,
517 channel,
518 last: Mutex::new(WirePresentation::empty()),
519 signin_attention: Mutex::new(RelaySignInAttention::default()),
520 views: Mutex::new(Vec::new()),
521 alive: AtomicBool::new(true),
522 watchers: std::sync::Mutex::new(0),
523 capture,
524 host_desired: AtomicBool::new(false),
525 host_push: Mutex::new(None),
526 announce_order: Mutex::new(()),
527 });
528 producer.spawn_capture_pump(rx);
529 producer
530 }
531
532 pub fn client_id(&self) -> &str {
533 &self.client_id
534 }
535
536 pub fn agent_id(&self) -> &str {
537 &self.agent_id
538 }
539
540 pub fn is_alive(&self) -> bool {
541 self.alive.load(Ordering::Acquire)
542 }
543
544 pub async fn presentation(&self) -> WirePresentation {
547 self.last.lock().await.clone()
548 }
549
550 pub async fn set_signin_attention(
556 &self,
557 attention: Option<Arc<dyn SignInAttention>>,
558 conversation_id: Option<String>,
559 ) {
560 let mut binding = self.signin_attention.lock().await;
561 binding.latest_conversation_id = conversation_id.clone();
562 if binding.announced.is_none() {
563 binding.conversation_id = conversation_id;
564 }
565 binding.attention = attention;
566 let pending = self.decide_signin_transition(&mut binding).await;
567 self.announce(binding, Vec::from_iter(pending)).await;
568 }
569
570 pub async fn detach_signin_attention(&self, conversation_id: Option<&str>) {
597 let mut binding = self.signin_attention.lock().await;
598 if binding.conversation_id.as_deref() != conversation_id {
599 return;
600 }
601 let live = self.live_view_keys().await;
610 let retiring_was_latest = binding.latest_conversation_id.as_deref() == conversation_id;
611 let latest_is_live = live
612 .iter()
613 .any(|key| key.as_deref() == binding.latest_conversation_id.as_deref());
614 let successor = if !retiring_was_latest && latest_is_live {
615 Some(binding.latest_conversation_id.clone())
620 } else {
621 live.into_iter()
622 .rev()
623 .find(|key| key.as_deref() != conversation_id)
624 };
625
626 let mut pending = Vec::new();
627 if let Some(before) = binding.announced.take() {
628 if let Some(attention) = binding.attention.as_ref() {
629 pending.push(PendingSignInAnnouncement {
630 attention: Arc::clone(attention),
631 conversation_id: conversation_id.map(str::to_string),
632 before: Some(before),
633 after: None,
634 });
635 }
636 }
637
638 match successor {
639 Some(key) => {
642 if retiring_was_latest || !latest_is_live {
654 binding.latest_conversation_id = key.clone();
655 }
656 binding.conversation_id = key;
657 pending.extend(self.decide_signin_transition(&mut binding).await);
658 }
659 None => {
660 binding.attention = None;
661 binding.conversation_id = None;
662 binding.latest_conversation_id = None;
663 }
664 }
665 self.announce(binding, pending).await;
666 }
667
668 pub async fn signin_snapshot(&self) -> Option<BrowserSignInSnapshot> {
669 let binding = self.signin_attention.lock().await;
670 binding.announced.as_ref().map(|message| {
671 BrowserSignInSnapshot::new(binding.conversation_id.as_deref(), message.clone())
672 })
673 }
674
675 async fn sync_signin_attention(&self) {
676 let mut binding = self.signin_attention.lock().await;
677 let pending = self.decide_signin_transition(&mut binding).await;
678 self.announce(binding, Vec::from_iter(pending)).await;
679 }
680
681 async fn decide_signin_transition(
689 &self,
690 binding: &mut RelaySignInAttention,
691 ) -> Option<PendingSignInAnnouncement> {
692 let attention = Arc::clone(binding.attention.as_ref()?);
693 let current = self.last.lock().await.pending_signin.clone();
694 if binding.announced == current {
695 return None;
696 }
697 let before = std::mem::replace(&mut binding.announced, current.clone());
698 let conversation_id = binding.conversation_id.clone();
699 if current.is_none() {
700 binding.conversation_id = binding.latest_conversation_id.clone();
701 }
702 Some(PendingSignInAnnouncement {
703 attention,
704 conversation_id,
705 before,
706 after: current,
707 })
708 }
709
710 async fn announce(
719 &self,
720 binding: MutexGuard<'_, RelaySignInAttention>,
721 pending: Vec<PendingSignInAnnouncement>,
722 ) {
723 if pending.is_empty() {
724 return;
725 }
726 let _order = self.announce_order.lock().await;
727 drop(binding);
728 for announcement in pending {
729 notify_signin_transition(
730 &announcement.attention,
731 announcement.conversation_id.as_deref(),
732 announcement.before.as_deref(),
733 announcement.after.as_deref(),
734 )
735 .await;
736 }
737 }
738
739 async fn live_view_keys(&self) -> Vec<Option<String>> {
742 self.live_views()
743 .await
744 .into_iter()
745 .map(|view| view.key().map(str::to_string))
746 .collect()
747 }
748
749 pub async fn control_status(&self) -> ControlStatus {
752 let last = self.last.lock().await;
753 ControlStatus {
754 owner: last.owner.into(),
755 signin_pending: last.pending_signin.is_some(),
756 blackout_active: last.blackout_active,
757 }
758 }
759
760 pub async fn control(
774 &self,
775 control: ViewControl,
776 ) -> Result<(ControlOwner, Vec<ControlEffect>), String> {
777 if !self.is_alive() {
778 return Err(PRODUCER_GONE.to_string());
779 }
780 let value = call_agent(
781 &self.channel,
782 "agent.browser.control",
783 json!({ "action": control_to_wire(control) }),
784 )
785 .await
786 .map_err(|error| {
787 tracing::warn!(
788 agent_id = %self.agent_id,
789 action = control_to_wire(control),
790 %error,
791 "browser relay: control transition did not reach the agent process"
792 );
793 error
794 })?;
795 let mut owner: Option<ControlOwner> = None;
798 if let Some(presentation) = value.get("presentation") {
799 match serde_json::from_value::<WirePresentation>(presentation.clone()) {
800 Ok(presentation) => {
801 owner = Some(presentation.owner.into());
809 self.cache(presentation).await;
810 }
811 Err(e) => tracing::warn!(
812 error = %e,
813 "browser relay: agent returned an unparseable presentation"
814 ),
815 }
816 }
817 let owner = match owner {
818 Some(owner) => owner,
819 None => self.last.lock().await.owner.into(),
820 };
821 Ok((
822 owner,
823 effects_from_wire(value.get("effects").unwrap_or(&Value::Null)),
824 ))
825 }
826
827 pub async fn input(&self, input: ViewInput) -> Result<Option<String>, String> {
831 if !self.is_alive() {
832 return Err(PRODUCER_GONE.to_string());
833 }
834 let out = call_agent(&self.channel, "agent.browser.input", input_to_wire(&input)).await?;
835 Ok(out
836 .get("tab_id")
837 .and_then(Value::as_str)
838 .map(str::to_string))
839 }
840
841 pub fn desired_capture(&self) -> bool {
853 match self.watchers.lock() {
854 Ok(watchers) => *watchers > 0,
855 Err(poisoned) => *poisoned.into_inner() > 0,
856 }
857 }
858
859 pub fn start_capture(&self) {
861 self.set_watchers(|n| n + 1);
862 }
863
864 pub fn stop_capture(&self) {
866 self.set_watchers(|n| n.saturating_sub(1));
867 }
868
869 fn set_watchers(&self, f: impl Fn(usize) -> usize) {
890 let mut watchers = match self.watchers.lock() {
891 Ok(watchers) => watchers,
892 Err(poisoned) => poisoned.into_inner(),
893 };
894 *watchers = f(*watchers);
895 let desired = *watchers > 0;
896 self.capture.send_if_modified(|current| {
897 let changed = *current != desired;
898 *current = desired;
899 changed
900 });
901 }
902
903 pub async fn attach_view(&self, view: &Arc<BrowserView>) {
905 let mut views = self.views.lock().await;
906 views.retain(|existing| existing.strong_count() > 0);
907 views.push(Arc::downgrade(view));
908 }
909
910 pub async fn views_past_the_cap(&self) -> Vec<Arc<BrowserView>> {
917 let mut views = self.views.lock().await;
918 views.retain(|view| view.strong_count() > 0);
919 if views.len() <= MAX_VIEWS_PER_PRODUCER {
920 return Vec::new();
921 }
922 let stale = views.len() - MAX_VIEWS_PER_PRODUCER;
923 views.iter().take(stale).filter_map(Weak::upgrade).collect()
924 }
925
926 pub async fn set_presentation(&self, presentation: WirePresentation) {
930 self.cache(presentation).await;
931 self.sync_signin_attention().await;
932 }
933
934 pub async fn push_presentation(&self, presentation: WirePresentation) {
937 self.cache(presentation).await;
938 self.sync_signin_attention().await;
939 for view in self.live_views().await {
940 view.refresh_presentation().await;
941 }
942 }
943
944 async fn cache(&self, presentation: WirePresentation) {
961 let mut last = self.last.lock().await;
962 if presentation.revision < last.revision {
963 return;
964 }
965 *last = presentation;
966 }
967
968 pub async fn push_frame(&self, frame: WireFrame) {
980 let mut watched = Vec::new();
981 for view in self.live_views().await {
982 if view.has_subscribers().await {
983 watched.push(view);
984 }
985 }
986 let Some(last) = watched.pop() else { return };
987 for view in watched {
988 view.emit_wire_frame(frame.clone()).await;
989 }
990 last.emit_wire_frame(frame).await;
991 }
992
993 pub async fn push_host_connected(&self, connected: bool) {
1002 if !self.is_alive() {
1003 return;
1004 }
1005 self.host_desired.store(connected, Ordering::Release);
1006 let mut last = self.host_push.lock().await;
1009 let connected = self.host_desired.load(Ordering::Acquire);
1013 if *last == Some(connected) {
1014 return;
1015 }
1016 match call_agent(
1017 &self.channel,
1018 "agent.browser.host_connected",
1019 json!({ "connected": connected }),
1020 )
1021 .await
1022 {
1023 Ok(_) => *last = Some(connected),
1024 Err(error) => tracing::debug!(
1025 agent_id = %self.agent_id,
1026 %error,
1027 "browser relay: could not tell the agent process about a host transition"
1028 ),
1029 }
1030 }
1031
1032 pub async fn note_disconnected(&self) {
1042 self.alive.store(false, Ordering::Release);
1043 let _ = self.capture.send(false);
1044 {
1045 let mut last = self.last.lock().await;
1046 let revision = last.revision.saturating_add(1);
1047 *last = WirePresentation::empty();
1048 last.revision = revision;
1051 }
1052 self.sync_signin_attention().await;
1053 for view in self.live_views().await {
1054 view.refresh_presentation().await;
1055 }
1056 }
1057
1058 async fn live_views(&self) -> Vec<Arc<BrowserView>> {
1059 let mut views = self.views.lock().await;
1060 views.retain(|view| view.strong_count() > 0);
1061 views.iter().filter_map(Weak::upgrade).collect()
1062 }
1063
1064 fn spawn_capture_pump(self: &Arc<Self>, mut rx: watch::Receiver<bool>) {
1065 let producer = Arc::downgrade(self);
1066 tokio::spawn(async move {
1067 let mut undelivered = false;
1074 loop {
1075 if !undelivered && rx.changed().await.is_err() {
1076 return;
1077 }
1078 let enabled = *rx.borrow_and_update();
1079 let Some(producer) = producer.upgrade() else {
1080 return;
1081 };
1082 if !producer.is_alive() {
1083 return;
1084 }
1085 let sent = call_agent(
1086 &producer.channel,
1087 "agent.browser.capture",
1088 json!({ "enabled": enabled }),
1089 )
1090 .await;
1091 undelivered = match sent {
1092 Ok(_) => false,
1093 Err(e) => {
1094 tracing::debug!(
1105 agent_id = %producer.agent_id,
1106 enabled,
1107 error = %e,
1108 "browser relay: capture request did not reach the agent process; retrying"
1109 );
1110 true
1111 }
1112 };
1113 drop(producer);
1114 if undelivered {
1115 tokio::select! {
1120 changed = rx.changed() => {
1121 if changed.is_err() {
1122 return;
1123 }
1124 }
1125 _ = tokio::time::sleep(CAPTURE_RETRY_BACKOFF) => {}
1126 }
1127 }
1128 }
1129 });
1130 }
1131}
1132
1133async fn authorize_producer(session: &ClientSession) -> Result<String, String> {
1141 session.agent_id.lock().await.clone().ok_or_else(|| {
1142 "not authorized to use browser.producer.*: this connection is not a supervised agent \
1143 (session.auth { token, agent_id })"
1144 .to_string()
1145 })
1146}
1147
1148pub fn authorize_conversation_claim(
1168 conversation_id: &str,
1169 agent_id: &str,
1170 live_owner: Option<&str>,
1171 bound_owner: Option<&str>,
1172) -> Result<(), String> {
1173 match live_owner.or(bound_owner) {
1174 Some(owner) if owner == agent_id => Ok(()),
1175 Some(owner) => Err(format!(
1176 "conversation '{conversation_id}' is served by agent '{owner}', not '{agent_id}'"
1177 )),
1178 None => Err(format!(
1179 "conversation '{conversation_id}' is not an active chat session for agent \
1180 '{agent_id}' — register from inside the turn that serves it"
1181 )),
1182 }
1183}
1184
1185pub async fn handle_producer_register(
1193 req: &JsonRpcMessage,
1194 session: &Arc<ClientSession>,
1195 state: &Arc<ServerState>,
1196) -> Result<Value, String> {
1197 let agent_id = authorize_producer(session).await?;
1198 let conversation_id = req
1199 .params
1200 .get("conversation_id")
1201 .and_then(Value::as_str)
1202 .map(str::trim)
1203 .filter(|id| !id.is_empty())
1204 .ok_or("browser.producer.register requires a non-empty { conversation_id }")?
1205 .to_string();
1206
1207 let live_owner = state
1213 .chat_sessions
1214 .lock()
1215 .await
1216 .get(&conversation_id)
1217 .map(|chat| chat.agent_id.clone());
1218 let bound_owner = state
1219 .browser_views
1220 .conversation_owner(&conversation_id)
1221 .await;
1222 authorize_conversation_claim(
1223 &conversation_id,
1224 &agent_id,
1225 live_owner.as_deref(),
1226 bound_owner.as_deref(),
1227 )?;
1228 state
1229 .browser_views
1230 .bind_conversation(&conversation_id, &agent_id)
1231 .await;
1232
1233 let producer = state
1234 .browser_views
1235 .producer_for(&session.client_id, &agent_id, &session.channel)
1236 .await;
1237 if let Some(presentation) = req.params.get("presentation") {
1238 match serde_json::from_value::<WirePresentation>(presentation.clone()) {
1239 Ok(presentation) => producer.set_presentation(presentation).await,
1240 Err(e) => {
1241 return Err(format!(
1242 "browser.producer.register `presentation` is not a presentation object: {e}"
1243 ))
1244 }
1245 }
1246 }
1247 state
1248 .browser_views
1249 .register_relay(conversation_id.clone(), Arc::clone(&producer))
1250 .await;
1251
1252 Ok(json!({
1261 "ok": true,
1262 "conversation_id": conversation_id,
1263 "host_connected": state.any_host_connected().await,
1264 "capture": producer.desired_capture(),
1271 }))
1272}
1273
1274pub(crate) async fn try_handle_producer_push(
1279 parsed: &JsonRpcMessage,
1280 state: &Arc<ServerState>,
1281 session: &Arc<ClientSession>,
1282) -> bool {
1283 let Some(method) = parsed.method.as_deref() else {
1284 return false;
1285 };
1286 if method != "browser.producer.presentation" && method != "browser.producer.frame" {
1287 return false;
1288 }
1289 if !parsed.id.is_null() {
1290 return false;
1293 }
1294 if state.auth_token.get().is_some()
1305 && !session
1306 .authenticated
1307 .load(std::sync::atomic::Ordering::Acquire)
1308 {
1309 return false;
1310 }
1311 let Some(producer) = state.browser_views.producer(&session.client_id).await else {
1314 tracing::debug!(
1315 client_id = %session.client_id,
1316 method,
1317 "browser relay: push from a connection with no registered producer"
1318 );
1319 return true;
1320 };
1321
1322 if method == "browser.producer.presentation" {
1323 match serde_json::from_value::<WirePresentation>(
1324 parsed
1325 .params
1326 .get("presentation")
1327 .cloned()
1328 .unwrap_or(Value::Null),
1329 ) {
1330 Ok(presentation) => producer.push_presentation(presentation).await,
1331 Err(e) => tracing::debug!(
1332 error = %e,
1333 "browser relay: unparseable presentation push"
1334 ),
1335 }
1336 } else {
1337 match serde_json::from_value::<WireFrame>(
1338 parsed.params.get("frame").cloned().unwrap_or(Value::Null),
1339 ) {
1340 Ok(frame) if frame.jpeg_base64.len() > MAX_PRODUCER_FRAME_BYTES => {
1341 tracing::debug!(
1342 client_id = %session.client_id,
1343 bytes = frame.jpeg_base64.len(),
1344 "browser relay: dropped an oversized producer frame"
1345 );
1346 }
1347 Ok(frame) => producer.push_frame(frame).await,
1348 Err(e) => tracing::debug!(error = %e, "browser relay: unparseable frame push"),
1349 }
1350 }
1351 true
1352}
1353
1354#[derive(Default)]
1358pub struct ProducerRegistry {
1359 producers: Mutex<HashMap<String, Arc<RelayProducer>>>,
1360 bindings: Mutex<HashMap<String, String>>,
1365}
1366
1367impl ProducerRegistry {
1368 pub async fn get(&self, client_id: &str) -> Option<Arc<RelayProducer>> {
1369 self.producers.lock().await.get(client_id).cloned()
1370 }
1371
1372 pub async fn conversation_owner(&self, conversation_id: &str) -> Option<String> {
1375 self.bindings.lock().await.get(conversation_id).cloned()
1376 }
1377
1378 pub async fn bind_conversation(&self, conversation_id: &str, agent_id: &str) {
1383 self.bindings
1384 .lock()
1385 .await
1386 .insert(conversation_id.to_string(), agent_id.to_string());
1387 }
1388
1389 pub async fn forget_binding(&self, conversation_id: &str) {
1394 self.bindings.lock().await.remove(conversation_id);
1395 }
1396
1397 pub async fn get_or_create(
1399 &self,
1400 client_id: &str,
1401 agent_id: &str,
1402 channel: &Arc<WsChannel>,
1403 ) -> Arc<RelayProducer> {
1404 let mut producers = self.producers.lock().await;
1405 Arc::clone(producers.entry(client_id.to_string()).or_insert_with(|| {
1406 RelayProducer::new(
1407 client_id.to_string(),
1408 agent_id.to_string(),
1409 Arc::clone(channel),
1410 )
1411 }))
1412 }
1413
1414 pub async fn broadcast_host_connected(&self, connected: bool) {
1421 let producers: Vec<Arc<RelayProducer>> =
1422 self.producers.lock().await.values().cloned().collect();
1423 for producer in producers {
1428 tokio::spawn(async move { producer.push_host_connected(connected).await });
1429 }
1430 }
1431
1432 pub async fn note_disconnected(&self, client_id: &str) {
1435 let producer = self.producers.lock().await.remove(client_id);
1436 if let Some(producer) = producer {
1437 producer.note_disconnected().await;
1438 }
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use crate::browser_view::{BrowserViewRegistry, WireOwner};
1446
1447 use crate::browser_view::BrowserViewEvent;
1448 use crate::session::WsSink;
1449 use futures::StreamExt;
1450
1451 fn agent_channel() -> (
1452 Arc<WsChannel>,
1453 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1454 ) {
1455 let (channel, frames) = WsChannel::test_capture();
1456 (Arc::new(channel), frames)
1457 }
1458
1459 fn capture_channel() -> (
1462 Arc<WsChannel>,
1463 futures::channel::mpsc::UnboundedReceiver<Message>,
1464 ) {
1465 use futures::sink::SinkExt as _;
1466 let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
1467 let sink: WsSink =
1468 Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
1469 let channel = Arc::new(WsChannel {
1470 write: Mutex::new(sink),
1471 pending: Mutex::new(HashMap::new()),
1472 active_actions: Mutex::new(HashMap::new()),
1473 next_id: std::sync::atomic::AtomicU64::new(0),
1474 });
1475 (channel, rx)
1476 }
1477
1478 async fn next_event(
1479 rx: &mut futures::channel::mpsc::UnboundedReceiver<Message>,
1480 ) -> BrowserViewEvent {
1481 let frame = tokio::time::timeout(Duration::from_secs(2), rx.next())
1482 .await
1483 .expect("an event within the deadline")
1484 .expect("a frame");
1485 let text = match frame {
1486 Message::Text(text) => text.to_string(),
1487 other => panic!("expected a text frame, got {other:?}"),
1488 };
1489 let json: Value = serde_json::from_str(&text).unwrap();
1490 assert_eq!(json["method"], "browser.view.event");
1491 serde_json::from_value(json["params"].clone()).expect("a browser.view.event payload")
1492 }
1493
1494 async fn answer_next_call(
1498 channel: &Arc<WsChannel>,
1499 frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1500 result: Value,
1501 ) -> Value {
1502 for _ in 0..200 {
1503 let request = frames
1504 .lock()
1505 .unwrap()
1506 .iter()
1507 .filter_map(|text| serde_json::from_str::<Value>(text).ok())
1508 .find(|value| value.get("id").and_then(Value::as_str).is_some());
1509 if let Some(request) = request {
1510 let id = request["id"].as_str().unwrap().to_string();
1511 let waiter = channel.pending.lock().await.remove(&id);
1512 if let Some(waiter) = waiter {
1513 let _ = waiter.send(car_proto::ToolExecuteResponse {
1514 action_id: id,
1515 output: Some(result),
1516 error: None,
1517 terminal: false,
1518 });
1519 frames.lock().unwrap().clear();
1520 return request;
1521 }
1522 }
1523 tokio::time::sleep(Duration::from_millis(5)).await;
1524 }
1525 panic!("no reverse call arrived on the agent channel");
1526 }
1527
1528 async fn park_next_call(frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1532 for _ in 0..200 {
1533 let seen = frames
1534 .lock()
1535 .unwrap()
1536 .iter()
1537 .filter_map(|text| serde_json::from_str::<Value>(text).ok())
1538 .any(|value| value.get("id").and_then(Value::as_str).is_some());
1539 if seen {
1540 frames.lock().unwrap().clear();
1541 return;
1542 }
1543 tokio::time::sleep(Duration::from_millis(5)).await;
1544 }
1545 panic!("no reverse call arrived on the agent channel");
1546 }
1547
1548 fn presentation(owner: WireOwner, url: &str) -> WirePresentation {
1549 let mut wire = WirePresentation::empty();
1550 wire.revision = 3;
1551 wire.owner = owner;
1552 wire.url = Some(url.to_string());
1553 wire
1554 }
1555
1556 fn presentation_at(revision: u64, pending_signin: Option<&str>) -> WirePresentation {
1562 let mut wire = WirePresentation::empty();
1563 wire.revision = revision;
1564 wire.owner = WireOwner::Agent;
1565 wire.pending_signin = pending_signin.map(str::to_string);
1566 wire.blackout_active = pending_signin.is_some();
1567 wire
1568 }
1569
1570 async fn relayed_view_watching_signin() -> (
1574 Arc<RelayProducer>,
1575 Arc<crate::browser_view::BrowserViewRegistry>,
1576 Arc<crate::browser_attention::RecordingAttention>,
1577 ) {
1578 let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1579 let recorder = Arc::new(crate::browser_attention::RecordingAttention::default());
1580 registry.set_signin_attention(recorder.clone());
1581 let (channel, _frames) = agent_channel();
1582 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
1583 registry
1584 .register_relay("conv-1", Arc::clone(&producer))
1585 .await;
1586 (producer, registry, recorder)
1587 }
1588
1589 #[tokio::test]
1595 async fn a_relayed_sign_in_notifies_from_the_presentation_push_alone() {
1596 let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1597
1598 producer.push_presentation(presentation_at(1, None)).await;
1599 assert!(
1600 recorder.kinds().is_empty(),
1601 "an ordinary presentation is not news"
1602 );
1603
1604 producer
1605 .push_presentation(presentation_at(
1606 2,
1607 Some("Sign in at https://example.com/login"),
1608 ))
1609 .await;
1610 assert_eq!(
1611 recorder.calls(),
1612 vec![(
1613 crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1614 Some("conv-1".to_string()),
1615 Some("Sign in at https://example.com/login".to_string()),
1616 )],
1617 "the view key and the agent's own prompt both travel"
1618 );
1619
1620 producer.push_presentation(presentation_at(3, None)).await;
1621 assert_eq!(
1622 recorder.kinds(),
1623 vec![
1624 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1625 crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1626 ],
1627 "and the agent process resolving it clears the badge"
1628 );
1629 }
1630
1631 #[tokio::test]
1636 async fn republishing_the_same_pending_sign_in_says_nothing() {
1637 let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1638
1639 let pending = presentation_at(2, Some("Sign in at https://example.com/login"));
1640 for _ in 0..4 {
1641 producer.push_presentation(pending.clone()).await;
1642 }
1643 assert_eq!(
1644 recorder.kinds(),
1645 vec![crate::browser_attention::BROWSER_SIGNIN_NEEDED],
1646 "one wait is one notification, however many times it is republished"
1647 );
1648
1649 let mut moved = presentation_at(3, Some("Sign in at https://example.com/login"));
1652 moved.url = Some("https://example.com/login?step=2".into());
1653 producer.push_presentation(moved).await;
1654 assert_eq!(
1655 recorder.kinds(),
1656 vec![crate::browser_attention::BROWSER_SIGNIN_NEEDED]
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn one_process_with_two_views_emits_one_needed_event() {
1662 let (producer, registry, recorder) = relayed_view_watching_signin().await;
1663 registry
1664 .register_relay("conv-2", Arc::clone(&producer))
1665 .await;
1666
1667 producer
1668 .push_presentation(presentation_at(2, Some("Sign in")))
1669 .await;
1670
1671 assert_eq!(
1672 recorder.calls(),
1673 vec![(
1674 crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1675 Some("conv-2".to_string()),
1676 Some("Sign in".to_string()),
1677 )],
1678 "attention is process-owned and routed through the newest view"
1679 );
1680 assert_eq!(registry.pending_signins().await.len(), 1);
1681 }
1682
1683 #[tokio::test]
1684 async fn a_new_conversation_does_not_steal_an_announced_wait() {
1685 let (producer, registry, recorder) = relayed_view_watching_signin().await;
1686 producer
1687 .push_presentation(presentation_at(2, Some("Sign in for chat one")))
1688 .await;
1689
1690 registry
1691 .register_relay("conv-2", Arc::clone(&producer))
1692 .await;
1693 assert_eq!(
1694 recorder.calls(),
1695 vec![(
1696 crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1697 Some("conv-1".to_string()),
1698 Some("Sign in for chat one".to_string()),
1699 )],
1700 "a later turn cannot move an active badge off the blocked chat"
1701 );
1702 assert_eq!(
1703 registry.pending_signins().await[0].conversation_id,
1704 "conv-1"
1705 );
1706
1707 producer.push_presentation(presentation_at(3, None)).await;
1708 producer
1709 .push_presentation(presentation_at(4, Some("Sign in for chat two")))
1710 .await;
1711 assert_eq!(
1712 recorder.calls().last().unwrap().1.as_deref(),
1713 Some("conv-2"),
1714 "after resolution the newest registered chat owns the next wait"
1715 );
1716 }
1717
1718 #[tokio::test]
1719 async fn changing_the_pending_prompt_refreshes_operator_attention() {
1720 let (producer, registry, recorder) = relayed_view_watching_signin().await;
1721 producer
1722 .push_presentation(presentation_at(2, Some("Sign in at A")))
1723 .await;
1724 producer
1725 .push_presentation(presentation_at(3, Some("Sign in at B")))
1726 .await;
1727
1728 assert_eq!(
1729 recorder.kinds(),
1730 vec![
1731 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1732 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1733 ]
1734 );
1735 assert_eq!(registry.pending_signins().await[0].message, "Sign in at B");
1736 }
1737
1738 #[tokio::test]
1743 async fn the_agent_process_going_away_resolves_its_pending_sign_in() {
1744 let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1745 producer
1746 .push_presentation(presentation_at(2, Some("Sign in")))
1747 .await;
1748 producer.note_disconnected().await;
1749 assert_eq!(
1750 recorder.kinds(),
1751 vec![
1752 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1753 crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1754 ]
1755 );
1756 }
1757
1758 #[tokio::test]
1763 async fn a_registry_with_no_attention_sink_relays_but_announces_nothing() {
1764 let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1765 let (channel, _frames) = agent_channel();
1766 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
1767 let view = registry
1768 .register_relay("conv-1", Arc::clone(&producer))
1769 .await;
1770 producer
1771 .push_presentation(presentation_at(2, Some("Sign in")))
1772 .await;
1773 assert_eq!(
1776 view.snapshot_for_test().await.0.pending_signin.as_deref(),
1777 Some("Sign in")
1778 );
1779 assert!(
1784 producer.signin_snapshot().await.is_none(),
1785 "no sink means nothing was ever announced"
1786 );
1787 assert!(registry.pending_signins().await.is_empty());
1788 }
1789
1790 #[tokio::test]
1796 async fn retiring_the_routed_view_keeps_the_sink_for_the_views_that_remain() {
1797 let (producer, registry, recorder) = relayed_view_watching_signin().await;
1798 registry
1800 .register_relay("conv-2", Arc::clone(&producer))
1801 .await;
1802
1803 producer.detach_signin_attention(Some("conv-2")).await;
1806
1807 producer
1808 .push_presentation(presentation_at(2, Some("Sign in")))
1809 .await;
1810 assert_eq!(
1811 recorder.calls(),
1812 vec![(
1813 crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1814 Some("conv-1".to_string()),
1815 Some("Sign in".to_string()),
1816 )],
1817 "the route moves to a surviving view instead of nulling the sink"
1818 );
1819 }
1820
1821 #[tokio::test]
1827 async fn retiring_the_routed_view_hands_the_route_to_the_newest_turn() {
1828 let (producer, registry, recorder) = relayed_view_watching_signin().await;
1829 producer
1830 .push_presentation(presentation_at(
1831 2,
1832 Some("Sign in at https://example.com/login"),
1833 ))
1834 .await;
1835
1836 for turn in 2..=9 {
1839 registry
1840 .register_relay(format!("conv-{turn}"), Arc::clone(&producer))
1841 .await;
1842 }
1843 producer.detach_signin_attention(Some("conv-1")).await;
1846
1847 assert_eq!(
1848 recorder.calls(),
1849 vec![
1850 (
1851 crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1852 Some("conv-1".to_string()),
1853 Some("Sign in at https://example.com/login".to_string()),
1854 ),
1855 (
1856 crate::browser_attention::BROWSER_SIGNIN_RESOLVED.to_string(),
1857 Some("conv-1".to_string()),
1858 None,
1859 ),
1860 (
1861 crate::browser_attention::BROWSER_SIGNIN_NEEDED.to_string(),
1862 Some("conv-9".to_string()),
1863 Some("Sign in at https://example.com/login".to_string()),
1864 ),
1865 ],
1866 "the badge moves to the newest turn, not the oldest survivor, and \
1867 the still-blocked browser is re-raised rather than left cleared"
1868 );
1869 assert_eq!(
1870 producer
1871 .signin_snapshot()
1872 .await
1873 .expect("the browser is still blocked")
1874 .conversation_id,
1875 "conv-9"
1876 );
1877 assert_eq!(
1878 registry.pending_signins().await[0].conversation_id,
1879 "conv-9",
1880 "and a host reconnecting mid-wait is pointed at the same turn"
1881 );
1882 }
1883
1884 #[tokio::test]
1889 async fn a_registration_landing_during_a_detach_still_leaves_a_live_sink() {
1890 let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1891 producer
1892 .push_presentation(presentation_at(2, Some("Sign in")))
1893 .await;
1894
1895 let (scratch_channel, _scratch_frames) = agent_channel();
1898 let scratch_producer =
1899 RelayProducer::new("other-conn".into(), "car-assistant".into(), scratch_channel);
1900 let scratch_registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1901 let view_two = scratch_registry
1902 .register_relay("conv-2", Arc::clone(&scratch_producer))
1903 .await;
1904
1905 let guard = producer.signin_attention.lock().await;
1908 let detach = tokio::spawn({
1909 let producer = Arc::clone(&producer);
1910 async move { producer.detach_signin_attention(Some("conv-1")).await }
1911 });
1912 tokio::task::yield_now().await;
1913
1914 producer.attach_view(&view_two).await;
1916 drop(guard);
1917 detach.await.unwrap();
1918
1919 assert_eq!(
1920 producer
1921 .signin_snapshot()
1922 .await
1923 .expect("a live view remains, so the wait keeps a route")
1924 .conversation_id,
1925 "conv-2"
1926 );
1927 assert_eq!(
1928 recorder.kinds(),
1929 vec![
1930 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1931 crate::browser_attention::BROWSER_SIGNIN_RESOLVED,
1932 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1933 ],
1934 "the badge moves to the surviving view instead of the sink being nulled"
1935 );
1936 }
1937
1938 #[tokio::test]
1941 async fn retiring_the_last_view_detaches_the_sink() {
1942 let (producer, _registry, recorder) = relayed_view_watching_signin().await;
1943 producer
1944 .push_presentation(presentation_at(2, Some("Sign in")))
1945 .await;
1946
1947 producer.detach_signin_attention(Some("conv-1")).await;
1948 assert_eq!(
1949 recorder.kinds(),
1950 vec![
1951 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1952 crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1953 ],
1954 "the wait it owned is resolved on the way out"
1955 );
1956
1957 producer
1958 .push_presentation(presentation_at(4, Some("Sign in again")))
1959 .await;
1960 assert_eq!(
1961 recorder.kinds(),
1962 vec![
1963 crate::browser_attention::BROWSER_SIGNIN_NEEDED,
1964 crate::browser_attention::BROWSER_SIGNIN_RESOLVED
1965 ],
1966 "nothing left to serve, so nothing left to announce"
1967 );
1968 }
1969
1970 #[tokio::test]
1976 async fn a_stalled_broadcast_does_not_block_the_next_presentation() {
1977 struct BlockingAttention {
1978 entered: Arc<tokio::sync::Notify>,
1979 release: Arc<tokio::sync::Notify>,
1980 }
1981
1982 #[async_trait::async_trait]
1983 impl SignInAttention for BlockingAttention {
1984 async fn signin_needed(&self, _conversation_id: Option<&str>, _message: &str) {
1985 self.entered.notify_one();
1986 self.release.notified().await;
1987 }
1988 async fn signin_resolved(&self, _conversation_id: Option<&str>) {}
1989 }
1990
1991 let entered = Arc::new(tokio::sync::Notify::new());
1992 let release = Arc::new(tokio::sync::Notify::new());
1993 let registry = Arc::new(crate::browser_view::BrowserViewRegistry::default());
1994 registry.set_signin_attention(Arc::new(BlockingAttention {
1995 entered: Arc::clone(&entered),
1996 release: Arc::clone(&release),
1997 }));
1998 let (channel, _frames) = agent_channel();
1999 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2000 registry
2001 .register_relay("conv-1", Arc::clone(&producer))
2002 .await;
2003
2004 let blocked = tokio::spawn({
2005 let producer = Arc::clone(&producer);
2006 async move {
2007 producer
2008 .push_presentation(presentation_at(2, Some("Sign in")))
2009 .await
2010 }
2011 });
2012 entered.notified().await;
2014
2015 let mut moved = presentation_at(3, Some("Sign in"));
2018 moved.url = Some("https://example.com/login?step=2".into());
2019 tokio::time::timeout(Duration::from_secs(5), producer.push_presentation(moved))
2020 .await
2021 .expect("a non-transition push must not queue behind a stalled broadcast");
2022
2023 release.notify_one();
2024 blocked.await.unwrap();
2025 }
2026
2027 #[tokio::test]
2028 async fn input_crosses_to_the_agent_process_as_a_reverse_call_and_returns_its_answer() {
2029 let (channel, frames) = agent_channel();
2030 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2031
2032 let relayed = tokio::spawn({
2033 let producer = Arc::clone(&producer);
2034 async move { producer.input(ViewInput::TabOpen).await }
2035 });
2036 let request =
2037 answer_next_call(&producer.channel, &frames, json!({ "tab_id": "tab-4" })).await;
2038
2039 assert_eq!(request["method"], "agent.browser.input");
2040 assert_eq!(request["params"]["op"], "tab_open");
2041 assert_eq!(relayed.await.unwrap().unwrap().as_deref(), Some("tab-4"));
2042 }
2043
2044 #[tokio::test]
2045 async fn the_agent_s_error_reaches_the_caller_verbatim() {
2046 let (channel, frames) = agent_channel();
2047 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2048
2049 let relayed = tokio::spawn({
2050 let producer = Arc::clone(&producer);
2051 async move { producer.input(ViewInput::Click { x: 1.0, y: 2.0 }).await }
2052 });
2053 for _ in 0..200 {
2056 let id = frames
2057 .lock()
2058 .unwrap()
2059 .iter()
2060 .filter_map(|t| serde_json::from_str::<Value>(t).ok())
2061 .find_map(|v| v.get("id").and_then(Value::as_str).map(str::to_string));
2062 if let Some(id) = id {
2063 if let Some(waiter) = producer.channel.pending.lock().await.remove(&id) {
2064 let _ = waiter.send(car_proto::ToolExecuteResponse {
2065 action_id: id,
2066 output: None,
2067 error: Some("no browser is running for this view".into()),
2068 terminal: false,
2069 });
2070 break;
2071 }
2072 }
2073 tokio::time::sleep(Duration::from_millis(5)).await;
2074 }
2075 assert_eq!(
2076 relayed.await.unwrap().unwrap_err(),
2077 "no browser is running for this view"
2078 );
2079 }
2080
2081 #[tokio::test]
2082 async fn control_relays_the_transition_and_brings_its_effects_back() {
2083 let (channel, frames) = agent_channel();
2084 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2085
2086 let relayed = tokio::spawn({
2087 let producer = Arc::clone(&producer);
2088 async move { producer.control(ViewControl::HolderDisconnected).await }
2089 });
2090 let request = answer_next_call(
2091 &producer.channel,
2092 &frames,
2093 json!({
2094 "presentation": presentation(WireOwner::User, "https://x.test/"),
2095 "effects": [{ "effect": "start_grace_period" }],
2096 }),
2097 )
2098 .await;
2099
2100 assert_eq!(request["method"], "agent.browser.control");
2101 assert_eq!(request["params"]["action"], "holder_disconnected");
2102 assert_eq!(
2103 relayed.await.unwrap(),
2104 Ok((ControlOwner::User, vec![ControlEffect::StartGracePeriod])),
2105 "the daemon owns the clock, so the effect has to cross back — and the owner \
2106 comes from THIS response, not from a re-read of the cache"
2107 );
2108 assert_eq!(
2110 producer.control_status().await.owner,
2111 crate::assistant::browser_control::ControlOwner::User
2112 );
2113 }
2114
2115 #[tokio::test]
2116 async fn a_dead_producer_refuses_input_instead_of_hanging_on_a_call() {
2117 let (channel, _frames) = agent_channel();
2118 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2119 producer
2120 .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
2121 .await;
2122
2123 producer.note_disconnected().await;
2124
2125 assert!(!producer.is_alive());
2126 let err = producer
2127 .input(ViewInput::Navigate {
2128 url: "https://y.test".into(),
2129 })
2130 .await
2131 .unwrap_err();
2132 assert_eq!(err, PRODUCER_GONE);
2133 let cleared = producer.presentation().await;
2134 assert_eq!(cleared.owner, WireOwner::None);
2135 assert_eq!(cleared.url, None);
2136 assert!(
2137 cleared.revision > 3,
2138 "the revision never moves backwards, even when the browser vanishes"
2139 );
2140 }
2141
2142 #[tokio::test]
2150 async fn a_control_transition_reports_the_owner_from_its_own_answer() {
2151 let (channel, frames) = agent_channel();
2152 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2153
2154 let relayed = {
2155 let producer = Arc::clone(&producer);
2156 tokio::spawn(async move { producer.control(ViewControl::TakeControl).await })
2157 };
2158 answer_next_call(
2159 &producer.channel,
2160 &frames,
2161 json!({
2162 "presentation": presentation(WireOwner::User, "https://x.test/"),
2163 "effects": [],
2164 }),
2165 )
2166 .await;
2167 let (owner, _) = relayed.await.unwrap().unwrap();
2168 assert_eq!(
2169 owner,
2170 ControlOwner::User,
2171 "the transition landed on User, and that is what the caller must act on"
2172 );
2173
2174 producer
2177 .push_presentation(presentation(WireOwner::Agent, "https://x.test/"))
2178 .await;
2179 assert_eq!(
2180 producer.control_status().await.owner,
2181 ControlOwner::Agent,
2182 "the cache really can go backwards, which is why it cannot be the decider"
2183 );
2184 }
2185
2186 #[tokio::test(start_paused = true)]
2195 async fn a_disconnect_arms_the_grace_timer_even_when_the_transition_never_lands() {
2196 let (channel, frames) = agent_channel();
2197 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2198 let registry = BrowserViewRegistry::new(std::env::temp_dir());
2199 let view = registry
2200 .register_relay("conv-1", Arc::clone(&producer))
2201 .await;
2202
2203 let taking = {
2205 let view = Arc::clone(&view);
2206 tokio::spawn(async move { view.take_control_for_test("host-1").await })
2207 };
2208 answer_next_call(
2209 &producer.channel,
2210 &frames,
2211 json!({
2212 "presentation": presentation(WireOwner::User, "https://x.test/"),
2213 "effects": [],
2214 }),
2215 )
2216 .await;
2217 taking.await.unwrap().expect("take control");
2218
2219 producer.note_disconnected().await;
2221 let before = view.grace_generation_for_test().await;
2222 view.note_disconnect("host-1", false).await;
2223 let after = view.grace_generation_for_test().await;
2224
2225 assert!(
2226 view.control_holder_for_test().await.is_none(),
2227 "the holder is cleared at disconnect — the connection is provably gone"
2228 );
2229 assert_eq!(
2234 after - before,
2235 2,
2236 "the timer must be armed from what the daemon knows, not from the reply of a \
2237 process that is not answering"
2238 );
2239 }
2240
2241 #[tokio::test(start_paused = true)]
2245 async fn a_take_control_inside_the_disconnect_window_is_not_revoked_by_the_grace_timer() {
2246 let (channel, frames) = agent_channel();
2247 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2248 let registry = BrowserViewRegistry::new(std::env::temp_dir());
2249 let view = registry
2250 .register_relay("conv-1", Arc::clone(&producer))
2251 .await;
2252
2253 let taking = {
2255 let view = Arc::clone(&view);
2256 tokio::spawn(async move { view.take_control_for_test("host-1").await })
2257 };
2258 answer_next_call(
2259 &producer.channel,
2260 &frames,
2261 json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2262 )
2263 .await;
2264 taking.await.unwrap().expect("take control");
2265
2266 let disconnecting = {
2270 let view = Arc::clone(&view);
2271 tokio::spawn(async move { view.note_disconnect("host-1", false).await })
2272 };
2273 park_next_call(&frames).await;
2276 disconnecting.await.unwrap();
2277
2278 let retaking = {
2280 let view = Arc::clone(&view);
2281 tokio::spawn(async move { view.take_control_for_test("host-2").await })
2282 };
2283 answer_next_call(
2284 &producer.channel,
2285 &frames,
2286 json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2287 )
2288 .await;
2289 retaking.await.unwrap().expect("re-take control");
2290 assert_eq!(
2291 view.control_holder_for_test().await.as_deref(),
2292 Some("host-2")
2293 );
2294
2295 tokio::time::sleep(RELAY_CALL_TIMEOUT + Duration::from_secs(1)).await;
2298 tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
2299
2300 assert_eq!(
2301 view.control_holder_for_test().await.as_deref(),
2302 Some("host-2"),
2303 "a holder who took control legitimately must not be revoked by a timer armed \
2304 for the connection they replaced"
2305 );
2306 }
2307
2308 fn count_control_calls(
2312 frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2313 action: &str,
2314 ) -> usize {
2315 frames
2316 .lock()
2317 .unwrap()
2318 .iter()
2319 .filter_map(|text| serde_json::from_str::<Value>(text).ok())
2320 .filter(|value| {
2321 value["method"] == "agent.browser.control" && value["params"]["action"] == action
2322 })
2323 .count()
2324 }
2325
2326 #[tokio::test]
2345 async fn a_holder_that_was_also_watching_reconciles_its_disconnect_exactly_once() {
2346 let (channel, frames) = agent_channel();
2347 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2348 let registry = BrowserViewRegistry::new(std::env::temp_dir());
2349 let view = registry
2350 .register_relay("conv-1", Arc::clone(&producer))
2351 .await;
2352
2353 let taking = {
2354 let view = Arc::clone(&view);
2355 tokio::spawn(async move { view.take_control_for_test("host-1").await })
2356 };
2357 answer_next_call(
2358 &producer.channel,
2359 &frames,
2360 json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2361 )
2362 .await;
2363 taking.await.unwrap().expect("take control");
2364
2365 let (host_channel, _host_rx) = capture_channel();
2368 view.subscribe_for_test("host-1", host_channel).await;
2369 frames.lock().unwrap().clear();
2370
2371 tokio::time::timeout(
2372 Duration::from_secs(2),
2373 registry.drop_subscriptions_for_client("host-1"),
2374 )
2375 .await
2376 .expect("teardown must not park on a relay call nothing is going to answer");
2377
2378 for _ in 0..200 {
2381 if count_control_calls(&frames, "holder_disconnected") > 0 {
2382 break;
2383 }
2384 tokio::time::sleep(Duration::from_millis(5)).await;
2385 }
2386 tokio::time::sleep(Duration::from_millis(200)).await;
2387 assert_eq!(
2388 count_control_calls(&frames, "holder_disconnected"),
2389 1,
2390 "exactly one path owns a disconnect — two arm two grace timers with different \
2391 semantics and let the relay decide which one survives"
2392 );
2393 }
2394
2395 #[tokio::test(start_paused = true)]
2412 async fn a_drawer_that_returns_inside_the_window_cancels_its_own_grace_expiry() {
2413 let (channel, frames) = agent_channel();
2414 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2415 let registry = BrowserViewRegistry::new(std::env::temp_dir());
2416 let view = registry
2417 .register_relay("conv-1", Arc::clone(&producer))
2418 .await;
2419
2420 let taking = {
2422 let view = Arc::clone(&view);
2423 tokio::spawn(async move { view.take_control_for_test("host-1").await })
2424 };
2425 answer_next_call(
2426 &producer.channel,
2427 &frames,
2428 json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2429 )
2430 .await;
2431 taking.await.unwrap().expect("take control");
2432
2433 let (host_channel, _host_rx) = capture_channel();
2436 view.subscribe_for_test("host-1", host_channel).await;
2437
2438 registry.drop_subscriptions_for_client("host-1").await;
2440
2441 let (again, _again_rx) = capture_channel();
2444 view.subscribe_for_test("host-2", again).await;
2445
2446 frames.lock().unwrap().clear();
2447 tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
2448
2449 assert_eq!(
2450 count_control_calls(&frames, "grace_expired"),
2451 0,
2452 "a drawer watching inside the window is the person coming back — expiring under \
2453 them resolves their pending sign-in as failed and hands the page to the agent"
2454 );
2455 }
2456
2457 async fn pending_call_id(
2462 frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2463 action: &str,
2464 ) -> String {
2465 for _ in 0..200 {
2466 let found = frames
2467 .lock()
2468 .unwrap()
2469 .iter()
2470 .filter_map(|text| serde_json::from_str::<Value>(text).ok())
2471 .find(|value| {
2472 value["method"] == "agent.browser.control"
2473 && value["params"]["action"] == action
2474 && value.get("id").and_then(Value::as_str).is_some()
2475 })
2476 .map(|value| value["id"].as_str().unwrap().to_string());
2477 if let Some(id) = found {
2478 return id;
2479 }
2480 tokio::time::sleep(Duration::from_millis(5)).await;
2481 }
2482 panic!("no '{action}' reverse call arrived on the agent channel");
2483 }
2484
2485 async fn answer_call_by_id(channel: &Arc<WsChannel>, id: &str, result: Value) {
2487 let waiter = channel
2488 .pending
2489 .lock()
2490 .await
2491 .remove(id)
2492 .expect("the parked call is still pending");
2493 let _ = waiter.send(car_proto::ToolExecuteResponse {
2494 action_id: id.to_string(),
2495 output: Some(result),
2496 error: None,
2497 terminal: false,
2498 });
2499 }
2500
2501 #[tokio::test(start_paused = true)]
2512 async fn an_answered_holder_disconnect_does_not_re_arm_over_a_landed_take_control() {
2513 let (channel, frames) = agent_channel();
2514 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2515 let registry = BrowserViewRegistry::new(std::env::temp_dir());
2516 let view = registry
2517 .register_relay("conv-1", Arc::clone(&producer))
2518 .await;
2519
2520 let taking = {
2521 let view = Arc::clone(&view);
2522 tokio::spawn(async move { view.take_control_for_test("host-1").await })
2523 };
2524 answer_next_call(
2525 &producer.channel,
2526 &frames,
2527 json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2528 )
2529 .await;
2530 taking.await.unwrap().expect("take control");
2531
2532 view.note_disconnect("host-1", false).await;
2535 let disconnect_id = pending_call_id(&frames, "holder_disconnected").await;
2536 frames.lock().unwrap().clear();
2539
2540 let retaking = {
2542 let view = Arc::clone(&view);
2543 tokio::spawn(async move { view.take_control_for_test("host-2").await })
2544 };
2545 answer_next_call(
2546 &producer.channel,
2547 &frames,
2548 json!({ "presentation": presentation(WireOwner::User, "https://x.test/"), "effects": [] }),
2549 )
2550 .await;
2551 retaking.await.unwrap().expect("re-take control");
2552 assert_eq!(
2553 view.control_holder_for_test().await.as_deref(),
2554 Some("host-2")
2555 );
2556 let generation_after_retake = view.grace_generation_for_test().await;
2557
2558 answer_call_by_id(
2560 &producer.channel,
2561 &disconnect_id,
2562 json!({
2563 "presentation": presentation(WireOwner::User, "https://x.test/"),
2564 "effects": [{ "effect": "start_grace_period" }],
2565 }),
2566 )
2567 .await;
2568 for _ in 0..200 {
2569 if view.grace_generation_for_test().await != generation_after_retake {
2570 break;
2571 }
2572 tokio::time::sleep(Duration::from_millis(5)).await;
2573 }
2574 assert_eq!(
2575 view.grace_generation_for_test().await,
2576 generation_after_retake,
2577 "the disconnect already armed its clock before relaying; re-arming here would \
2578 capture host-2's generation and disarm the stale-expiry check"
2579 );
2580
2581 tokio::time::sleep(crate::browser_view::CONTROL_GRACE + Duration::from_secs(1)).await;
2582 assert_eq!(
2583 view.control_holder_for_test().await.as_deref(),
2584 Some("host-2"),
2585 "a holder who took control legitimately must survive a timer armed for the \
2586 connection they replaced, however late that connection's process answers"
2587 );
2588 }
2589
2590 #[tokio::test]
2597 async fn an_older_presentation_never_rewinds_the_cache_the_input_gate_reads() {
2598 let (channel, _frames) = agent_channel();
2599 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2600
2601 let mut taken = presentation(WireOwner::User, "https://x.test/");
2602 taken.revision = 9;
2603 producer.push_presentation(taken).await;
2604 assert_eq!(producer.control_status().await.owner, ControlOwner::User);
2605
2606 let mut stale = presentation(WireOwner::Agent, "https://x.test/");
2608 stale.revision = 8;
2609 producer.set_presentation(stale).await;
2610
2611 assert_eq!(
2612 producer.control_status().await.owner,
2613 ControlOwner::User,
2614 "the person still holds control, so their input must still be admitted"
2615 );
2616
2617 let mut newer = presentation(WireOwner::Agent, "https://x.test/");
2619 newer.revision = 10;
2620 producer.push_presentation(newer).await;
2621 assert_eq!(producer.control_status().await.owner, ControlOwner::Agent);
2622 }
2623
2624 #[tokio::test]
2633 async fn the_capture_signal_always_matches_the_settled_watcher_count() {
2634 let (channel, _frames) = agent_channel();
2635 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2636 let mut capture = producer.capture.subscribe();
2637
2638 producer.start_capture(); producer.stop_capture(); assert!(
2643 !*capture.borrow_and_update(),
2644 "count is 0, so capture is off"
2645 );
2646
2647 producer.start_capture();
2648 assert!(*capture.borrow_and_update());
2649 producer.start_capture();
2650 producer.stop_capture();
2651 assert!(
2652 *capture.borrow_and_update(),
2653 "one watcher remains, so the process must still be capturing"
2654 );
2655 producer.stop_capture();
2656 assert!(!*capture.borrow_and_update());
2657 }
2658
2659 #[tokio::test]
2660 async fn capture_is_asked_for_only_while_somebody_is_watching() {
2661 let (channel, frames) = agent_channel();
2662 let producer = RelayProducer::new("agent-conn".into(), "car-assistant".into(), channel);
2663 let mut capture = producer.capture.subscribe();
2664
2665 producer.start_capture();
2666 capture
2667 .changed()
2668 .await
2669 .expect("the first watcher changes capture");
2670 capture.borrow_and_update();
2671 let request = answer_next_call(&producer.channel, &frames, json!({ "ok": true })).await;
2672 assert_eq!(request["method"], "agent.browser.capture");
2673 assert_eq!(request["params"]["enabled"], true);
2674
2675 producer.start_capture();
2681 assert!(
2682 !capture.has_changed().expect("capture sender remains live"),
2683 "capture is a producer-level state, not a per-subscriber one; no signal was published"
2684 );
2685 tokio::time::sleep(Duration::from_millis(30)).await;
2686 assert!(
2687 frames.lock().unwrap().is_empty(),
2688 "capture is a producer-level state, not a per-subscriber one"
2689 );
2690
2691 producer.stop_capture();
2692 assert!(
2693 !capture.has_changed().expect("capture sender remains live"),
2694 "one watcher left, one remains — no stop signal was published"
2695 );
2696 tokio::time::sleep(Duration::from_millis(30)).await;
2697 assert!(
2698 frames.lock().unwrap().is_empty(),
2699 "one watcher left, one remains — the process keeps capturing"
2700 );
2701
2702 producer.stop_capture();
2703 capture
2704 .changed()
2705 .await
2706 .expect("the final watcher changes capture");
2707 assert!(!*capture.borrow_and_update());
2708 let request = answer_next_call(&producer.channel, &frames, json!({ "ok": true })).await;
2709 assert_eq!(request["params"]["enabled"], false);
2710 }
2711
2712 fn broken_channel() -> Arc<WsChannel> {
2717 use futures::sink::SinkExt as _;
2718 let (tx, rx) = futures::channel::mpsc::channel::<Message>(1);
2719 drop(rx);
2720 let sink: WsSink =
2721 Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
2722 Arc::new(WsChannel {
2723 write: Mutex::new(sink),
2724 pending: Mutex::new(HashMap::new()),
2725 active_actions: Mutex::new(HashMap::new()),
2726 next_id: std::sync::atomic::AtomicU64::new(0),
2727 })
2728 }
2729
2730 #[tokio::test]
2736 async fn a_control_transition_that_never_reached_the_process_fails_and_changes_nothing() {
2737 let (state, _temp) = test_state().await;
2738 let (host, _rx) = host_session(&state, "host-1").await;
2739 let producer = state
2740 .browser_views
2741 .producer_for("agent-conn", "car-assistant", &broken_channel())
2742 .await;
2743 producer
2744 .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
2745 .await;
2746 let view = state
2747 .browser_views
2748 .register_relay("conv-1", Arc::clone(&producer))
2749 .await;
2750
2751 let err = crate::browser_view::handle_take_control(
2752 &request(
2753 "browser.view.take_control",
2754 json!({ "conversation_id": "conv-1" }),
2755 ),
2756 &host,
2757 &state,
2758 )
2759 .await
2760 .expect_err("a transition that did not land must not report success");
2761 assert!(err.contains("unreachable"), "got: {err}");
2762
2763 assert_eq!(
2766 view.snapshot_for_test().await.0.owner,
2767 WireOwner::Agent,
2768 "the drawer must not be told the user took control of a browser that never heard"
2769 );
2770 let err = crate::browser_view::handle_input(
2771 crate::browser_view::InputOp::Click,
2772 &request(
2773 "browser.view.click",
2774 json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
2775 ),
2776 &host,
2777 &state,
2778 )
2779 .await
2780 .unwrap_err();
2781 assert!(
2782 err.contains("take_control"),
2783 "both sides agree the agent still holds it; got: {err}"
2784 );
2785 }
2786
2787 #[tokio::test]
2793 async fn a_reconnecting_process_republishes_between_turns_without_a_new_user_turn() {
2794 let (state, _temp) = test_state().await;
2795 let (agent, _frames) = agent_session(&state, "conn-1", "car-assistant", "conv-1").await;
2796 let (host, mut host_rx) = host_session(&state, "host-1").await;
2797
2798 handle_producer_register(
2799 &request(
2800 "browser.producer.register",
2801 json!({ "conversation_id": "conv-1",
2802 "presentation": presentation(WireOwner::Agent, "https://x.test/") }),
2803 ),
2804 &agent,
2805 &state,
2806 )
2807 .await
2808 .unwrap();
2809 let subscribed = crate::browser_view::handle_subscribe(
2810 &request(
2811 "browser.view.subscribe",
2812 json!({ "conversation_id": "conv-1" }),
2813 ),
2814 &host,
2815 &state,
2816 )
2817 .await
2818 .unwrap();
2819 let before = subscribed["cursor"].as_u64().unwrap();
2820
2821 state.chat_sessions.lock().await.remove("conv-1");
2824 state.remove_session("conn-1").await;
2825 assert_eq!(
2826 state
2827 .browser_views
2828 .get(Some("conv-1"))
2829 .await
2830 .unwrap()
2831 .snapshot_for_test()
2832 .await
2833 .0
2834 .owner,
2835 WireOwner::None,
2836 "the view reports the browser as gone while the process is away"
2837 );
2838
2839 let (channel, _frames) = agent_channel();
2842 let reconnected = state.create_session("conn-2", channel).await.unwrap();
2843 *reconnected.agent_id.lock().await = Some("car-assistant".to_string());
2844 handle_producer_register(
2845 &request(
2846 "browser.producer.register",
2847 json!({ "conversation_id": "conv-1",
2848 "presentation": presentation(WireOwner::Agent, "https://back.test/") }),
2849 ),
2850 &reconnected,
2851 &state,
2852 )
2853 .await
2854 .expect("the agent that established this conversation may republish it");
2855
2856 let view = state.browser_views.get(Some("conv-1")).await.unwrap();
2857 assert_eq!(
2858 view.snapshot_for_test().await.0.url.as_deref(),
2859 Some("https://back.test/")
2860 );
2861 assert_eq!(
2862 view.subscriber_count_for_test().await,
2863 1,
2864 "the drawer came across without re-subscribing"
2865 );
2866 let mut seen = next_event(&mut host_rx).await;
2867 while seen.cursor <= before {
2868 seen = next_event(&mut host_rx).await;
2869 }
2870 assert!(seen.cursor > before, "the cursor never moves backwards");
2871 }
2872
2873 #[tokio::test]
2877 async fn republishing_is_still_refused_to_an_agent_that_never_served_the_conversation() {
2878 let (state, _temp) = test_state().await;
2879 let (agent, _frames) = agent_session(&state, "conn-1", "car-assistant", "conv-1").await;
2880 handle_producer_register(
2881 &request(
2882 "browser.producer.register",
2883 json!({ "conversation_id": "conv-1" }),
2884 ),
2885 &agent,
2886 &state,
2887 )
2888 .await
2889 .unwrap();
2890 state.chat_sessions.lock().await.remove("conv-1");
2891
2892 let (channel, _frames) = agent_channel();
2894 let impostor = state.create_session("conn-x", channel).await.unwrap();
2895 *impostor.agent_id.lock().await = Some("some-other-agent".to_string());
2896 let err = handle_producer_register(
2897 &request(
2898 "browser.producer.register",
2899 json!({ "conversation_id": "conv-1" }),
2900 ),
2901 &impostor,
2902 &state,
2903 )
2904 .await
2905 .unwrap_err();
2906 assert!(
2907 err.contains("is served by agent 'car-assistant'"),
2908 "got: {err}"
2909 );
2910
2911 let err = handle_producer_register(
2913 &request(
2914 "browser.producer.register",
2915 json!({ "conversation_id": "never-seen" }),
2916 ),
2917 &impostor,
2918 &state,
2919 )
2920 .await
2921 .unwrap_err();
2922 assert!(err.contains("not an active chat session"), "got: {err}");
2923 }
2924
2925 #[test]
2926 fn a_conversation_claim_needs_a_live_turn_or_a_binding_this_agent_established() {
2927 assert!(
2929 authorize_conversation_claim("c", "a", Some("a"), None).is_ok(),
2930 "the agent the daemon dispatched the turn to"
2931 );
2932 assert!(authorize_conversation_claim("c", "a", None, Some("a")).is_ok());
2934 assert!(authorize_conversation_claim("c", "a", Some("b"), Some("a")).is_err());
2936 assert!(authorize_conversation_claim("c", "a", None, Some("b")).is_err());
2938 assert!(authorize_conversation_claim("c", "a", None, None).is_err());
2940 }
2941
2942 #[test]
2945 fn every_input_round_trips_through_the_wire() {
2946 for input in [
2947 ViewInput::Navigate {
2948 url: "https://x.test/".into(),
2949 },
2950 ViewInput::Click { x: 1.5, y: 2.5 },
2951 ViewInput::Type {
2952 text: "hello".into(),
2953 },
2954 ViewInput::Keypress {
2955 key: "Enter".into(),
2956 modifiers: vec![Modifier::Meta, Modifier::Shift],
2957 },
2958 ViewInput::Scroll { delta_y: -120 },
2959 ViewInput::Paste {
2960 text: "pasted".into(),
2961 },
2962 ViewInput::Back,
2963 ViewInput::Forward,
2964 ViewInput::Reload,
2965 ViewInput::TabOpen,
2966 ViewInput::TabClose {
2967 tab_id: "tab-2".into(),
2968 },
2969 ViewInput::TabSwitch {
2970 tab_id: "tab-3".into(),
2971 },
2972 ] {
2973 let wire = input_to_wire(&input);
2974 assert_eq!(
2975 input_from_wire(&wire).expect("decodes"),
2976 input,
2977 "round trip failed for {wire}"
2978 );
2979 }
2980 }
2981
2982 #[test]
2983 fn every_control_action_and_effect_round_trips() {
2984 for control in [
2985 ViewControl::TakeControl,
2986 ViewControl::HandBack,
2987 ViewControl::RunEnded,
2988 ViewControl::HolderDisconnected,
2989 ViewControl::GraceExpired,
2990 ] {
2991 assert_eq!(
2992 control_from_wire(control_to_wire(control)).unwrap(),
2993 control
2994 );
2995 }
2996 let effects = vec![
2997 ControlEffect::StartGracePeriod,
2998 ControlEffect::SignInResolved { signed_in: true },
2999 ];
3000 assert_eq!(effects_from_wire(&effects_to_wire(&effects)), effects);
3001 }
3002
3003 #[test]
3004 fn an_unknown_effect_is_dropped_rather_than_failing_the_transition() {
3005 let wire = json!([{ "effect": "teleport" }, { "effect": "start_grace_period" }]);
3006 assert_eq!(
3007 effects_from_wire(&wire),
3008 vec![ControlEffect::StartGracePeriod]
3009 );
3010 }
3011
3012 #[test]
3013 fn an_unknown_input_op_is_a_clean_error() {
3014 let err = input_from_wire(&json!({ "op": "read_dom" })).unwrap_err();
3015 assert!(err.contains("unknown agent.browser.input op"), "got: {err}");
3016 }
3017
3018 #[tokio::test]
3021 async fn a_registered_conversation_resolves_to_the_process_s_browser() {
3022 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3023 let (channel, _frames) = agent_channel();
3024 let producer = registry
3025 .producer_for("agent-conn", "car-assistant", &channel)
3026 .await;
3027 producer
3028 .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
3029 .await;
3030 let view = registry
3031 .register_relay("conv-1", Arc::clone(&producer))
3032 .await;
3033
3034 let found = registry.get(Some("conv-1")).await.expect("registered");
3035 assert!(Arc::ptr_eq(&view, &found));
3036 let (snapshot, _) = found.snapshot_for_test().await;
3037 assert_eq!(snapshot.url.as_deref(), Some("https://x.test/"));
3038 assert_eq!(snapshot.owner, WireOwner::Agent);
3039 }
3040
3041 #[tokio::test]
3042 async fn re_registering_the_same_conversation_is_a_no_op_for_the_drawer() {
3043 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3044 let (channel, _frames) = agent_channel();
3045 let producer = registry
3046 .producer_for("agent-conn", "car-assistant", &channel)
3047 .await;
3048 let first = registry
3049 .register_relay("conv-1", Arc::clone(&producer))
3050 .await;
3051 let second = registry
3053 .register_relay("conv-1", Arc::clone(&producer))
3054 .await;
3055 assert!(
3056 Arc::ptr_eq(&first, &second),
3057 "the same process re-claiming its own conversation keeps the view"
3058 );
3059 }
3060
3061 #[tokio::test]
3062 async fn one_process_backs_every_conversation_it_registers() {
3063 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3064 let (channel, _frames) = agent_channel();
3065 let producer = registry
3066 .producer_for("agent-conn", "car-assistant", &channel)
3067 .await;
3068 registry
3069 .register_relay("conv-1", Arc::clone(&producer))
3070 .await;
3071 registry
3072 .register_relay("conv-2", Arc::clone(&producer))
3073 .await;
3074
3075 producer
3076 .push_presentation(presentation(WireOwner::Agent, "https://shared.test/"))
3077 .await;
3078
3079 for key in ["conv-1", "conv-2"] {
3080 let view = registry.get(Some(key)).await.expect("registered");
3081 assert_eq!(
3082 view.snapshot_for_test().await.0.url.as_deref(),
3083 Some("https://shared.test/"),
3084 "a supervised process has ONE browser; both of its conversations show it"
3085 );
3086 }
3087 }
3088
3089 #[tokio::test]
3093 async fn two_processes_two_conversations_never_see_each_other() {
3094 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3095 let (channel_a, _frames_a) = agent_channel();
3096 let (channel_b, _frames_b) = agent_channel();
3097 let alpha = registry
3098 .producer_for("conn-a", "agent-alpha", &channel_a)
3099 .await;
3100 let beta = registry
3101 .producer_for("conn-b", "agent-beta", &channel_b)
3102 .await;
3103 let view_a = registry.register_relay("conv-a", Arc::clone(&alpha)).await;
3104 let view_b = registry.register_relay("conv-b", Arc::clone(&beta)).await;
3105
3106 let (host_a, mut rx_a) = capture_channel();
3107 let (host_b, mut rx_b) = capture_channel();
3108 view_a.subscribe_for_test("host-1", host_a).await;
3109 view_b.subscribe_for_test("host-1", host_b).await;
3110
3111 alpha
3112 .push_presentation(presentation(WireOwner::Agent, "https://alpha.test/"))
3113 .await;
3114 beta.push_presentation(presentation(WireOwner::User, "https://beta.test/"))
3115 .await;
3116 alpha
3117 .push_frame(WireFrame {
3118 jpeg_base64: "QQ==".into(),
3119 width: 800,
3120 height: 600,
3121 device_pixel_ratio: 1.0,
3122 captured_at: 0.0,
3123 })
3124 .await;
3125
3126 assert_eq!(
3127 view_a.snapshot_for_test().await.0.url.as_deref(),
3128 Some("https://alpha.test/")
3129 );
3130 assert_eq!(
3131 view_b.snapshot_for_test().await.0.url.as_deref(),
3132 Some("https://beta.test/")
3133 );
3134
3135 match next_event(&mut rx_a).await.payload {
3137 crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
3138 assert_eq!(presentation.url.as_deref(), Some("https://alpha.test/"));
3139 }
3140 crate::browser_view::BrowserViewPayload::Frame { .. } => {
3141 panic!("expected alpha's presentation first")
3142 }
3143 }
3144 match next_event(&mut rx_a).await.payload {
3145 crate::browser_view::BrowserViewPayload::Frame { frame } => {
3146 assert_eq!(frame.jpeg_base64, "QQ==");
3147 }
3148 crate::browser_view::BrowserViewPayload::Presentation { .. } => {
3149 panic!("expected alpha's frame")
3150 }
3151 }
3152 match next_event(&mut rx_b).await.payload {
3155 crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
3156 assert_eq!(presentation.url.as_deref(), Some("https://beta.test/"));
3157 assert_eq!(presentation.owner, WireOwner::User);
3158 }
3159 crate::browser_view::BrowserViewPayload::Frame { .. } => {
3160 panic!("beta's drawer must never receive alpha's frame")
3161 }
3162 }
3163 assert!(
3164 tokio::time::timeout(Duration::from_millis(100), rx_b.next())
3165 .await
3166 .is_err(),
3167 "nothing else crossed between the two conversations"
3168 );
3169 }
3170
3171 #[tokio::test]
3172 async fn a_restarted_process_replaces_the_view_and_carries_the_drawer_across() {
3173 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3174 let (first_channel, _first_frames) = agent_channel();
3175 let first = registry
3176 .producer_for("agent-conn-1", "car-assistant", &first_channel)
3177 .await;
3178 let view = registry.register_relay("conv-1", Arc::clone(&first)).await;
3179 let (host, mut host_rx) = capture_channel();
3180 view.subscribe_for_test("host-1", host).await;
3181 first
3182 .push_presentation(presentation(WireOwner::Agent, "https://x.test/"))
3183 .await;
3184 let before = next_event(&mut host_rx).await.cursor;
3185
3186 registry.note_producer_disconnected("agent-conn-1").await;
3189 let (second_channel, _second_frames) = agent_channel();
3190 let second = registry
3191 .producer_for("agent-conn-2", "car-assistant", &second_channel)
3192 .await;
3193 second
3194 .set_presentation(presentation(
3195 WireOwner::Agent,
3196 "https://after-restart.test/",
3197 ))
3198 .await;
3199 let replacement = registry.register_relay("conv-1", Arc::clone(&second)).await;
3200
3201 assert!(
3202 !Arc::ptr_eq(&view, &replacement),
3203 "a different process is a different producer, so a different view"
3204 );
3205 assert_eq!(
3206 replacement.subscriber_count_for_test().await,
3207 1,
3208 "the drawer came across without re-subscribing"
3209 );
3210 let mut previous_cursor = before;
3213 loop {
3214 let seen = next_event(&mut host_rx).await;
3215 assert_eq!(
3216 seen.cursor,
3217 previous_cursor + 1,
3218 "the cursor sequence must be contiguous and monotonic"
3219 );
3220 previous_cursor = seen.cursor;
3221 if matches!(
3222 seen.payload,
3223 crate::browser_view::BrowserViewPayload::Presentation { ref presentation }
3224 if presentation.url.as_deref() == Some("https://after-restart.test/")
3225 ) {
3226 break;
3227 }
3228 }
3229 assert_eq!(
3230 replacement.snapshot_for_test().await.0.url.as_deref(),
3231 Some("https://after-restart.test/")
3232 );
3233 }
3234
3235 #[tokio::test]
3236 async fn a_pushed_frame_reaches_the_drawer_with_the_next_cursor() {
3237 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3238 let (channel, _frames) = agent_channel();
3239 let producer = registry
3240 .producer_for("agent-conn", "car-assistant", &channel)
3241 .await;
3242 let view = registry
3243 .register_relay("conv-1", Arc::clone(&producer))
3244 .await;
3245 let (host, mut host_rx) = capture_channel();
3246 let (_, cursor) = view.subscribe_for_test("host-1", host).await;
3247
3248 producer
3249 .push_frame(WireFrame {
3250 jpeg_base64: "AQID".into(),
3251 width: 1920,
3252 height: 1080,
3253 device_pixel_ratio: 2.0,
3254 captured_at: 1.5,
3255 })
3256 .await;
3257
3258 let event = next_event(&mut host_rx).await;
3259 assert_eq!(event.cursor, cursor + 1);
3260 assert_eq!(event.conversation_id.as_deref(), Some("conv-1"));
3261 match event.payload {
3262 crate::browser_view::BrowserViewPayload::Frame { frame } => {
3263 assert_eq!(frame.jpeg_base64, "AQID");
3264 assert_eq!(frame.width, 1920);
3265 assert_eq!(frame.device_pixel_ratio, 2.0);
3266 }
3267 crate::browser_view::BrowserViewPayload::Presentation { .. } => {
3268 panic!("expected a frame event")
3269 }
3270 }
3271 }
3272
3273 #[tokio::test]
3280 async fn a_registration_retires_this_producer_s_oldest_views() {
3281 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3282 let (channel, _frames) = agent_channel();
3283 let producer = registry
3284 .producer_for("agent-conn", "car-assistant", &channel)
3285 .await;
3286
3287 for turn in 0..MAX_VIEWS_PER_PRODUCER {
3289 let key = format!("turn-{turn}");
3290 registry.bind_conversation(&key, "car-assistant").await;
3291 registry.register_relay(key, Arc::clone(&producer)).await;
3292 }
3293 assert!(
3294 registry.get(Some("turn-0")).await.is_some(),
3295 "precondition: nothing is retired while the producer is within its cap"
3296 );
3297
3298 registry.bind_conversation("turn-8", "car-assistant").await;
3300 registry
3301 .register_relay("turn-8", Arc::clone(&producer))
3302 .await;
3303
3304 assert!(
3305 registry.get(Some("turn-0")).await.is_none(),
3306 "the oldest turn's view must be retired, not accumulated"
3307 );
3308 assert!(
3309 registry.conversation_owner("turn-0").await.is_none(),
3310 "and its binding with it — nothing can re-register a key past the cap"
3311 );
3312 assert!(
3313 registry.get(Some("turn-1")).await.is_some(),
3314 "only what is PAST the cap goes"
3315 );
3316 assert!(registry.get(Some("turn-8")).await.is_some());
3317 }
3318
3319 #[tokio::test]
3324 async fn a_retired_view_somebody_is_watching_survives() {
3325 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3326 let (channel, _frames) = agent_channel();
3327 let producer = registry
3328 .producer_for("agent-conn", "car-assistant", &channel)
3329 .await;
3330
3331 let watched = registry
3332 .register_relay("turn-0", Arc::clone(&producer))
3333 .await;
3334 let (host, _rx) = capture_channel();
3335 watched.subscribe_for_test("host-1", host).await;
3336
3337 for turn in 1..=MAX_VIEWS_PER_PRODUCER {
3338 registry
3339 .register_relay(format!("turn-{turn}"), Arc::clone(&producer))
3340 .await;
3341 }
3342
3343 assert!(
3344 registry.get(Some("turn-0")).await.is_some(),
3345 "a view the drawer is subscribed to is never taken out from under it"
3346 );
3347 }
3348
3349 #[tokio::test]
3353 async fn a_frame_only_reaches_views_somebody_is_watching() {
3354 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3355 let (channel, _frames) = agent_channel();
3356 let producer = registry
3357 .producer_for("agent-conn", "car-assistant", &channel)
3358 .await;
3359 let unwatched = registry
3360 .register_relay("turn-0", Arc::clone(&producer))
3361 .await;
3362 let watched = registry
3363 .register_relay("turn-1", Arc::clone(&producer))
3364 .await;
3365 let (host, mut host_rx) = capture_channel();
3366 let (_snapshot, cursor) = watched.subscribe_for_test("host-1", host).await;
3367 let (_, unwatched_cursor) = unwatched.snapshot_for_test().await;
3368
3369 producer
3370 .push_frame(WireFrame {
3371 jpeg_base64: "AQID".into(),
3372 width: 8,
3373 height: 8,
3374 device_pixel_ratio: 1.0,
3375 captured_at: 0.5,
3376 })
3377 .await;
3378
3379 let event = next_event(&mut host_rx).await;
3380 assert_eq!(event.cursor, cursor + 1, "the watched view is served");
3381 let (_, after) = unwatched.snapshot_for_test().await;
3382 assert_eq!(
3383 after, unwatched_cursor,
3384 "a view nobody is watching pays nothing — not even a cursor bump"
3385 );
3386 }
3387
3388 #[tokio::test]
3392 async fn a_disconnected_producer_is_dropped_from_the_registry_and_clears_its_views() {
3393 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3394 let (channel, _frames) = agent_channel();
3395 let producer = registry
3396 .producer_for("agent-conn", "car-assistant", &channel)
3397 .await;
3398 producer
3399 .set_presentation(presentation(WireOwner::Agent, "https://x.test/"))
3400 .await;
3401 let view = registry
3402 .register_relay("conv-1", Arc::clone(&producer))
3403 .await;
3404 let (host, _rx) = capture_channel();
3405 view.subscribe_for_test("host-1", host).await;
3406
3407 registry.note_producer_disconnected("agent-conn").await;
3408
3409 assert!(registry.producer("agent-conn").await.is_none());
3410 let view = registry
3411 .get(Some("conv-1"))
3412 .await
3413 .expect("the view stays so a restarted process can replace it");
3414 let (snapshot, _) = view.snapshot_for_test().await;
3415 assert_eq!(snapshot.owner, WireOwner::None);
3416 assert_eq!(snapshot.url, None);
3417 }
3418
3419 #[tokio::test]
3428 async fn a_disconnected_producer_s_unwatched_views_are_released_with_their_socket() {
3429 let registry = BrowserViewRegistry::new(std::env::temp_dir());
3430 let (channel, _frames) = agent_channel();
3431 let producer = registry
3432 .producer_for("agent-conn", "car-assistant", &channel)
3433 .await;
3434 let weak = Arc::downgrade(&producer);
3435 registry
3436 .register_relay("conv-1", Arc::clone(&producer))
3437 .await;
3438 drop(producer);
3439
3440 registry.note_producer_disconnected("agent-conn").await;
3441
3442 assert!(
3443 registry.get(Some("conv-1")).await.is_none(),
3444 "an unwatched view for a process that is gone must not stay registered"
3445 );
3446 assert!(
3447 weak.upgrade().is_none(),
3448 "and the producer — with the dead connection's WsChannel — must actually be released"
3449 );
3450 }
3451
3452 async fn test_state() -> (Arc<ServerState>, tempfile::TempDir) {
3455 let temp = tempfile::tempdir().unwrap();
3456 let state = Arc::new(ServerState::with_config(
3457 crate::session::ServerStateConfig::new(temp.path().to_path_buf()),
3458 ));
3459 (state, temp)
3460 }
3461
3462 fn request(method: &str, params: Value) -> JsonRpcMessage {
3463 JsonRpcMessage {
3464 jsonrpc: "2.0".to_string(),
3465 id: json!(1),
3466 method: Some(method.to_string()),
3467 params,
3468 result: None,
3469 error: None,
3470 }
3471 }
3472
3473 fn notification(method: &str, params: Value) -> JsonRpcMessage {
3474 JsonRpcMessage {
3475 jsonrpc: "2.0".to_string(),
3476 id: Value::Null,
3477 method: Some(method.to_string()),
3478 params,
3479 result: None,
3480 error: None,
3481 }
3482 }
3483
3484 async fn agent_session(
3488 state: &Arc<ServerState>,
3489 client_id: &str,
3490 agent_id: &str,
3491 conversation: &str,
3492 ) -> (
3493 Arc<ClientSession>,
3494 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
3495 ) {
3496 let (channel, frames) = agent_channel();
3497 let session = state.create_session(client_id, channel).await.unwrap();
3498 *session.agent_id.lock().await = Some(agent_id.to_string());
3499 state.chat_sessions.lock().await.insert(
3500 conversation.to_string(),
3501 crate::session::ChatSession {
3502 agent_id: agent_id.to_string(),
3503 host_client_id: "host-1".to_string(),
3504 created_at: 0,
3505 local_cancel: None,
3506 },
3507 );
3508 (session, frames)
3509 }
3510
3511 async fn host_session(
3512 state: &Arc<ServerState>,
3513 client_id: &str,
3514 ) -> (
3515 Arc<ClientSession>,
3516 futures::channel::mpsc::UnboundedReceiver<Message>,
3517 ) {
3518 let (channel, rx) = capture_channel();
3519 let session = state.create_session(client_id, channel).await.unwrap();
3520 session
3521 .is_host
3522 .store(true, std::sync::atomic::Ordering::Release);
3523 (session, rx)
3524 }
3525
3526 #[tokio::test]
3527 async fn a_connection_that_is_not_a_supervised_agent_cannot_publish_a_browser() {
3528 let (state, _temp) = test_state().await;
3529 let (session, _rx) = host_session(&state, "host-1").await;
3530
3531 let err = handle_producer_register(
3532 &request(
3533 "browser.producer.register",
3534 json!({ "conversation_id": "conv-1" }),
3535 ),
3536 &session,
3537 &state,
3538 )
3539 .await
3540 .unwrap_err();
3541 assert!(err.contains("not a supervised agent"), "got: {err}");
3542 assert!(state.browser_views.get(Some("conv-1")).await.is_none());
3543 }
3544
3545 #[tokio::test]
3546 async fn an_agent_cannot_claim_a_conversation_it_is_not_serving() {
3547 let (state, _temp) = test_state().await;
3548 let (session, _frames) =
3549 agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3550
3551 state.chat_sessions.lock().await.insert(
3553 "conv-other".to_string(),
3554 crate::session::ChatSession {
3555 agent_id: "some-other-agent".to_string(),
3556 host_client_id: "host-1".to_string(),
3557 created_at: 0,
3558 local_cancel: None,
3559 },
3560 );
3561 let err = handle_producer_register(
3562 &request(
3563 "browser.producer.register",
3564 json!({ "conversation_id": "conv-other" }),
3565 ),
3566 &session,
3567 &state,
3568 )
3569 .await
3570 .unwrap_err();
3571 assert!(
3572 err.contains("is served by agent 'some-other-agent'"),
3573 "got: {err}"
3574 );
3575
3576 let err = handle_producer_register(
3578 &request(
3579 "browser.producer.register",
3580 json!({ "conversation_id": "ghost" }),
3581 ),
3582 &session,
3583 &state,
3584 )
3585 .await
3586 .unwrap_err();
3587 assert!(err.contains("not an active chat session"), "got: {err}");
3588 assert!(state.browser_views.get(Some("ghost")).await.is_none());
3589 }
3590
3591 #[tokio::test]
3596 async fn the_drawer_subscribes_by_conversation_and_receives_the_process_s_pushes() {
3597 let (state, _temp) = test_state().await;
3598 let (agent, agent_frames) =
3599 agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3600 let (host, mut host_rx) = host_session(&state, "host-1").await;
3601
3602 let out = handle_producer_register(
3603 &request(
3604 "browser.producer.register",
3605 json!({
3606 "conversation_id": "conv-1",
3607 "presentation": presentation(WireOwner::Agent, "https://x.test/"),
3608 }),
3609 ),
3610 &agent,
3611 &state,
3612 )
3613 .await
3614 .expect("the agent may publish the conversation it serves");
3615 assert_eq!(out["ok"], true);
3616
3617 let snapshot = crate::browser_view::handle_subscribe(
3619 &request(
3620 "browser.view.subscribe",
3621 json!({ "conversation_id": "conv-1" }),
3622 ),
3623 &host,
3624 &state,
3625 )
3626 .await
3627 .expect("a supervised agent's browser is subscribable by conversation");
3628 assert_eq!(snapshot["standing_session"], false);
3629 assert_eq!(snapshot["presentation"]["url"], "https://x.test/");
3630 assert_eq!(snapshot["presentation"]["owner"], "agent");
3631 let cursor = snapshot["cursor"].as_u64().unwrap();
3632
3633 let capture = answer_next_call(&agent.channel, &agent_frames, json!({ "ok": true })).await;
3637 assert_eq!(capture["method"], "agent.browser.capture");
3638 assert_eq!(capture["params"]["enabled"], true);
3639
3640 assert!(
3642 try_handle_producer_push(
3643 ¬ification(
3644 "browser.producer.presentation",
3645 json!({ "presentation": presentation(WireOwner::Agent, "https://moved.test/") }),
3646 ),
3647 &state,
3648 &agent,
3649 )
3650 .await
3651 );
3652 let event = next_event(&mut host_rx).await;
3653 assert_eq!(event.cursor, cursor + 1);
3654 match event.payload {
3655 crate::browser_view::BrowserViewPayload::Presentation { presentation } => {
3656 assert_eq!(presentation.url.as_deref(), Some("https://moved.test/"));
3657 }
3658 crate::browser_view::BrowserViewPayload::Frame { .. } => {
3659 panic!("expected a presentation event")
3660 }
3661 }
3662 }
3663
3664 #[tokio::test]
3670 async fn producer_register_reports_whether_a_host_is_currently_connected() {
3671 let (state, _temp) = test_state().await;
3672 let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3673
3674 let out = handle_producer_register(
3676 &request(
3677 "browser.producer.register",
3678 json!({ "conversation_id": "conv-1" }),
3679 ),
3680 &agent,
3681 &state,
3682 )
3683 .await
3684 .unwrap();
3685 assert_eq!(out["host_connected"], false);
3686
3687 let (_host, _host_rx) = host_session(&state, "host-1").await;
3693 let out = handle_producer_register(
3694 &request(
3695 "browser.producer.register",
3696 json!({ "conversation_id": "conv-1" }),
3697 ),
3698 &agent,
3699 &state,
3700 )
3701 .await
3702 .unwrap();
3703 assert_eq!(out["host_connected"], true);
3704 }
3705
3706 fn host_connected_calls(
3709 frames: &std::sync::Arc<std::sync::Mutex<Vec<String>>>,
3710 ) -> (usize, Option<Value>) {
3711 let seen: Vec<Value> = frames
3712 .lock()
3713 .unwrap()
3714 .iter()
3715 .filter_map(|text| serde_json::from_str::<Value>(text).ok())
3716 .filter(|value| value["method"] == "agent.browser.host_connected")
3717 .collect();
3718 let last = seen
3719 .last()
3720 .map(|value| value["params"]["connected"].clone());
3721 (seen.len(), last)
3722 }
3723
3724 #[tokio::test]
3730 async fn only_a_host_removal_tells_the_producers_that_host_connectivity_changed() {
3731 let (state, _temp) = test_state().await;
3732 let (agent, frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3733 handle_producer_register(
3734 &request(
3735 "browser.producer.register",
3736 json!({ "conversation_id": "conv-1" }),
3737 ),
3738 &agent,
3739 &state,
3740 )
3741 .await
3742 .unwrap();
3743
3744 let (_host, _host_rx) = host_session(&state, "host-1").await;
3745 let (other_channel, _other_rx) = capture_channel();
3746 state
3747 .create_session("other-1", other_channel)
3748 .await
3749 .unwrap();
3750 frames.lock().unwrap().clear();
3751
3752 state
3754 .remove_session("other-1")
3755 .await
3756 .expect("the non-host session was registered");
3757 tokio::time::sleep(Duration::from_millis(100)).await;
3758 assert_eq!(
3759 host_connected_calls(&frames).0,
3760 0,
3761 "a non-host disconnect must not fan a reverse call to every producer"
3762 );
3763
3764 state
3766 .remove_session("host-1")
3767 .await
3768 .expect("the host session was registered");
3769 for _ in 0..200 {
3770 if host_connected_calls(&frames).0 > 0 {
3771 break;
3772 }
3773 tokio::time::sleep(Duration::from_millis(5)).await;
3774 }
3775 let (count, connected) = host_connected_calls(&frames);
3776 assert_eq!(
3777 count, 1,
3778 "removing the last host must still tell the producers"
3779 );
3780 assert_eq!(
3781 connected,
3782 Some(json!(false)),
3783 "the session is already out of `sessions`, so the broadcast reads the \
3784 post-removal truth"
3785 );
3786 }
3787
3788 #[tokio::test]
3789 async fn a_push_from_a_connection_with_no_producer_is_consumed_and_dropped() {
3790 let (state, _temp) = test_state().await;
3791 let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3792
3793 assert!(
3796 try_handle_producer_push(
3797 ¬ification(
3798 "browser.producer.frame",
3799 json!({ "frame": { "jpeg_base64": "AQ==", "width": 1, "height": 1,
3800 "device_pixel_ratio": 1.0, "captured_at": 0.0 } }),
3801 ),
3802 &state,
3803 &agent,
3804 )
3805 .await
3806 );
3807 assert!(state.browser_views.get(Some("conv-1")).await.is_none());
3808 }
3809
3810 #[tokio::test]
3811 async fn a_producer_request_with_an_id_is_left_to_the_dispatcher() {
3812 let (state, _temp) = test_state().await;
3813 let (agent, _frames) = agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3814 assert!(
3815 !try_handle_producer_push(
3816 &request("browser.producer.presentation", json!({})),
3817 &state,
3818 &agent,
3819 )
3820 .await,
3821 "a frame with an id is a request; it must get a real reply, not be swallowed"
3822 );
3823 }
3824
3825 #[tokio::test]
3829 async fn input_reaches_the_process_only_after_the_control_gate_passes() {
3830 let (state, _temp) = test_state().await;
3831 let (agent, agent_frames) =
3832 agent_session(&state, "agent-conn", "car-assistant", "conv-1").await;
3833 let (host, _host_rx) = host_session(&state, "host-1").await;
3834
3835 handle_producer_register(
3836 &request(
3837 "browser.producer.register",
3838 json!({
3839 "conversation_id": "conv-1",
3840 "presentation": presentation(WireOwner::Agent, "https://x.test/"),
3841 }),
3842 ),
3843 &agent,
3844 &state,
3845 )
3846 .await
3847 .unwrap();
3848
3849 let click = request(
3851 "browser.view.click",
3852 json!({ "conversation_id": "conv-1", "x": 4.0, "y": 5.0 }),
3853 );
3854 let err = crate::browser_view::handle_input(
3855 crate::browser_view::InputOp::Click,
3856 &click,
3857 &host,
3858 &state,
3859 )
3860 .await
3861 .unwrap_err();
3862 assert!(err.contains("take_control"), "got: {err}");
3863 assert!(
3864 agent_frames.lock().unwrap().is_empty(),
3865 "a refused input must never reach the agent process"
3866 );
3867
3868 let taking = tokio::spawn({
3870 let state = Arc::clone(&state);
3871 let host = Arc::clone(&host);
3872 async move {
3873 crate::browser_view::handle_take_control(
3874 &request(
3875 "browser.view.take_control",
3876 json!({ "conversation_id": "conv-1" }),
3877 ),
3878 &host,
3879 &state,
3880 )
3881 .await
3882 }
3883 });
3884 let request_frame = answer_next_call(
3885 &agent.channel,
3886 &agent_frames,
3887 json!({
3888 "presentation": presentation(WireOwner::User, "https://x.test/"),
3889 "effects": [],
3890 }),
3891 )
3892 .await;
3893 assert_eq!(request_frame["method"], "agent.browser.control");
3894 assert_eq!(request_frame["params"]["action"], "take_control");
3895 assert_eq!(
3896 taking.await.unwrap().unwrap()["presentation"]["owner"],
3897 "user"
3898 );
3899
3900 let clicking = tokio::spawn({
3901 let state = Arc::clone(&state);
3902 let host = Arc::clone(&host);
3903 async move {
3904 crate::browser_view::handle_input(
3905 crate::browser_view::InputOp::Click,
3906 &click,
3907 &host,
3908 &state,
3909 )
3910 .await
3911 }
3912 });
3913 let request_frame = answer_next_call(&agent.channel, &agent_frames, json!({})).await;
3914 assert_eq!(request_frame["method"], "agent.browser.input");
3915 assert_eq!(request_frame["params"]["op"], "click");
3916 assert_eq!(request_frame["params"]["x"], 4.0);
3917 assert_eq!(clicking.await.unwrap().unwrap()["ok"], true);
3918 }
3919}