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 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        // Seed the state (model / thinking / tools) for the chrome.
273        let _ = driver.send(DriverCommand::RefreshState).await;
274        Ok(driver)
275    }
276
277    /// Best-effort session teardown.
278    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
314/// Snapshot the foldable parts of a [`RemoteSession`] the chrome needs.
315fn 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
333/// Preserve dialog messages alongside transcript events; they are not part
334/// of the transcript and must reach the shell's modal input slot.
335fn 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
354/// Project a wire [`RemoteEvent`] into the shared [`UiEvent`].
355pub 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
434/// Append a `--token` hint when the server rejected us for authentication.
435fn 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
444// ---------------------------------------------------------------------------
445// Local driver
446// ---------------------------------------------------------------------------
447
448/// A [`SessionDriver`] backed by an in-process harness lane: it turns the
449/// lane's `broadcast::Receiver<AgentEvent>` into [`DriverEvent`]s and maps
450/// [`DriverCommand`]s onto the lane. Additive — the interactive TUI can adopt
451/// it without changing the rich local operations it performs through the lane
452/// directly.
453///
454/// Model switching is **not** exposed through the driver: `AgentLane::set_model`
455/// takes a resolved `Model`, while the driver only carries an id. The local TUI
456/// keeps using its own model catalog for `/model`.
457pub 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    /// Wrap a lane plus its live event receiver.
465    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                // Drive the run on a background task: `prompt_text` only returns
490                // once the run completes, but a driver `send` must not block the
491                // TUI loop on the whole agent turn.
492                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
533/// Forward the lane's `AgentEvent` stream as [`DriverEvent`]s, emitting a fresh
534/// [`DriverStatus`] on each run boundary.
535async 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
573/// Query the lane for a status snapshot.
574async 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
590/// The wire/display name of a thinking level.
591fn 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
604/// Parse a thinking-level name (case-insensitive); `None` for an unknown name.
605fn 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}