Skip to main content

rpi_cli/
session_driver.rs

1//! Transport-agnostic **session driver** — the "only the transport differs"
2//! boundary.
3//!
4//! A driver bundles the two things a TUI needs from its backend:
5//!
6//! 1. a **stream** of normalized events ([`DriverEvent`] / [`UiEvent`]), and
7//! 2. a **command** sink ([`DriverCommand`]).
8//!
9//! The remote client ([`RemoteDriver`]) implements this over TCP JSON-RPC. The
10//! local interactive host drives the same rendering path via
11//! [`map_agent_event`] (its own in-process `AgentEvent` stream → [`UiEvent`]).
12//! Because both sides speak [`UiEvent`], they render through the same
13//! [`crate::transcript_view::TranscriptView`].
14//!
15//! [`UiEvent`]: crate::transcript_view::UiEvent
16
17use 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/// A command the TUI sends to its backend.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum DriverCommand {
38    /// Run a prompt on the backend's main lane.
39    Prompt(String),
40    /// Interrupt the in-flight run.
41    Abort,
42    /// Re-query the backend's session state (model / thinking / tools).
43    RefreshState,
44    /// Switch the active model by id.
45    SetModel(String),
46    /// Set the thinking level (`off`…`max`).
47    SetThinkingLevel(String),
48    /// Replace the active tool set.
49    SetActiveTools(Vec<String>),
50}
51
52/// A snapshot of backend session state for the chrome (header / footer / retry).
53#[derive(Debug, Clone, Default, PartialEq)]
54pub struct DriverStatus {
55    pub ready: bool,
56    pub streaming: bool,
57    pub model: Option<String>,
58    pub thinking: Option<String>,
59    pub active_tools: Vec<String>,
60    /// `(attempt, max, delay_ms)` while a retry is pending.
61    pub retry: Option<(u32, u32, u64)>,
62}
63
64/// An item pushed from a driver to the TUI.
65#[derive(Debug, Clone)]
66pub enum DriverEvent {
67    /// A transcript event.
68    Ui(UiEvent),
69    /// A fresh session-state snapshot.
70    Status(DriverStatus),
71    /// A local notice line (error / connection message).
72    Notice(String),
73    /// The backend stream ended.
74    Ended,
75}
76
77/// A backend the TUI can drive: it yields [`DriverEvent`]s and accepts
78/// [`DriverCommand`]s. Both the in-process host and the remote client
79/// implement it, so the transcript render layer above is transport-agnostic.
80#[async_trait]
81pub trait SessionDriver: Send + Sync {
82    /// Take the driver's event receiver. Returns `Some` exactly once; later
83    /// calls return `None`.
84    fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>>;
85
86    /// Send a command to the backend.
87    async fn send(&self, command: DriverCommand) -> Result<(), String>;
88}
89
90// ---------------------------------------------------------------------------
91// Local projection: in-process `AgentEvent` → normalized `UiEvent`
92// ---------------------------------------------------------------------------
93
94/// Project an in-process [`AgentEvent`] into the shared [`UiEvent`]. Returns
95/// `None` for events with no transcript effect.
96pub fn map_agent_event(event: &AgentEvent) -> Option<UiEvent> {
97    match event {
98        AgentEvent::AgentStart => Some(UiEvent::AgentStart),
99        AgentEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
100        AgentEvent::RetryScheduled {
101            attempt,
102            max_retries,
103            delay_ms,
104            error,
105        } => Some(UiEvent::Retry {
106            attempt: *attempt,
107            max_retries: *max_retries,
108            delay_ms: *delay_ms,
109            error: error.clone(),
110        }),
111        AgentEvent::TurnStart | AgentEvent::TurnEnd { .. } => None,
112        AgentEvent::MessageStart { message } => match message {
113            AgentMessage::Assistant(a) => Some(UiEvent::AssistantStart {
114                blocks: assistant_blocks(a),
115            }),
116            _ => None,
117        },
118        AgentEvent::MessageUpdate { message, .. } => Some(UiEvent::AssistantUpdate {
119            blocks: assistant_blocks(message),
120            tool_calls: assistant_tool_calls(message),
121        }),
122        AgentEvent::MessageEnd { message } => match message {
123            AgentMessage::Assistant(a) => Some(UiEvent::AssistantEnd {
124                blocks: assistant_blocks(a),
125            }),
126            _ => None,
127        },
128        AgentEvent::ToolExecutionStart {
129            tool_call_id,
130            tool_name,
131            args,
132        } => Some(UiEvent::ToolStart {
133            id: tool_call_id.clone(),
134            name: tool_name.clone(),
135            args: args.clone(),
136        }),
137        AgentEvent::ToolExecutionUpdate {
138            tool_call_id,
139            tool_name,
140            args,
141            partial_result,
142        } => Some(UiEvent::ToolUpdate {
143            id: tool_call_id.clone(),
144            name: tool_name.clone(),
145            args: args.clone(),
146            result: crate::remote::protocol::tool_result_json(partial_result),
147        }),
148        AgentEvent::ToolExecutionEnd {
149            tool_call_id,
150            tool_name,
151            result,
152            is_error,
153        } => Some(UiEvent::ToolEnd {
154            id: tool_call_id.clone(),
155            name: tool_name.clone(),
156            result: crate::remote::protocol::tool_result_json(result),
157            is_error: *is_error,
158        }),
159    }
160}
161
162/// Provider-free assistant block projection (text / thinking / decoded image),
163/// in document order.
164pub fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
165    msg.content
166        .iter()
167        .filter_map(|c| match c {
168            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
169            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
170            Content::Image(image) => base64::engine::general_purpose::STANDARD
171                .decode(&image.data)
172                .ok()
173                .filter(|data| !data.is_empty())
174                .map(AssistantBlock::Image),
175            _ => None,
176        })
177        .collect()
178}
179
180/// Finalized tool calls in an assistant message (placeholder calls with an
181/// empty name are skipped).
182pub fn assistant_tool_calls(msg: &AssistantMessage) -> Vec<UiToolCall> {
183    msg.content
184        .iter()
185        .filter_map(|c| match c {
186            Content::ToolCall(tc) if !tc.name.trim().is_empty() => Some(UiToolCall {
187                id: tc.id.clone(),
188                name: tc.name.clone(),
189                args: tc.arguments.clone(),
190            }),
191            _ => None,
192        })
193        .collect()
194}
195
196// ---------------------------------------------------------------------------
197// Remote driver
198// ---------------------------------------------------------------------------
199
200/// A [`SessionDriver`] backed by a remote `rpi --server` over TCP JSON-RPC.
201pub struct RemoteDriver {
202    client: tokio::sync::Mutex<RemoteClient>,
203    session_id: String,
204    next_id: AtomicU64,
205    events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
206}
207
208impl RemoteDriver {
209    /// Connect, authenticate, start a remote session, subscribe, and spawn the
210    /// event pump that folds the wire stream into [`DriverEvent`]s.
211    pub async fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
212        let mut client = RemoteClient::connect(addr).await?;
213        client
214            .authenticate(token.unwrap_or(""))
215            .await
216            .map_err(auth_hint)?;
217
218        let started = client
219            .call("start_session", json!({}))
220            .await
221            .map_err(auth_hint)?;
222        let session_id = started
223            .get("sessionId")
224            .and_then(Value::as_str)
225            .ok_or("server did not return a sessionId")?
226            .to_string();
227
228        let subscribed = client
229            .call("subscribe", json!({"sessionId": session_id}))
230            .await?;
231        let subscription_id = subscribed
232            .get("subscriptionId")
233            .and_then(Value::as_u64)
234            .ok_or("server did not return a subscriptionId")?;
235
236        let mut lines = client.start_event_pump(subscription_id);
237        let (tx, rx) = mpsc::unbounded_channel();
238        tokio::spawn(async move {
239            let mut session = RemoteSession::new();
240            let mut last_error: Option<String> = None;
241            while let Some(line) = lines.recv().await {
242                session.apply_line(&line);
243                let _ = tx.send(DriverEvent::Status(status_of(&session)));
244                if session.last_error != last_error {
245                    last_error = session.last_error.clone();
246                    if let Some(error) = &last_error {
247                        let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
248                    }
249                }
250                if let Ok(event) = serde_json::from_value::<RemoteEvent>(line) {
251                    if let Some(ui) = map_remote_event(&event) {
252                        let _ = tx.send(DriverEvent::Ui(ui));
253                    }
254                }
255            }
256            let _ = tx.send(DriverEvent::Ended);
257        });
258
259        let driver = Self {
260            client: tokio::sync::Mutex::new(client),
261            session_id,
262            next_id: AtomicU64::new(1),
263            events: StdMutex::new(Some(rx)),
264        };
265        // Seed the state (model / thinking / tools) for the chrome.
266        let _ = driver.send(DriverCommand::RefreshState).await;
267        Ok(driver)
268    }
269
270    /// Best-effort session teardown.
271    pub async fn shutdown(&self) {
272        let mut client = self.client.lock().await;
273        client.stop_session(&self.session_id).await;
274    }
275}
276
277#[async_trait]
278impl SessionDriver for RemoteDriver {
279    fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
280        self.events.lock().unwrap().take()
281    }
282
283    async fn send(&self, command: DriverCommand) -> Result<(), String> {
284        let remote = match command {
285            DriverCommand::Prompt(content) => RemoteCommand::Prompt { content },
286            DriverCommand::Abort => RemoteCommand::Abort,
287            DriverCommand::RefreshState => RemoteCommand::GetState,
288            DriverCommand::SetModel(model) => RemoteCommand::SetModel { model },
289            DriverCommand::SetThinkingLevel(level) => RemoteCommand::SetThinkingLevel { level },
290            DriverCommand::SetActiveTools(tools) => RemoteCommand::SetActiveTools { tools },
291        };
292        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
293        let mut client = self.client.lock().await;
294        client
295            .send_command(&self.session_id, remote.with_id(id))
296            .await
297    }
298}
299
300/// Snapshot the foldable parts of a [`RemoteSession`] the chrome needs.
301fn status_of(session: &RemoteSession) -> DriverStatus {
302    DriverStatus {
303        ready: session.ready,
304        streaming: session.streaming,
305        model: session.state.model.clone(),
306        thinking: session
307            .state
308            .thinking_level
309            .as_ref()
310            .and_then(Value::as_str)
311            .map(str::to_string),
312        active_tools: session.state.active_tools.clone(),
313        retry: session
314            .retry_attempt
315            .map(|(attempt, max)| (attempt, max, session.retry_delay_ms.unwrap_or(1000))),
316    }
317}
318
319/// Project a wire [`RemoteEvent`] into the shared [`UiEvent`].
320pub fn map_remote_event(event: &RemoteEvent) -> Option<UiEvent> {
321    use crate::remote::session::message_role;
322    use crate::transcript_view::{assistant_blocks_from_value, tool_calls_from_value};
323
324    match event {
325        RemoteEvent::AgentStart => Some(UiEvent::AgentStart),
326        RemoteEvent::AgentEnd { .. } => Some(UiEvent::AgentEnd),
327        RemoteEvent::RetryScheduled {
328            attempt,
329            max_retries,
330            delay_ms,
331            error,
332        } => Some(UiEvent::Retry {
333            attempt: *attempt,
334            max_retries: *max_retries,
335            delay_ms: *delay_ms,
336            error: error.clone(),
337        }),
338        RemoteEvent::MessageStart { message } => {
339            if message_role(message) != "assistant" {
340                return None;
341            }
342            Some(UiEvent::AssistantStart {
343                blocks: assistant_blocks_from_value(message),
344            })
345        }
346        RemoteEvent::MessageUpdate { message, .. } => {
347            if !message.is_object() {
348                return None;
349            }
350            Some(UiEvent::AssistantUpdate {
351                blocks: assistant_blocks_from_value(message),
352                tool_calls: tool_calls_from_value(message),
353            })
354        }
355        RemoteEvent::MessageEnd { message } => {
356            if message_role(message) != "assistant" {
357                return None;
358            }
359            Some(UiEvent::AssistantEnd {
360                blocks: assistant_blocks_from_value(message),
361            })
362        }
363        RemoteEvent::ToolExecutionStart {
364            tool_call_id,
365            tool_name,
366            args,
367        } => Some(UiEvent::ToolStart {
368            id: tool_call_id.clone(),
369            name: tool_name.clone(),
370            args: args.clone(),
371        }),
372        RemoteEvent::ToolExecutionUpdate {
373            tool_call_id,
374            tool_name,
375            args,
376            partial_result,
377        } => Some(UiEvent::ToolUpdate {
378            id: tool_call_id.clone(),
379            name: tool_name.clone(),
380            args: args.clone(),
381            result: partial_result.clone(),
382        }),
383        RemoteEvent::ToolExecutionEnd {
384            tool_call_id,
385            tool_name,
386            result,
387            is_error,
388        } => Some(UiEvent::ToolEnd {
389            id: tool_call_id.clone(),
390            name: tool_name.clone(),
391            result: result.clone(),
392            is_error: *is_error,
393        }),
394        RemoteEvent::TurnStart | RemoteEvent::TurnEnd { .. } => None,
395    }
396}
397
398/// Append a `--token` hint when the server rejected us for authentication.
399fn auth_hint(error: String) -> String {
400    let lower = error.to_lowercase();
401    if lower.contains("authentication") || lower.contains("token") {
402        format!("{error} (retrieve the token printed by `rpi --server` and pass `--token <token>`)")
403    } else {
404        error
405    }
406}
407
408// ---------------------------------------------------------------------------
409// Local driver
410// ---------------------------------------------------------------------------
411
412/// A [`SessionDriver`] backed by an in-process harness lane: it turns the
413/// lane's `broadcast::Receiver<AgentEvent>` into [`DriverEvent`]s and maps
414/// [`DriverCommand`]s onto the lane. Additive — the interactive TUI can adopt
415/// it without changing the rich local operations it performs through the lane
416/// directly.
417///
418/// Model switching is **not** exposed through the driver: `AgentLane::set_model`
419/// takes a resolved `Model`, while the driver only carries an id. The local TUI
420/// keeps using its own model catalog for `/model`.
421pub struct LocalDriver {
422    lane: Arc<dyn AgentLane>,
423    status_tx: mpsc::UnboundedSender<DriverEvent>,
424    events: StdMutex<Option<mpsc::UnboundedReceiver<DriverEvent>>>,
425}
426
427impl LocalDriver {
428    /// Wrap a lane plus its live event receiver.
429    pub fn new(lane: Arc<dyn AgentLane>, events: broadcast::Receiver<AgentEvent>) -> Self {
430        let (tx, rx) = mpsc::unbounded_channel();
431        let status_tx = tx.clone();
432        tokio::spawn(pump_agent_events(lane.clone(), events, tx));
433        Self {
434            lane,
435            status_tx,
436            events: StdMutex::new(Some(rx)),
437        }
438    }
439}
440
441#[async_trait]
442impl SessionDriver for LocalDriver {
443    fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>> {
444        self.events.lock().unwrap().take()
445    }
446
447    async fn send(&self, command: DriverCommand) -> Result<(), String> {
448        match command {
449            DriverCommand::Prompt(text) => {
450                // Drive the run on a background task: `prompt_text` only returns
451                // once the run completes, but a driver `send` must not block the
452                // TUI loop on the whole agent turn.
453                let lane = self.lane.clone();
454                let tx = self.status_tx.clone();
455                tokio::spawn(async move {
456                    if let Err(error) = lane.prompt_text(&text, Vec::new()).await {
457                        let _ = tx.send(DriverEvent::Notice(format!("error: {error}")));
458                    }
459                });
460                Ok(())
461            }
462            DriverCommand::Abort => self
463                .lane
464                .abort()
465                .await
466                .map(|_| ())
467                .map_err(|e| e.to_string()),
468            DriverCommand::RefreshState => {
469                let status = query_status(&self.lane, false).await;
470                let _ = self.status_tx.send(DriverEvent::Status(status));
471                Ok(())
472            }
473            DriverCommand::SetThinkingLevel(level) => {
474                let parsed = parse_thinking_level_name(&level)
475                    .ok_or_else(|| format!("unknown thinking level: {level}"))?;
476                self.lane
477                    .set_thinking_level(parsed)
478                    .await
479                    .map_err(|e| e.to_string())
480            }
481            DriverCommand::SetActiveTools(tools) => self
482                .lane
483                .set_active_tools(tools)
484                .await
485                .map_err(|e| e.to_string()),
486            DriverCommand::SetModel(_) => Err(
487                "model switching is not available through the local driver (use /model)"
488                    .to_string(),
489            ),
490        }
491    }
492}
493
494/// Forward the lane's `AgentEvent` stream as [`DriverEvent`]s, emitting a fresh
495/// [`DriverStatus`] on each run boundary.
496async fn pump_agent_events(
497    lane: Arc<dyn AgentLane>,
498    mut rx: broadcast::Receiver<AgentEvent>,
499    tx: mpsc::UnboundedSender<DriverEvent>,
500) {
501    let mut streaming = false;
502    loop {
503        match rx.recv().await {
504            Ok(event) => {
505                match &event {
506                    AgentEvent::AgentStart => streaming = true,
507                    AgentEvent::AgentEnd { .. } => streaming = false,
508                    _ => {}
509                }
510                if let Some(ui) = map_agent_event(&event) {
511                    if tx.send(DriverEvent::Ui(ui)).is_err() {
512                        return;
513                    }
514                }
515                if matches!(
516                    &event,
517                    AgentEvent::AgentStart
518                        | AgentEvent::AgentEnd { .. }
519                        | AgentEvent::RetryScheduled { .. }
520                ) {
521                    let status = query_status(&lane, streaming).await;
522                    if tx.send(DriverEvent::Status(status)).is_err() {
523                        return;
524                    }
525                }
526            }
527            Err(broadcast::error::RecvError::Lagged(_)) => continue,
528            Err(broadcast::error::RecvError::Closed) => break,
529        }
530    }
531    let _ = tx.send(DriverEvent::Ended);
532}
533
534/// Query the lane for a status snapshot.
535async fn query_status(lane: &Arc<dyn AgentLane>, streaming: bool) -> DriverStatus {
536    DriverStatus {
537        ready: true,
538        streaming,
539        model: lane.get_model().await.ok().map(|model| model.id),
540        thinking: lane
541            .get_thinking_level()
542            .await
543            .ok()
544            .map(thinking_level_name)
545            .map(str::to_string),
546        active_tools: lane.get_active_tools().await.unwrap_or_default(),
547        retry: None,
548    }
549}
550
551/// The wire/display name of a thinking level.
552fn thinking_level_name(level: ThinkingLevel) -> &'static str {
553    use ThinkingLevel::*;
554    match level {
555        Off => "off",
556        Minimal => "minimal",
557        Low => "low",
558        Medium => "medium",
559        High => "high",
560        Xhigh => "xhigh",
561        Max => "max",
562    }
563}
564
565/// Parse a thinking-level name (case-insensitive); `None` for an unknown name.
566fn parse_thinking_level_name(name: &str) -> Option<ThinkingLevel> {
567    match name.trim().to_ascii_lowercase().as_str() {
568        "off" => Some(ThinkingLevel::Off),
569        "minimal" => Some(ThinkingLevel::Minimal),
570        "low" => Some(ThinkingLevel::Low),
571        "medium" => Some(ThinkingLevel::Medium),
572        "high" => Some(ThinkingLevel::High),
573        "xhigh" => Some(ThinkingLevel::Xhigh),
574        "max" => Some(ThinkingLevel::Max),
575        _ => None,
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use serde_json::json;
583
584    #[test]
585    fn map_remote_event_ignores_non_assistant_messages() {
586        let event = serde_json::from_value::<RemoteEvent>(json!({
587            "type": "message_start",
588            "message": {"kind": "user", "content": []}
589        }))
590        .unwrap();
591        assert!(map_remote_event(&event).is_none());
592    }
593
594    #[test]
595    fn map_remote_event_projects_assistant_update() {
596        let event = serde_json::from_value::<RemoteEvent>(json!({
597            "type": "message_update",
598            "message": {"kind": "assistant", "content": [{"type": "text", "text": "hi"}]}
599        }))
600        .unwrap();
601        match map_remote_event(&event) {
602            Some(UiEvent::AssistantUpdate { blocks, tool_calls }) => {
603                assert_eq!(blocks, vec![AssistantBlock::Text("hi".into())]);
604                assert!(tool_calls.is_empty());
605            }
606            other => panic!("expected AssistantUpdate, got {other:?}"),
607        }
608    }
609
610    #[test]
611    fn map_remote_event_projects_tool_end() {
612        let event = serde_json::from_value::<RemoteEvent>(json!({
613            "type": "tool_execution_end",
614            "toolCallId": "c1",
615            "toolName": "read",
616            "isError": true,
617            "result": {"content": [{"type": "text", "text": "nope"}]}
618        }))
619        .unwrap();
620        match map_remote_event(&event) {
621            Some(UiEvent::ToolEnd {
622                id, name, is_error, ..
623            }) => {
624                assert_eq!(id, "c1");
625                assert_eq!(name, "read");
626                assert!(is_error);
627            }
628            other => panic!("expected ToolEnd, got {other:?}"),
629        }
630    }
631
632    #[test]
633    fn auth_hint_mentions_the_token_flag() {
634        assert!(auth_hint("authentication required".to_string()).contains("--token"));
635        assert_eq!(auth_hint("boom".to_string()), "boom");
636    }
637
638    #[test]
639    fn thinking_level_name_round_trips() {
640        for name in ["off", "minimal", "low", "medium", "high", "xhigh", "max"] {
641            let level = parse_thinking_level_name(name).unwrap();
642            assert_eq!(thinking_level_name(level), name);
643        }
644        assert_eq!(parse_thinking_level_name("HIGH"), Some(ThinkingLevel::High));
645        assert_eq!(parse_thinking_level_name("bogus"), None);
646    }
647}