Skip to main content

oxi_agent/tools/
ask.rs

1//! Ask tool — ask the user one or more questions via the TUI overlay.
2//!
3//! Architecture (omp `ask` style, adapted to oxi's ratatui stack):
4//! - `AskBridge` is created in `oxi-cli` and shared (via `Arc`) between
5//!   `AskTool` (agent thread) and `AppState` (TUI main thread).
6//! - When the tool executes, it creates a oneshot channel and stores
7//!   (questions, sender) in the bridge — a single round-trip. The overlay
8//!   drives the **sequential, one-question-at-a-time** flow internally
9//!   (←/→ to move between questions), matching omp's `askSingleQuestion` UX.
10//! - The TUI main loop polls the bridge; when a pending ask is found it
11//!   creates an `AskOverlay` to display it.
12//! - User interaction drives the overlay to send an `AskResponse` via the
13//!   oneshot `Sender`. The tool's `execute()` receives it via `rx.await`.
14//! - Abort (Ctrl+C) is handled via `tokio::select!` with the abort signal.
15//!
16//! The transcript renderer (`format_ask_result` in `oxi-tui`) reconstructs the
17//! "filled menu" (every option re-shown with its selection marker filled) by
18//! combining the call arguments (the full option list) with the result text
19//! (which option was selected).
20
21use serde::{Deserialize, Serialize};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::time::Duration;
25use tokio::sync::oneshot;
26
27use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
28use async_trait::async_trait;
29
30/// Shared bridge between the ask tool (agent thread) and the TUI overlay (main
31/// thread). Created in `oxi-cli`, injected into both the tool and `AppState`.
32#[derive(Clone)]
33pub struct AskBridge {
34    inner: Arc<parking_lot::Mutex<Option<PendingAsk>>>,
35    /// Set to `true` when the TUI main loop starts polling.
36    /// In headless mode (`--print`, RPC) this stays `false`, allowing the
37    /// tool to refuse execution instead of hanging forever.
38    ui_attached: Arc<AtomicBool>,
39    /// Identity of the owning session. Set when [`Self::attach_with_session`]
40    /// is called (typically from the TUI bootstrap with the same
41    /// `ownership_session_id` used by the issue system). Required non-empty
42    /// at [`Self::set`] time so concurrent agents can't impersonate each
43    /// other's ask overlays — see AGENTS.md "Issue-system ownership identity
44    /// (Phase 0 / defect #13)" for the analogous invariant.
45    session_id: Arc<parking_lot::Mutex<Option<String>>>,
46    /// Ask overlay timeout. `None` = disabled (wait indefinitely).
47    /// Set at construction from `Settings::ask_timeout_secs`.
48    timeout: Option<Duration>,
49}
50
51impl AskBridge {
52    /// Create a new empty bridge with no timeout and UI not attached.
53    pub fn new() -> Self {
54        Self {
55            inner: Arc::new(parking_lot::Mutex::new(None)),
56            ui_attached: Arc::new(AtomicBool::new(false)),
57            session_id: Arc::new(parking_lot::Mutex::new(None)),
58            timeout: None,
59        }
60    }
61
62    /// Create a new bridge with a timeout duration.
63    pub fn with_timeout(timeout: Option<Duration>) -> Self {
64        Self {
65            timeout,
66            ..Self::new()
67        }
68    }
69
70    /// Signal that the TUI main loop is polling, and bind it to a session
71    /// identity. Called once at TUI startup.
72    ///
73    /// `session_id` must be non-empty — mirroring the issue-system
74    /// invariant (AGENTS.md pitfall "Issue-system ownership identity").
75    /// An empty id is a programming error and is rejected.
76    pub fn attach_with_session(&self, session_id: impl Into<String>) {
77        let id = session_id.into();
78        debug_assert!(
79            !id.is_empty(),
80            "AskBridge::attach_with_session called with empty session_id"
81        );
82        *self.session_id.lock() = Some(id);
83        self.ui_attached.store(true, Ordering::SeqCst);
84    }
85
86    /// Returns `true` when the TUI is polling the bridge (interactive mode).
87    pub fn is_ui_attached(&self) -> bool {
88        self.ui_attached.load(Ordering::SeqCst)
89    }
90
91    /// Signal that the TUI main loop is polling, without binding a session.
92    /// Test-only convenience — production code must use
93    /// [`Self::attach_with_session`].
94    #[cfg(any(test, debug_assertions))]
95    pub fn attach(&self) {
96        self.ui_attached.store(true, Ordering::SeqCst);
97    }
98
99    /// Returns the bound session identity, if `attach_with_session` was called.
100    pub fn session_id(&self) -> Option<String> {
101        self.session_id.lock().clone()
102    }
103    /// Returns the configured timeout duration, if any.
104    pub fn timeout(&self) -> Option<Duration> {
105        self.timeout
106    }
107
108    /// Store a pending ask. Called by `AskTool::execute`.
109    /// Returns `false` if another ask is already pending (should not happen in
110    /// sequential tool execution, but guards against races).
111    pub fn set(&self, pending: PendingAsk) -> bool {
112        let mut lock = self.inner.lock();
113        if lock.is_some() {
114            return false;
115        }
116        *lock = Some(pending);
117        true
118    }
119
120    /// Try to take the pending ask. Called by the TUI main loop polling.
121    /// Returns `None` if nothing is pending or already taken.
122    pub fn try_take(&self) -> Option<PendingAsk> {
123        self.inner.lock().take()
124    }
125
126    /// Returns `true` if an ask is currently pending.
127    pub fn has_pending(&self) -> bool {
128        self.inner.lock().is_some()
129    }
130}
131
132impl Default for AskBridge {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// A pending ask waiting for user interaction.
139/// The `responder` is a oneshot `Sender` — the overlay calls `send()` when the
140/// user submits or cancels, and the tool's `rx.await` receives it.
141pub struct PendingAsk {
142    /// Questions to display to the user.
143    pub questions: Vec<Question>,
144    /// Sender end of the response channel. Dropping this (without sending) is
145    /// equivalent to user dismiss.
146    pub responder: oneshot::Sender<AskResponse>,
147    /// Overlay timeout. `None` = disabled.
148    pub timeout: Option<Duration>,
149    /// Session identity that produced this ask (from `AskBridge::session_id`).
150    /// Mirrored into the TUI's liveness flock for ownership consistency —
151    /// see AGENTS.md "Issue-system ownership identity (Phase 0 / defect #13)".
152    pub session_id: Option<String>,
153}
154
155/// A single question to ask the user.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Question {
158    /// Unique identifier for this question.
159    pub id: String,
160    /// Short contextual label. Used as a section tag in the transcript.
161    /// Defaults to the `id` if empty.
162    #[serde(default)]
163    pub label: String,
164    /// The full question text to display.
165    pub prompt: String,
166    /// Available options. Can be empty when `allow_other` is `true`.
167    #[serde(default)]
168    pub options: Vec<QuestionOption>,
169    /// Whether to show "Other (type your own)" option. Defaults to `true`.
170    /// The UI appends "Other" automatically — the model MUST NOT include an
171    /// "Other" option itself.
172    #[serde(default = "default_true")]
173    pub allow_other: bool,
174    /// Whether multiple options can be selected. Defaults to `false`.
175    #[serde(default)]
176    pub multi_select: bool,
177    /// Recommended option index (0-based). Used for default cursor position,
178    /// a "(Recommended)" suffix on the option label, and timeout
179    /// auto-selection fallback.
180    #[serde(default)]
181    pub recommended: Option<usize>,
182}
183
184fn default_true() -> bool {
185    true
186}
187
188/// An option within a question.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct QuestionOption {
191    /// Value returned when this option is selected.
192    pub value: String,
193    /// Display label for the option.
194    pub label: String,
195    /// Optional description shown below the label.
196    pub description: Option<String>,
197}
198
199/// Response from user interaction.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AskResponse {
202    /// All answers collected, one per answered question.
203    pub answers: Vec<Answer>,
204    /// `true` if the user cancelled (Esc).
205    pub cancelled: bool,
206    /// `true` if answers were auto-selected due to timeout.
207    #[serde(default)]
208    pub timed_out: bool,
209}
210
211/// A single answer to a question.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Answer {
214    /// Question ID this answer belongs to.
215    pub id: String,
216    /// The value(s) selected or entered, comma-joined for multi-select.
217    pub value: String,
218    /// Display label(s), comma-joined for multi-select, or the custom text.
219    pub label: String,
220    /// `true` if the user typed custom text (allowOther).
221    pub was_custom: bool,
222    /// 1-based index of the selected option. `None` for custom/multi input.
223    pub index: Option<usize>,
224}
225
226// ── Tool ───────────────────────────────────────────────────────────────────
227
228/// The ask tool — asks the user one or more questions via TUI overlay.
229pub struct AskTool {
230    bridge: Arc<AskBridge>,
231}
232
233impl AskTool {
234    /// Create a new `AskTool` that communicates via the given bridge.
235    pub fn new(bridge: Arc<AskBridge>) -> Self {
236        Self { bridge }
237    }
238}
239
240// `Clone` is needed because ToolRegistry stores `Arc<dyn AgentTool>`.
241// `AskTool` is cheap to clone (only copies the Arc).
242impl Clone for AskTool {
243    fn clone(&self) -> Self {
244        Self {
245            bridge: self.bridge.clone(),
246        }
247    }
248}
249
250#[async_trait]
251impl AgentTool for AskTool {
252    fn name(&self) -> &str {
253        "ask"
254    }
255
256    fn label(&self) -> &str {
257        "Ask"
258    }
259
260    fn description(&self) -> &str {
261        "Ask the user a clarifying question when choices have materially \
262         different tradeoffs the user must decide. Default to action — pick \
263         the conservative/standard option and proceed when a reasonable \
264         default exists; only ask when the user must weigh the tradeoff. Do \
265         NOT include an 'Other' option — the UI appends 'Other (type your \
266         own)' automatically. Use 'recommended' (0-indexed) to mark the \
267         default; a '(Recommended)' suffix is added automatically. Set \
268         'multiSelect' true to allow multiple selections. Provide 2-5 \
269         concise options with short labels; put explanatory tradeoffs in \
270         'description'. Batch related questions in one call via 'questions'."
271    }
272
273    fn parameters_schema(&self) -> serde_json::Value {
274        serde_json::json!({
275            "type": "object",
276            "properties": {
277                "questions": {
278                    "type": "array",
279                    "description": "Questions to ask the user",
280                    "items": {
281                        "type": "object",
282                        "properties": {
283                            "id": {
284                                "type": "string",
285                                "description": "Unique identifier for this question"
286                            },
287                            "label": {
288                                "type": "string",
289                                "description": "Short contextual label (defaults to the id)"
290                            },
291                            "prompt": {
292                                "type": "string",
293                                "description": "The full question text to display"
294                            },
295                            "options": {
296                                "type": "array",
297                                "description": "Available options (2-5). Do NOT include 'Other' — the UI adds it automatically.",
298                                "default": [],
299                                "items": {
300                                    "type": "object",
301                                    "properties": {
302                                        "value": {
303                                            "type": "string",
304                                            "description": "The value returned when selected"
305                                        },
306                                        "label": {
307                                            "type": "string",
308                                            "description": "Short display label for the option"
309                                        },
310                                        "description": {
311                                            "type": "string",
312                                            "description": "Optional explanatory tradeoff shown below the label"
313                                        }
314                                    },
315                                    "required": ["value", "label"]
316                                }
317                            },
318                            "allowOther": {
319                                "type": "boolean",
320                                "description": "Show 'Other (type your own)' (default: true)",
321                                "default": true
322                            },
323                            "multiSelect": {
324                                "type": "boolean",
325                                "description": "Allow multiple selections (default: false)",
326                                "default": false
327                            },
328                            "recommended": {
329                                "type": "number",
330                                "description": "Recommended option index (0-based). Marks the default and is used for timeout auto-selection.",
331                                "minimum": 0
332                            }
333                        },
334                        "required": ["id", "prompt"]
335                }
336            },
337            },
338            "required": ["questions"]
339        })
340    }
341
342    fn intent(&self) -> Option<&str> {
343        Some("Ask the user clarifying questions")
344    }
345
346    async fn execute(
347        &self,
348        _tool_call_id: &str,
349        params: serde_json::Value,
350        signal: Option<oneshot::Receiver<()>>,
351        _ctx: &ToolContext,
352    ) -> Result<AgentToolResult, ToolError> {
353        // 0. Headless guard — refuse in non-interactive mode
354        if !self.bridge.is_ui_attached() {
355            return Ok(AgentToolResult::error(
356                "Ask requires interactive TUI mode. \
357                 Not available in --print or RPC mode.",
358            ));
359        }
360
361        // 0b. Ownership guard — refuse if no session_id is bound. Mirrors the
362        // issue-system invariant (AGENTS.md "Issue-system ownership identity
363        // (Phase 0 / defect #13)"): a non-empty session_id identifies the
364        // caller for CAS / overlay-ownership checks. Calling attach() without
365        // a session is a programming error in production; the assertion
366        // surfaces it during development.
367        let session_id = self.bridge.session_id();
368        debug_assert!(
369            session_id.as_deref().is_some_and(|s| !s.is_empty()),
370            "AskBridge was attached without a non-empty session_id; refusing to run"
371        );
372
373        // 1. Parse and validate
374        let questions = parse_questions(&params)?;
375        let timeout = self.bridge.timeout();
376
377        // 2. Create oneshot channel
378        let (tx, rx) = oneshot::channel();
379
380        // 3. Store in bridge — TUI polls it on the main thread
381        if !self.bridge.set(PendingAsk {
382            questions,
383            responder: tx,
384            timeout,
385            session_id,
386        }) {
387            return Ok(AgentToolResult::error("Another ask is already pending"));
388        }
389
390        // 4. Wait for user response — handle abort via tokio::select!
391        select_with_abort(rx, signal, &self.bridge).await
392    }
393}
394
395/// Wait for either the ask response or the abort signal.
396async fn select_with_abort(
397    rx: oneshot::Receiver<AskResponse>,
398    signal: Option<oneshot::Receiver<()>>,
399    bridge: &AskBridge,
400) -> Result<AgentToolResult, ToolError> {
401    // If no abort signal, use a future that never resolves
402    let abort = async {
403        if let Some(sig) = signal {
404            let _ = sig.await;
405        } else {
406            std::future::pending::<()>().await;
407        }
408    };
409
410    tokio::select! {
411        response = rx => {
412            match response {
413                Ok(resp) => {
414                    if resp.cancelled {
415                        Ok(AgentToolResult::success("User cancelled the question"))
416                    } else {
417                        Ok(AgentToolResult::success(format_answers(
418                            &resp.answers,
419                            resp.timed_out,
420                        )))
421                    }
422                }
423                Err(_) => {
424                    // Sender was dropped without sending — overlay was closed without result
425                    Ok(AgentToolResult::success("Question dismissed"))
426                }
427            }
428        }
429        () = abort => {
430            // Abort signal received (Ctrl+C) — clean up bridge
431            bridge.try_take();
432            Ok(AgentToolResult::success("Question cancelled by user interrupt"))
433        }
434    }
435}
436
437/// Parse and validate the ask parameters from JSON.
438fn parse_questions(params: &serde_json::Value) -> Result<Vec<Question>, ToolError> {
439    let questions = params
440        .get("questions")
441        .and_then(|v| v.as_array())
442        .cloned()
443        .ok_or_else(|| "Missing or invalid 'questions' field".to_string())?;
444
445    let questions: Vec<Question> = questions
446        .into_iter()
447        .map(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
448        .collect::<Result<Vec<_>, _>>()
449        .map_err(|e| format!("Invalid question: {}", e))?;
450
451    if questions.is_empty() {
452        return Err("At least one question is required".to_string());
453    }
454
455    // Assign default labels (use the id) if not provided
456    let questions: Vec<Question> = questions
457        .into_iter()
458        .map(|mut q| {
459            if q.label.is_empty() {
460                q.label = q.id.clone();
461            }
462            q
463        })
464        .collect();
465
466    // Validate question IDs are unique
467    let mut ids = std::collections::HashSet::new();
468    for q in &questions {
469        if !ids.insert(&q.id) {
470            return Err(format!("Duplicate question id: {}", q.id));
471        }
472    }
473
474    Ok(questions)
475}
476
477/// Format answers into a human-readable text for the tool result.
478///
479/// The transcript renderer (`format_ask_result`) parses this text together
480/// with the call arguments to reconstruct the filled-menu view. The format
481/// stays model-readable:
482/// - single select: `<id>: <label>`
483/// - multi select:  `<id>: [a, b]`
484/// - custom input:  `<id>: "<text>"`
485/// - cancelled:     `<id>: (cancelled)`
486/// - timeout suffix: ` (auto-selected after timeout)`
487pub fn format_answers(answers: &[Answer], timed_out: bool) -> String {
488    let suffix = if timed_out {
489        " (auto-selected after timeout)"
490    } else {
491        ""
492    };
493    answers
494        .iter()
495        .map(|a| {
496            let base = if a.was_custom {
497                format!("{}: \"{}\"", a.id, a.label)
498            } else if a.value.contains(',') {
499                // multi-select: value is comma-joined
500                let labels: Vec<&str> = a.label.split(", ").collect();
501                format!("{}: [{}]", a.id, labels.join(", "))
502            } else {
503                format!("{}: {}", a.id, a.label)
504            };
505            format!("{base}{suffix}")
506        })
507        .collect::<Vec<_>>()
508        .join("\n")
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_parse_questions_valid() {
517        let json = serde_json::json!({
518            "questions": [
519                {
520                    "id": "lang",
521                    "prompt": "Pick a language",
522                    "options": [
523                        { "value": "rust", "label": "Rust" },
524                        { "value": "ts", "label": "TypeScript" }
525                    ]
526                }
527            ]
528        });
529        let questions = parse_questions(&json).unwrap();
530        assert_eq!(questions.len(), 1);
531        assert_eq!(questions[0].id, "lang");
532        assert_eq!(questions[0].label, "lang"); // default label = id
533        assert_eq!(questions[0].options.len(), 2);
534        assert!(questions[0].allow_other); // default
535        assert!(!questions[0].multi_select); // default
536    }
537
538    #[test]
539    fn test_parse_questions_with_label() {
540        let json = serde_json::json!({
541            "questions": [
542                {
543                    "id": "lang",
544                    "label": "Language",
545                    "prompt": "Pick a language"
546                }
547            ]
548        });
549        let questions = parse_questions(&json).unwrap();
550        assert_eq!(questions[0].label, "Language");
551    }
552
553    #[test]
554    fn test_parse_questions_empty_options() {
555        // allowOther=true + empty options = free text question
556        let json = serde_json::json!({
557            "questions": [
558                {
559                    "id": "name",
560                    "prompt": "What's your project name?",
561                    "allowOther": true
562                }
563            ]
564        });
565        let questions = parse_questions(&json).unwrap();
566        assert_eq!(questions[0].options.len(), 0);
567        assert!(questions[0].allow_other);
568    }
569
570    #[test]
571    fn test_parse_questions_missing_questions() {
572        let json = serde_json::json!({});
573        let err = parse_questions(&json).unwrap_err();
574        assert!(err.contains("questions"));
575    }
576
577    #[test]
578    fn test_parse_questions_empty_array() {
579        let json = serde_json::json!({ "questions": [] });
580        let err = parse_questions(&json).unwrap_err();
581        assert!(err.contains("one question"));
582    }
583
584    #[test]
585    fn test_parse_questions_duplicate_ids() {
586        let json = serde_json::json!({
587            "questions": [
588                { "id": "a", "prompt": "Q1" },
589                { "id": "a", "prompt": "Q2" }
590            ]
591        });
592        let err = parse_questions(&json).unwrap_err();
593        assert!(err.contains("Duplicate"));
594    }
595
596    #[test]
597    fn test_format_answers_single() {
598        let answers = vec![Answer {
599            id: "lang".into(),
600            value: "rust".into(),
601            label: "Rust".into(),
602            was_custom: false,
603            index: Some(1),
604        }];
605        let text = format_answers(&answers, false);
606        assert_eq!(text, "lang: Rust");
607    }
608
609    #[test]
610    fn test_format_answers_custom() {
611        let answers = vec![Answer {
612            id: "name".into(),
613            value: "myproj".into(),
614            label: "myproj".into(),
615            was_custom: true,
616            index: None,
617        }];
618        let text = format_answers(&answers, false);
619        assert_eq!(text, "name: \"myproj\"");
620    }
621
622    #[test]
623    fn test_format_answers_multi() {
624        let answers = vec![Answer {
625            id: "lang".into(),
626            value: "rust, go".into(), // comma-joined values signal multi
627            label: "Rust, Go".into(),
628            was_custom: false,
629            index: None,
630        }];
631        let text = format_answers(&answers, false);
632        assert_eq!(text, "lang: [Rust, Go]");
633    }
634
635    #[test]
636    fn test_format_answers_timed_out() {
637        let answers = vec![Answer {
638            id: "auth".into(),
639            value: "oauth".into(),
640            label: "OAuth2".into(),
641            was_custom: false,
642            index: Some(2),
643        }];
644        let text = format_answers(&answers, true);
645        assert_eq!(text, "auth: OAuth2 (auto-selected after timeout)");
646    }
647
648    #[test]
649    fn test_bridge_set_take() {
650        let bridge = AskBridge::new();
651        assert!(!bridge.has_pending());
652
653        let (tx, _rx) = oneshot::channel();
654        let pending = PendingAsk {
655            questions: vec![],
656            responder: tx,
657            timeout: None,
658            session_id: None,
659        };
660        assert!(bridge.set(pending));
661        assert!(bridge.has_pending());
662
663        let taken = bridge.try_take();
664        assert!(taken.is_some());
665        assert!(!bridge.has_pending());
666
667        // Second take returns None
668        assert!(bridge.try_take().is_none());
669    }
670
671    #[test]
672    fn test_bridge_set_idempotent() {
673        let bridge = AskBridge::new();
674        let (tx1, _rx1) = oneshot::channel();
675        let (tx2, _rx2) = oneshot::channel();
676
677        bridge.set(PendingAsk {
678            questions: vec![],
679            responder: tx1,
680            timeout: None,
681            session_id: None,
682        });
683        assert!(!bridge.set(PendingAsk {
684            questions: vec![],
685            responder: tx2,
686            timeout: None,
687            session_id: None,
688        }));
689    }
690
691    #[test]
692    fn test_ui_attached_flag() {
693        let bridge = AskBridge::new();
694        assert!(!bridge.is_ui_attached());
695        bridge.attach();
696        assert!(bridge.is_ui_attached());
697    }
698
699    #[test]
700    fn test_bridge_with_timeout() {
701        let bridge = AskBridge::with_timeout(Some(Duration::from_secs(30)));
702        assert_eq!(bridge.timeout(), Some(Duration::from_secs(30)));
703        assert!(!bridge.is_ui_attached()); // with_timeout doesn't attach
704
705        let no_timeout = AskBridge::new();
706        assert_eq!(no_timeout.timeout(), None);
707    }
708
709    #[test]
710    fn test_question_deserializes_without_recommended() {
711        // recommended is optional with serde default — backward compatible
712        let json = serde_json::json!({
713            "id": "test",
714            "prompt": "Test question?",
715            "options": [{"value": "a", "label": "A"}]
716        });
717        let q: Question = serde_json::from_value(json).unwrap();
718        assert_eq!(q.recommended, None);
719    }
720
721    #[test]
722    fn test_question_deserializes_with_recommended() {
723        let json = serde_json::json!({
724            "id": "test",
725            "prompt": "Test question?",
726            "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
727            "recommended": 1
728        });
729        let q: Question = serde_json::from_value(json).unwrap();
730        assert_eq!(q.recommended, Some(1));
731    }
732
733    #[test]
734    fn test_tool_name_is_ask() {
735        let bridge = Arc::new(AskBridge::new());
736        let tool = AskTool::new(bridge);
737        assert_eq!(tool.name(), "ask");
738        assert_eq!(tool.label(), "Ask");
739    }
740
741    #[test]
742    fn test_attach_with_session_stores_id() {
743        let bridge = AskBridge::new();
744        assert!(!bridge.is_ui_attached());
745        assert_eq!(bridge.session_id(), None);
746        bridge.attach_with_session("tui");
747        assert!(bridge.is_ui_attached());
748        assert_eq!(bridge.session_id().as_deref(), Some("tui"));
749    }
750
751    #[test]
752    fn test_format_answers_multi_with_comma_label() {
753        // Regression: option label containing a comma must still render as a
754        // multi-select bracket form when the value is comma-joined, not be
755        // misparsed as a single label.
756        let answers = vec![Answer {
757            id: "tags".into(),
758            value: "a,b".into(),
759            label: "A, B".into(),
760            was_custom: false,
761            index: None,
762        }];
763        let text = format_answers(&answers, false);
764        assert_eq!(text, "tags: [A, B]");
765    }
766
767    #[test]
768    fn test_format_answers_cancelled_marker() {
769        let answers = vec![Answer {
770            id: "q1".into(),
771            value: String::new(),
772            label: String::new(),
773            was_custom: false,
774            index: None,
775        }];
776        // format_answers doesn't itself emit "cancelled" — that comes from
777        // AskOverlay when the user presses Esc. Verify the formatted path
778        // produces an empty answer for that case so the renderer can detect it.
779        let text = format_answers(&answers, false);
780        assert_eq!(text, "q1: ");
781    }
782}