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    /// Answer or cancel a remote question by request id.
39    UiDialogResponse {
40        request_id: String,
41        answer: Option<Value>,
42    },
43    /// Run a prompt on the backend's main lane.
44    Prompt(String),
45    /// Interrupt the in-flight run.
46    Abort,
47    /// Re-query the backend's session state (model / thinking / tools).
48    RefreshState,
49    /// Switch the active model by id.
50    SetModel(String),
51    /// Set the thinking level (`off`…`max`).
52    SetThinkingLevel(String),
53    /// Replace the active tool set.
54    SetActiveTools(Vec<String>),
55}
56
57/// A snapshot of backend session state for the chrome (header / footer / retry).
58#[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    /// `(attempt, max, delay_ms)` while a retry is pending.
66    pub retry: Option<(u32, u32, u64)>,
67}
68
69/// An item pushed from a driver to the TUI.
70#[derive(Debug, Clone)]
71pub enum DriverEvent {
72    UiDialog(rpi_extensions::UiDialogRequest),
73    UiDialogClosed(String),
74    /// A transcript event.
75    Ui(UiEvent),
76    /// A fresh session-state snapshot.
77    Status(DriverStatus),
78    /// A local notice line (error / connection message).
79    Notice(String),
80    /// The backend stream ended.
81    Ended,
82}
83
84/// A backend the TUI can drive: it yields [`DriverEvent`]s and accepts
85/// [`DriverCommand`]s. Both the in-process host and the remote client
86/// implement it, so the transcript render layer above is transport-agnostic.
87#[async_trait]
88pub trait SessionDriver: Send + Sync {
89    /// Take the driver's event receiver. Returns `Some` exactly once; later
90    /// calls return `None`.
91    fn events(&self) -> Option<mpsc::UnboundedReceiver<DriverEvent>>;
92
93    /// Send a command to the backend.
94    async fn send(&self, command: DriverCommand) -> Result<(), String>;
95}
96
97// ---------------------------------------------------------------------------
98// Local projection: in-process `AgentEvent` → normalized `UiEvent`
99// ---------------------------------------------------------------------------
100
101/// Project an in-process [`AgentEvent`] into the shared [`UiEvent`]. Returns
102/// `None` for events with no transcript effect.
103pub 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
169/// Provider-free assistant block projection (text / thinking / decoded image),
170/// in document order.
171pub 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
187/// Finalized tool calls in an assistant message (placeholder calls with an
188/// empty name are skipped).
189pub 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
203// ---------------------------------------------------------------------------
204// Remote driver
205// ---------------------------------------------------------------------------
206
207/// A [`SessionDriver`] backed by a remote `rpi --server` over TCP JSON-RPC.
208pub 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    /// Connect, authenticate, start a remote session, subscribe, and spawn the
217    /// event pump that folds the wire stream into [`DriverEvent`]s.
218    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        // Seed the state (model / thinking / tools) for the chrome.
274        let _ = driver.send(DriverCommand::RefreshState).await;
275        Ok(driver)
276    }
277
278    /// Best-effort session teardown.
279    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
315/// Snapshot the foldable parts of a [`RemoteSession`] the chrome needs.
316fn 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/// Stateful wire-to-UI adapter used by the actual remote event pump. Keep the
335/// stateless projection below available for callers with complete snapshots.
336#[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
347/// Preserve dialog messages alongside transcript events; they are not part
348/// of the transcript and must reach the shell's modal input slot.
349fn 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
368/// Project a wire [`RemoteEvent`] into the shared [`UiEvent`].
369pub 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
448/// Append a `--token` hint when the server rejected us for authentication.
449fn 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
458// ---------------------------------------------------------------------------
459// Local driver
460// ---------------------------------------------------------------------------
461
462/// A [`SessionDriver`] backed by an in-process harness lane: it turns the
463/// lane's `broadcast::Receiver<AgentEvent>` into [`DriverEvent`]s and maps
464/// [`DriverCommand`]s onto the lane. Additive — the interactive TUI can adopt
465/// it without changing the rich local operations it performs through the lane
466/// directly.
467///
468/// Model switching is **not** exposed through the driver: `AgentLane::set_model`
469/// takes a resolved `Model`, while the driver only carries an id. The local TUI
470/// keeps using its own model catalog for `/model`.
471pub 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    /// Wrap a lane plus its live event receiver.
479    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                // Drive the run on a background task: `prompt_text` only returns
504                // once the run completes, but a driver `send` must not block the
505                // TUI loop on the whole agent turn.
506                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
547/// Forward the lane's `AgentEvent` stream as [`DriverEvent`]s, emitting a fresh
548/// [`DriverStatus`] on each run boundary.
549async 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
587/// Query the lane for a status snapshot.
588async 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
604/// The wire/display name of a thinking level.
605fn 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
618/// Parse a thinking-level name (case-insensitive); `None` for an unknown name.
619fn 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}