1use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, Mutex as StdMutex};
19
20use async_trait::async_trait;
21use base64::Engine;
22use serde_json::{json, Value};
23use tokio::sync::{broadcast, mpsc};
24
25use rpi_agent::{AgentEvent, AgentMessage};
26use rpi_ai::types::{AssistantMessage, Content, ThinkingLevel};
27use rpi_harness::agent_harness::AgentLane;
28use rpi_tui::AssistantBlock;
29
30use crate::remote::client::RemoteClient;
31use crate::remote::protocol::{RemoteCommand, RemoteEvent};
32use crate::remote::session::RemoteSession;
33use crate::transcript_view::{UiEvent, UiToolCall};
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum DriverCommand {
38 UiDialogResponse {
40 request_id: String,
41 answer: Option<Value>,
42 },
43 Prompt(String),
45 Abort,
47 RefreshState,
49 SetModel(String),
51 SetThinkingLevel(String),
53 SetActiveTools(Vec<String>),
55}
56
57#[derive(Debug, Clone, Default, PartialEq)]
59pub struct DriverStatus {
60 pub ready: bool,
61 pub streaming: bool,
62 pub model: Option<String>,
63 pub thinking: Option<String>,
64 pub active_tools: Vec<String>,
65 pub retry: Option<(u32, u32, u64)>,
67}
68
69#[derive(Debug, Clone)]
71pub enum DriverEvent {
72 UiDialog(rpi_extensions::UiDialogRequest),
73 UiDialogClosed(String),
74 Ui(UiEvent),
76 Status(DriverStatus),
78 Notice(String),
80 Ended,
82}
83
84#[async_trait]
88pub trait SessionDriver: Send + Sync {
89 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>>;
92
93 async fn send(&self, command: DriverCommand) -> Result<(), String>;
95}
96
97pub fn map_agent_event(event: &AgentEvent) -> Option<UiEvent> {
104 match event {
105 AgentEvent::AgentStart => Some(UiEvent::AgentStart),
106 AgentEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
107 AgentEvent::RetryScheduled {
108 attempt,
109 max_retries,
110 delay_ms,
111 error,
112 } => Some(UiEvent::Retry {
113 attempt: *attempt,
114 max_retries: *max_retries,
115 delay_ms: *delay_ms,
116 error: error.clone(),
117 }),
118 AgentEvent::TurnStart | AgentEvent::TurnEnd { .. } => None,
119 AgentEvent::MessageStart { message, .. } => match message {
120 AgentMessage::Assistant(a) => Some(UiEvent::AssistantStart {
121 blocks: assistant_blocks(a),
122 }),
123 _ => None,
124 },
125 AgentEvent::MessageUpdate { message, .. } => Some(UiEvent::AssistantUpdate {
126 blocks: assistant_blocks(message),
127 tool_calls: assistant_tool_calls(message),
128 }),
129 AgentEvent::MessageEnd { message, .. } => match message {
130 AgentMessage::Assistant(a) => Some(UiEvent::AssistantEnd {
131 blocks: assistant_blocks(a),
132 }),
133 _ => None,
134 },
135 AgentEvent::ToolExecutionStart {
136 tool_call_id,
137 tool_name,
138 args,
139 } => Some(UiEvent::ToolStart {
140 id: tool_call_id.clone(),
141 name: tool_name.clone(),
142 args: args.clone(),
143 }),
144 AgentEvent::ToolExecutionUpdate {
145 tool_call_id,
146 tool_name,
147 args,
148 partial_result,
149 } => Some(UiEvent::ToolUpdate {
150 id: tool_call_id.clone(),
151 name: tool_name.clone(),
152 args: args.clone(),
153 result: crate::remote::protocol::tool_result_json(partial_result),
154 }),
155 AgentEvent::ToolExecutionEnd {
156 tool_call_id,
157 tool_name,
158 result,
159 is_error,
160 } => Some(UiEvent::ToolEnd {
161 id: tool_call_id.clone(),
162 name: tool_name.clone(),
163 result: crate::remote::protocol::tool_result_json(result),
164 is_error: *is_error,
165 }),
166 }
167}
168
169pub fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
172 msg.content
173 .iter()
174 .filter_map(|c| match c {
175 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
176 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
177 Content::Image(image) => base64::engine::general_purpose::STANDARD
178 .decode(&*image.data)
179 .ok()
180 .filter(|data| !data.is_empty())
181 .map(AssistantBlock::Image),
182 _ => None,
183 })
184 .collect()
185}
186
187pub fn assistant_tool_calls(msg: &AssistantMessage) -> Vec<UiToolCall> {
190 msg.content
191 .iter()
192 .filter_map(|c| match c {
193 Content::ToolCall(tc) if !tc.name.trim().is_empty() => Some(UiToolCall {
194 id: tc.id.clone(),
195 name: tc.name.clone(),
196 args: tc.arguments.clone(),
197 }),
198 _ => None,
199 })
200 .collect()
201}
202
203pub struct RemoteDriver {
209 client: tokio::sync::Mutex<RemoteClient>,
210 session_id: String,
211 next_id: AtomicU64,
212 events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
213}
214
215impl RemoteDriver {
216 pub async fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
219 let mut client = RemoteClient::connect(addr).await?;
220 client
221 .authenticate(token.unwrap_or(""))
222 .await
223 .map_err(auth_hint)?;
224
225 let started = client
226 .call("start_session", json!({}))
227 .await
228 .map_err(auth_hint)?;
229 let session_id = started
230 .get("sessionId")
231 .and_then(Value::as_str)
232 .ok_or("server did not return a sessionId")?
233 .to_string();
234
235 let subscribed = client
236 .call("subscribe", json!({"sessionId": session_id}))
237 .await?;
238 let subscription_id = subscribed
239 .get("subscriptionId")
240 .and_then(Value::as_u64)
241 .ok_or("server did not return a subscriptionId")?;
242
243 let mut lines = client.start_event_pump(subscription_id);
244 let (tx, rx) = mpsc::unbounded_channel();
245 tokio::spawn(async move {
246 let mut session = RemoteSession::new();
247 let mut mapper = RemoteEventMapper::default();
248 let mut last_error: Option<String> = None;
249 while let Some(line) = lines.recv().await {
250 session.apply_line(&line);
251 let _ = tx.send(DriverEvent::Status(status_of(&session)));
252 if session.last_error != last_error {
253 last_error = session.last_error.clone();
254 if let Some(error) = &last_error {
255 let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
256 }
257 }
258 if let Ok(event) = serde_json::from_value::<RemoteEvent>(line) {
259 if let Some(event) = mapper.map(&event) {
260 let _ = tx.send(event);
261 }
262 }
263 }
264 let _ = tx.send(DriverEvent::Ended);
265 });
266
267 let driver = Self {
268 client: tokio::sync::Mutex::new(client),
269 session_id,
270 next_id: AtomicU64::new(1),
271 events: StdMutex::new(Some(rx)),
272 };
273 let _ = driver.send(DriverCommand::RefreshState).await;
275 Ok(driver)
276 }
277
278 pub async fn shutdown(&self) {
280 let mut client = self.client.lock().await;
281 client.stop_session(&self.session_id).await;
282 }
283}
284
285#[async_trait]
286impl SessionDriver for RemoteDriver {
287 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
288 self.events.lock().unwrap().take()
289 }
290
291 async fn send(&self, command: DriverCommand) -> Result<(), String> {
292 let remote = match command {
293 DriverCommand::UiDialogResponse { request_id, answer } => {
294 RemoteCommand::UiDialogResponse {
295 request_id,
296 cancelled: answer.is_none(),
297 answer,
298 }
299 }
300 DriverCommand::Prompt(content) => RemoteCommand::Prompt { content },
301 DriverCommand::Abort => RemoteCommand::Abort,
302 DriverCommand::RefreshState => RemoteCommand::GetState,
303 DriverCommand::SetModel(model) => RemoteCommand::SetModel { model },
304 DriverCommand::SetThinkingLevel(level) => RemoteCommand::SetThinkingLevel { level },
305 DriverCommand::SetActiveTools(tools) => RemoteCommand::SetActiveTools { tools },
306 };
307 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
308 let mut client = self.client.lock().await;
309 client
310 .send_command(&self.session_id, remote.with_id(id))
311 .await
312 }
313}
314
315fn status_of(session: &RemoteSession) -> DriverStatus {
317 DriverStatus {
318 ready: session.ready,
319 streaming: session.streaming,
320 model: session.state.model.clone(),
321 thinking: session
322 .state
323 .thinking_level
324 .as_ref()
325 .and_then(Value::as_str)
326 .map(str::to_string),
327 active_tools: session.state.active_tools.clone(),
328 retry: session
329 .retry_attempt
330 .map(|(attempt, max)| (attempt, max, session.retry_delay_ms.unwrap_or(1000))),
331 }
332}
333
334#[derive(Default)]
337struct RemoteEventMapper {
338 messages: crate::remote::message_accumulator::MessageAccumulator,
339}
340
341impl RemoteEventMapper {
342 fn map(&mut self, event: &RemoteEvent) -> Option<DriverEvent> {
343 map_remote_driver_event(&self.messages.apply(event))
344 }
345}
346
347fn map_remote_driver_event(event: &RemoteEvent) -> Option<DriverEvent> {
350 match event {
351 RemoteEvent::UiDialogRequest {
352 request_id,
353 tool_call_id,
354 ui,
355 } => Some(DriverEvent::UiDialog(rpi_extensions::UiDialogRequest {
356 request_id: request_id.clone(),
357 tool_call_id: tool_call_id.clone(),
358 ui: ui.clone(),
359 raw: serde_json::to_value(event).unwrap_or(Value::Null),
360 })),
361 RemoteEvent::UiDialogClosed { request_id } => {
362 Some(DriverEvent::UiDialogClosed(request_id.clone()))
363 }
364 _ => map_remote_event(event).map(DriverEvent::Ui),
365 }
366}
367
368pub fn map_remote_event(event: &RemoteEvent) -> Option<UiEvent> {
370 use crate::remote::session::message_role;
371 use crate::transcript_view::{assistant_blocks_from_value, tool_calls_from_value};
372
373 match event {
374 RemoteEvent::UiDialogRequest { .. } | RemoteEvent::UiDialogClosed { .. } => None,
375 RemoteEvent::AgentStart => Some(UiEvent::AgentStart),
376 RemoteEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
377 RemoteEvent::RetryScheduled {
378 attempt,
379 max_retries,
380 delay_ms,
381 error,
382 } => Some(UiEvent::Retry {
383 attempt: *attempt,
384 max_retries: *max_retries,
385 delay_ms: *delay_ms,
386 error: error.clone(),
387 }),
388 RemoteEvent::MessageStart { message, .. } => {
389 if message_role(message) != "assistant" {
390 return None;
391 }
392 Some(UiEvent::AssistantStart {
393 blocks: assistant_blocks_from_value(message),
394 })
395 }
396 RemoteEvent::MessageUpdate { message, .. } => {
397 if !message.is_object() {
398 return None;
399 }
400 Some(UiEvent::AssistantUpdate {
401 blocks: assistant_blocks_from_value(message),
402 tool_calls: tool_calls_from_value(message),
403 })
404 }
405 RemoteEvent::MessageEnd { message, .. } => {
406 if message_role(message) != "assistant" {
407 return None;
408 }
409 Some(UiEvent::AssistantEnd {
410 blocks: assistant_blocks_from_value(message),
411 })
412 }
413 RemoteEvent::ToolExecutionStart {
414 tool_call_id,
415 tool_name,
416 args,
417 } => Some(UiEvent::ToolStart {
418 id: tool_call_id.clone(),
419 name: tool_name.clone(),
420 args: args.clone(),
421 }),
422 RemoteEvent::ToolExecutionUpdate {
423 tool_call_id,
424 tool_name,
425 args,
426 partial_result,
427 } => Some(UiEvent::ToolUpdate {
428 id: tool_call_id.clone(),
429 name: tool_name.clone(),
430 args: args.clone(),
431 result: partial_result.clone(),
432 }),
433 RemoteEvent::ToolExecutionEnd {
434 tool_call_id,
435 tool_name,
436 result,
437 is_error,
438 } => Some(UiEvent::ToolEnd {
439 id: tool_call_id.clone(),
440 name: tool_name.clone(),
441 result: result.clone(),
442 is_error: *is_error,
443 }),
444 RemoteEvent::TurnStart | RemoteEvent::TurnEnd { .. } => None,
445 }
446}
447
448fn auth_hint(error: String) -> String {
450 let lower = error.to_lowercase();
451 if lower.contains("authentication") || lower.contains("token") {
452 format!("{error} (retrieve the token printed by `rpi --server` and pass `--token <token>`)")
453 } else {
454 error
455 }
456}
457
458pub struct LocalDriver {
472 lane: Arc<dyn AgentLane>,
473 status_tx: mpsc::UnboundedSender<DriverEvent>,
474 events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
475}
476
477impl LocalDriver {
478 pub fn new(lane: Arc<dyn AgentLane>, events: broadcast::Receiver<AgentEvent>) -> Self {
480 let (tx, rx) = mpsc::unbounded_channel();
481 let status_tx = tx.clone();
482 tokio::spawn(pump_agent_events(lane.clone(), events, tx));
483 Self {
484 lane,
485 status_tx,
486 events: StdMutex::new(Some(rx)),
487 }
488 }
489}
490
491#[async_trait]
492impl SessionDriver for LocalDriver {
493 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
494 self.events.lock().unwrap().take()
495 }
496
497 async fn send(&self, command: DriverCommand) -> Result<(), String> {
498 match command {
499 DriverCommand::UiDialogResponse { .. } => {
500 Err("local driver has no dialog mailbox".to_string())
501 }
502 DriverCommand::Prompt(text) => {
503 let lane = self.lane.clone();
507 let tx = self.status_tx.clone();
508 tokio::spawn(async move {
509 if let Err(error) = lane.prompt_text(&text, Vec::new()).await {
510 let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
511 }
512 });
513 Ok(())
514 }
515 DriverCommand::Abort => self
516 .lane
517 .abort()
518 .await
519 .map(|_| ())
520 .map_err(|e| e.to_string()),
521 DriverCommand::RefreshState => {
522 let status = query_status(&self.lane, false).await;
523 let _ = self.status_tx.send(DriverEvent::Status(status));
524 Ok(())
525 }
526 DriverCommand::SetThinkingLevel(level) => {
527 let parsed = parse_thinking_level_name(&level)
528 .ok_or_else(|| format!("unknown thinking level: {level}"))?;
529 self.lane
530 .set_thinking_level(parsed)
531 .await
532 .map_err(|e| e.to_string())
533 }
534 DriverCommand::SetActiveTools(tools) => self
535 .lane
536 .set_active_tools(tools)
537 .await
538 .map_err(|e| e.to_string()),
539 DriverCommand::SetModel(_) => Err(
540 "model switching is not available through the local driver (use /model)"
541 .to_string(),
542 ),
543 }
544 }
545}
546
547async fn pump_agent_events(
550 lane: Arc<dyn AgentLane>,
551 mut rx: broadcast::Receiver<AgentEvent>,
552 tx: mpsc::UnboundedSender<DriverEvent>,
553) {
554 let mut streaming = false;
555 loop {
556 match rx.recv().await {
557 Ok(event) => {
558 match &event {
559 AgentEvent::AgentStart => streaming = true,
560 AgentEvent::AgentEnd { .. } => streaming = false,
561 _ => {}
562 }
563 if let Some(ui) = map_agent_event(&event) {
564 if tx.send(DriverEvent::Ui(ui)).is_err() {
565 return;
566 }
567 }
568 if matches!(
569 &event,
570 AgentEvent::AgentStart
571 | AgentEvent::AgentEnd { .. }
572 | AgentEvent::RetryScheduled { .. }
573 ) {
574 let status = query_status(&lane, streaming).await;
575 if tx.send(DriverEvent::Status(status)).is_err() {
576 return;
577 }
578 }
579 }
580 Err(broadcast::error::RecvError::Lagged(_)) => continue,
581 Err(broadcast::error::RecvError::Closed) => break,
582 }
583 }
584 let _ = tx.send(DriverEvent::Ended);
585}
586
587async fn query_status(lane: &Arc<dyn AgentLane>, streaming: bool) -> DriverStatus {
589 DriverStatus {
590 ready: true,
591 streaming,
592 model: lane.get_model().await.ok().map(|model| model.id),
593 thinking: lane
594 .get_thinking_level()
595 .await
596 .ok()
597 .map(thinking_level_name)
598 .map(str::to_string),
599 active_tools: lane.get_active_tools().await.unwrap_or_default(),
600 retry: None,
601 }
602}
603
604fn thinking_level_name(level: ThinkingLevel) -> &'static str {
606 use ThinkingLevel::*;
607 match level {
608 Off => "off",
609 Minimal => "minimal",
610 Low => "low",
611 Medium => "medium",
612 High => "high",
613 Xhigh => "xhigh",
614 Max => "max",
615 }
616}
617
618fn parse_thinking_level_name(name: &str) -> Option<ThinkingLevel> {
620 match name.trim().to_ascii_lowercase().as_str() {
621 "off" => Some(ThinkingLevel::Off),
622 "minimal" => Some(ThinkingLevel::Minimal),
623 "low" => Some(ThinkingLevel::Low),
624 "medium" => Some(ThinkingLevel::Medium),
625 "high" => Some(ThinkingLevel::High),
626 "xhigh" => Some(ThinkingLevel::Xhigh),
627 "max" => Some(ThinkingLevel::Max),
628 _ => None,
629 }
630}
631
632#[cfg(test)]
633#[path = "remote/compatibility_tests.rs"]
634mod compact_compatibility_tests;
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use serde_json::json;
640
641 #[test]
642 fn remote_dialogs_are_forwarded_as_modal_events() {
643 let request: RemoteEvent = serde_json::from_value(json!({
644 "type":"ui_dialog_request", "requestId":"q1", "toolCallId":"c1", "ui":{"question":"Allow?"},
645 })).unwrap();
646 let Some(DriverEvent::UiDialog(request)) = map_remote_driver_event(&request) else {
647 panic!("dialog was dropped")
648 };
649 assert_eq!(request.request_id, "q1");
650 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
651 assert_eq!(request.ui["question"], "Allow?");
652 assert!(
653 matches!(map_remote_driver_event(&RemoteEvent::UiDialogClosed { request_id: "q1".into() }),
654 Some(DriverEvent::UiDialogClosed(id)) if id == "q1")
655 );
656 }
657
658 #[test]
659 fn map_remote_event_ignores_non_assistant_messages() {
660 let event = serde_json::from_value::<RemoteEvent>(json!({
661 "type": "message_start",
662 "message": {"kind": "user", "content": []}
663 }))
664 .unwrap();
665 assert!(map_remote_event(&event).is_none());
666 }
667
668 #[test]
669 fn map_remote_event_projects_assistant_update() {
670 let event = serde_json::from_value::<RemoteEvent>(json!({
671 "type": "message_update",
672 "message": {"kind": "assistant", "content": [{"type": "text", "text": "hi"}]}
673 }))
674 .unwrap();
675 match map_remote_event(&event) {
676 Some(UiEvent::AssistantUpdate { blocks, tool_calls }) => {
677 assert_eq!(blocks, vec![AssistantBlock::Text("hi".into())]);
678 assert!(tool_calls.is_empty());
679 }
680 other => panic!("expected AssistantUpdate, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn map_remote_event_projects_tool_end() {
686 let event = serde_json::from_value::<RemoteEvent>(json!({
687 "type": "tool_execution_end",
688 "toolCallId": "c1",
689 "toolName": "read",
690 "isError": true,
691 "result": {"content": [{"type": "text", "text": "nope"}]}
692 }))
693 .unwrap();
694 match map_remote_event(&event) {
695 Some(UiEvent::ToolEnd {
696 id, name, is_error, ..
697 }) => {
698 assert_eq!(id, "c1");
699 assert_eq!(name, "read");
700 assert!(is_error);
701 }
702 other => panic!("expected ToolEnd, got {other:?}"),
703 }
704 }
705
706 #[test]
707 fn auth_hint_mentions_the_token_flag() {
708 assert!(auth_hint("authentication required".to_string()).contains("--token"));
709 assert_eq!(auth_hint("boom".to_string()), "boom");
710 }
711
712 #[test]
713 fn thinking_level_name_round_trips() {
714 for name in ["off", "minimal", "low", "medium", "high", "xhigh", "max"] {
715 let level = parse_thinking_level_name(name).unwrap();
716 assert_eq!(thinking_level_name(level), name);
717 }
718 assert_eq!(parse_thinking_level_name("HIGH"), Some(ThinkingLevel::High));
719 assert_eq!(parse_thinking_level_name("bogus"), None);
720 }
721}