Skip to main content

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