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 last_error: Option<String> = None;
248 while let Some(line) = lines.recv().await {
249 session.apply_line(&line);
250 let _ = tx.send(DriverEvent::Status(status_of(&session)));
251 if session.last_error != last_error {
252 last_error = session.last_error.clone();
253 if let Some(error) = &last_error {
254 let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
255 }
256 }
257 if let Ok(event) = serde_json::from_value::<RemoteEvent>(line) {
258 if let Some(event) = map_remote_driver_event(&event) {
259 let _ = tx.send(event);
260 }
261 }
262 }
263 let _ = tx.send(DriverEvent::Ended);
264 });
265
266 let driver = Self {
267 client: tokio::sync::Mutex::new(client),
268 session_id,
269 next_id: AtomicU64::new(1),
270 events: StdMutex::new(Some(rx)),
271 };
272 let _ = driver.send(DriverCommand::RefreshState).await;
274 Ok(driver)
275 }
276
277 pub async fn shutdown(&self) {
279 let mut client = self.client.lock().await;
280 client.stop_session(&self.session_id).await;
281 }
282}
283
284#[async_trait]
285impl SessionDriver for RemoteDriver {
286 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
287 self.events.lock().unwrap().take()
288 }
289
290 async fn send(&self, command: DriverCommand) -> Result<(), String> {
291 let remote = match command {
292 DriverCommand::UiDialogResponse { request_id, answer } => {
293 RemoteCommand::UiDialogResponse {
294 request_id,
295 cancelled: answer.is_none(),
296 answer,
297 }
298 }
299 DriverCommand::Prompt(content) => RemoteCommand::Prompt { content },
300 DriverCommand::Abort => RemoteCommand::Abort,
301 DriverCommand::RefreshState => RemoteCommand::GetState,
302 DriverCommand::SetModel(model) => RemoteCommand::SetModel { model },
303 DriverCommand::SetThinkingLevel(level) => RemoteCommand::SetThinkingLevel { level },
304 DriverCommand::SetActiveTools(tools) => RemoteCommand::SetActiveTools { tools },
305 };
306 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
307 let mut client = self.client.lock().await;
308 client
309 .send_command(&self.session_id, remote.with_id(id))
310 .await
311 }
312}
313
314fn status_of(session: &RemoteSession) -> DriverStatus {
316 DriverStatus {
317 ready: session.ready,
318 streaming: session.streaming,
319 model: session.state.model.clone(),
320 thinking: session
321 .state
322 .thinking_level
323 .as_ref()
324 .and_then(Value::as_str)
325 .map(str::to_string),
326 active_tools: session.state.active_tools.clone(),
327 retry: session
328 .retry_attempt
329 .map(|(attempt, max)| (attempt, max, session.retry_delay_ms.unwrap_or(1000))),
330 }
331}
332
333fn map_remote_driver_event(event: &RemoteEvent) -> Option<DriverEvent> {
336 match event {
337 RemoteEvent::UiDialogRequest {
338 request_id,
339 tool_call_id,
340 ui,
341 } => Some(DriverEvent::UiDialog(rpi_extensions::UiDialogRequest {
342 request_id: request_id.clone(),
343 tool_call_id: tool_call_id.clone(),
344 ui: ui.clone(),
345 raw: serde_json::to_value(event).unwrap_or(Value::Null),
346 })),
347 RemoteEvent::UiDialogClosed { request_id } => {
348 Some(DriverEvent::UiDialogClosed(request_id.clone()))
349 }
350 _ => map_remote_event(event).map(DriverEvent::Ui),
351 }
352}
353
354pub fn map_remote_event(event: &RemoteEvent) -> Option<UiEvent> {
356 use crate::remote::session::message_role;
357 use crate::transcript_view::{assistant_blocks_from_value, tool_calls_from_value};
358
359 match event {
360 RemoteEvent::UiDialogRequest { .. } | RemoteEvent::UiDialogClosed { .. } => None,
361 RemoteEvent::AgentStart => Some(UiEvent::AgentStart),
362 RemoteEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
363 RemoteEvent::RetryScheduled {
364 attempt,
365 max_retries,
366 delay_ms,
367 error,
368 } => Some(UiEvent::Retry {
369 attempt: *attempt,
370 max_retries: *max_retries,
371 delay_ms: *delay_ms,
372 error: error.clone(),
373 }),
374 RemoteEvent::MessageStart { message } => {
375 if message_role(message) != "assistant" {
376 return None;
377 }
378 Some(UiEvent::AssistantStart {
379 blocks: assistant_blocks_from_value(message),
380 })
381 }
382 RemoteEvent::MessageUpdate { message, .. } => {
383 if !message.is_object() {
384 return None;
385 }
386 Some(UiEvent::AssistantUpdate {
387 blocks: assistant_blocks_from_value(message),
388 tool_calls: tool_calls_from_value(message),
389 })
390 }
391 RemoteEvent::MessageEnd { message } => {
392 if message_role(message) != "assistant" {
393 return None;
394 }
395 Some(UiEvent::AssistantEnd {
396 blocks: assistant_blocks_from_value(message),
397 })
398 }
399 RemoteEvent::ToolExecutionStart {
400 tool_call_id,
401 tool_name,
402 args,
403 } => Some(UiEvent::ToolStart {
404 id: tool_call_id.clone(),
405 name: tool_name.clone(),
406 args: args.clone(),
407 }),
408 RemoteEvent::ToolExecutionUpdate {
409 tool_call_id,
410 tool_name,
411 args,
412 partial_result,
413 } => Some(UiEvent::ToolUpdate {
414 id: tool_call_id.clone(),
415 name: tool_name.clone(),
416 args: args.clone(),
417 result: partial_result.clone(),
418 }),
419 RemoteEvent::ToolExecutionEnd {
420 tool_call_id,
421 tool_name,
422 result,
423 is_error,
424 } => Some(UiEvent::ToolEnd {
425 id: tool_call_id.clone(),
426 name: tool_name.clone(),
427 result: result.clone(),
428 is_error: *is_error,
429 }),
430 RemoteEvent::TurnStart | RemoteEvent::TurnEnd { .. } => None,
431 }
432}
433
434fn auth_hint(error: String) -> String {
436 let lower = error.to_lowercase();
437 if lower.contains("authentication") || lower.contains("token") {
438 format!("{error} (retrieve the token printed by `rpi --server` and pass `--token <token>`)")
439 } else {
440 error
441 }
442}
443
444pub struct LocalDriver {
458 lane: Arc<dyn AgentLane>,
459 status_tx: mpsc::UnboundedSender<DriverEvent>,
460 events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
461}
462
463impl LocalDriver {
464 pub fn new(lane: Arc<dyn AgentLane>, events: broadcast::Receiver<AgentEvent>) -> Self {
466 let (tx, rx) = mpsc::unbounded_channel();
467 let status_tx = tx.clone();
468 tokio::spawn(pump_agent_events(lane.clone(), events, tx));
469 Self {
470 lane,
471 status_tx,
472 events: StdMutex::new(Some(rx)),
473 }
474 }
475}
476
477#[async_trait]
478impl SessionDriver for LocalDriver {
479 fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
480 self.events.lock().unwrap().take()
481 }
482
483 async fn send(&self, command: DriverCommand) -> Result<(), String> {
484 match command {
485 DriverCommand::UiDialogResponse { .. } => {
486 Err("local driver has no dialog mailbox".to_string())
487 }
488 DriverCommand::Prompt(text) => {
489 let lane = self.lane.clone();
493 let tx = self.status_tx.clone();
494 tokio::spawn(async move {
495 if let Err(error) = lane.prompt_text(&text, Vec::new()).await {
496 let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
497 }
498 });
499 Ok(())
500 }
501 DriverCommand::Abort => self
502 .lane
503 .abort()
504 .await
505 .map(|_| ())
506 .map_err(|e| e.to_string()),
507 DriverCommand::RefreshState => {
508 let status = query_status(&self.lane, false).await;
509 let _ = self.status_tx.send(DriverEvent::Status(status));
510 Ok(())
511 }
512 DriverCommand::SetThinkingLevel(level) => {
513 let parsed = parse_thinking_level_name(&level)
514 .ok_or_else(|| format!("unknown thinking level: {level}"))?;
515 self.lane
516 .set_thinking_level(parsed)
517 .await
518 .map_err(|e| e.to_string())
519 }
520 DriverCommand::SetActiveTools(tools) => self
521 .lane
522 .set_active_tools(tools)
523 .await
524 .map_err(|e| e.to_string()),
525 DriverCommand::SetModel(_) => Err(
526 "model switching is not available through the local driver (use /model)"
527 .to_string(),
528 ),
529 }
530 }
531}
532
533async fn pump_agent_events(
536 lane: Arc<dyn AgentLane>,
537 mut rx: broadcast::Receiver<AgentEvent>,
538 tx: mpsc::UnboundedSender<DriverEvent>,
539) {
540 let mut streaming = false;
541 loop {
542 match rx.recv().await {
543 Ok(event) => {
544 match &event {
545 AgentEvent::AgentStart => streaming = true,
546 AgentEvent::AgentEnd { .. } => streaming = false,
547 _ => {}
548 }
549 if let Some(ui) = map_agent_event(&event) {
550 if tx.send(DriverEvent::Ui(ui)).is_err() {
551 return;
552 }
553 }
554 if matches!(
555 &event,
556 AgentEvent::AgentStart
557 | AgentEvent::AgentEnd { .. }
558 | AgentEvent::RetryScheduled { .. }
559 ) {
560 let status = query_status(&lane, streaming).await;
561 if tx.send(DriverEvent::Status(status)).is_err() {
562 return;
563 }
564 }
565 }
566 Err(broadcast::error::RecvError::Lagged(_)) => continue,
567 Err(broadcast::error::RecvError::Closed) => break,
568 }
569 }
570 let _ = tx.send(DriverEvent::Ended);
571}
572
573async fn query_status(lane: &Arc<dyn AgentLane>, streaming: bool) -> DriverStatus {
575 DriverStatus {
576 ready: true,
577 streaming,
578 model: lane.get_model().await.ok().map(|model| model.id),
579 thinking: lane
580 .get_thinking_level()
581 .await
582 .ok()
583 .map(thinking_level_name)
584 .map(str::to_string),
585 active_tools: lane.get_active_tools().await.unwrap_or_default(),
586 retry: None,
587 }
588}
589
590fn thinking_level_name(level: ThinkingLevel) -> &'static str {
592 use ThinkingLevel::*;
593 match level {
594 Off => "off",
595 Minimal => "minimal",
596 Low => "low",
597 Medium => "medium",
598 High => "high",
599 Xhigh => "xhigh",
600 Max => "max",
601 }
602}
603
604fn parse_thinking_level_name(name: &str) -> Option<ThinkingLevel> {
606 match name.trim().to_ascii_lowercase().as_str() {
607 "off" => Some(ThinkingLevel::Off),
608 "minimal" => Some(ThinkingLevel::Minimal),
609 "low" => Some(ThinkingLevel::Low),
610 "medium" => Some(ThinkingLevel::Medium),
611 "high" => Some(ThinkingLevel::High),
612 "xhigh" => Some(ThinkingLevel::Xhigh),
613 "max" => Some(ThinkingLevel::Max),
614 _ => None,
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use serde_json::json;
622
623 #[test]
624 fn remote_dialogs_are_forwarded_as_modal_events() {
625 let request: RemoteEvent = serde_json::from_value(json!({
626 "type":"ui_dialog_request", "requestId":"q1", "toolCallId":"c1", "ui":{"question":"Allow?"},
627 })).unwrap();
628 let Some(DriverEvent::UiDialog(request)) = map_remote_driver_event(&request) else {
629 panic!("dialog was dropped")
630 };
631 assert_eq!(request.request_id, "q1");
632 assert_eq!(request.tool_call_id.as_deref(), Some("c1"));
633 assert_eq!(request.ui["question"], "Allow?");
634 assert!(
635 matches!(map_remote_driver_event(&RemoteEvent::UiDialogClosed { request_id: "q1".into() }),
636 Some(DriverEvent::UiDialogClosed(id)) if id == "q1")
637 );
638 }
639
640 #[test]
641 fn map_remote_event_ignores_non_assistant_messages() {
642 let event = serde_json::from_value::<RemoteEvent>(json!({
643 "type": "message_start",
644 "message": {"kind": "user", "content": []}
645 }))
646 .unwrap();
647 assert!(map_remote_event(&event).is_none());
648 }
649
650 #[test]
651 fn map_remote_event_projects_assistant_update() {
652 let event = serde_json::from_value::<RemoteEvent>(json!({
653 "type": "message_update",
654 "message": {"kind": "assistant", "content": [{"type": "text", "text": "hi"}]}
655 }))
656 .unwrap();
657 match map_remote_event(&event) {
658 Some(UiEvent::AssistantUpdate { blocks, tool_calls }) => {
659 assert_eq!(blocks, vec![AssistantBlock::Text("hi".into())]);
660 assert!(tool_calls.is_empty());
661 }
662 other => panic!("expected AssistantUpdate, got {other:?}"),
663 }
664 }
665
666 #[test]
667 fn map_remote_event_projects_tool_end() {
668 let event = serde_json::from_value::<RemoteEvent>(json!({
669 "type": "tool_execution_end",
670 "toolCallId": "c1",
671 "toolName": "read",
672 "isError": true,
673 "result": {"content": [{"type": "text", "text": "nope"}]}
674 }))
675 .unwrap();
676 match map_remote_event(&event) {
677 Some(UiEvent::ToolEnd {
678 id, name, is_error, ..
679 }) => {
680 assert_eq!(id, "c1");
681 assert_eq!(name, "read");
682 assert!(is_error);
683 }
684 other => panic!("expected ToolEnd, got {other:?}"),
685 }
686 }
687
688 #[test]
689 fn auth_hint_mentions_the_token_flag() {
690 assert!(auth_hint("authentication required".to_string()).contains("--token"));
691 assert_eq!(auth_hint("boom".to_string()), "boom");
692 }
693
694 #[test]
695 fn thinking_level_name_round_trips() {
696 for name in ["off", "minimal", "low", "medium", "high", "xhigh", "max"] {
697 let level = parse_thinking_level_name(name).unwrap();
698 assert_eq!(thinking_level_name(level), name);
699 }
700 assert_eq!(parse_thinking_level_name("HIGH"), Some(ThinkingLevel::High));
701 assert_eq!(parse_thinking_level_name("bogus"), None);
702 }
703}