1use 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#[derive(Clone)]
33pub struct AskBridge {
34 inner: Arc<parking_lot::Mutex<Option<PendingAsk>>>,
35 ui_attached: Arc<AtomicBool>,
39 session_id: Arc<parking_lot::Mutex<Option<String>>>,
46 timeout: Option<Duration>,
49}
50
51impl AskBridge {
52 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 pub fn with_timeout(timeout: Option<Duration>) -> Self {
64 Self {
65 timeout,
66 ..Self::new()
67 }
68 }
69
70 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 pub fn is_ui_attached(&self) -> bool {
88 self.ui_attached.load(Ordering::SeqCst)
89 }
90
91 #[cfg(any(test, debug_assertions))]
95 pub fn attach(&self) {
96 self.ui_attached.store(true, Ordering::SeqCst);
97 }
98
99 pub fn session_id(&self) -> Option<String> {
101 self.session_id.lock().clone()
102 }
103 pub fn timeout(&self) -> Option<Duration> {
105 self.timeout
106 }
107
108 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 pub fn try_take(&self) -> Option<PendingAsk> {
123 self.inner.lock().take()
124 }
125
126 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
138pub struct PendingAsk {
142 pub questions: Vec<Question>,
144 pub responder: oneshot::Sender<AskResponse>,
147 pub timeout: Option<Duration>,
149 pub session_id: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Question {
158 pub id: String,
160 #[serde(default)]
163 pub label: String,
164 pub prompt: String,
166 #[serde(default)]
168 pub options: Vec<QuestionOption>,
169 #[serde(default = "default_true")]
173 pub allow_other: bool,
174 #[serde(default)]
176 pub multi_select: bool,
177 #[serde(default)]
181 pub recommended: Option<usize>,
182}
183
184fn default_true() -> bool {
185 true
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct QuestionOption {
191 pub value: String,
193 pub label: String,
195 pub description: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AskResponse {
202 pub answers: Vec<Answer>,
204 pub cancelled: bool,
206 #[serde(default)]
208 pub timed_out: bool,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Answer {
214 pub id: String,
216 pub value: String,
218 pub label: String,
220 pub was_custom: bool,
222 pub index: Option<usize>,
224}
225
226pub struct AskTool {
230 bridge: Arc<AskBridge>,
231}
232
233impl AskTool {
234 pub fn new(bridge: Arc<AskBridge>) -> Self {
236 Self { bridge }
237 }
238}
239
240impl 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 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 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 let questions = parse_questions(¶ms)?;
375 let timeout = self.bridge.timeout();
376
377 let (tx, rx) = oneshot::channel();
379
380 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 select_with_abort(rx, signal, &self.bridge).await
392 }
393}
394
395async fn select_with_abort(
397 rx: oneshot::Receiver<AskResponse>,
398 signal: Option<oneshot::Receiver<()>>,
399 bridge: &AskBridge,
400) -> Result<AgentToolResult, ToolError> {
401 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 Ok(AgentToolResult::success("Question dismissed"))
426 }
427 }
428 }
429 () = abort => {
430 bridge.try_take();
432 Ok(AgentToolResult::success("Question cancelled by user interrupt"))
433 }
434 }
435}
436
437fn 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 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 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
477pub 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 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"); assert_eq!(questions[0].options.len(), 2);
534 assert!(questions[0].allow_other); assert!(!questions[0].multi_select); }
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 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(), 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 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()); let no_timeout = AskBridge::new();
706 assert_eq!(no_timeout.timeout(), None);
707 }
708
709 #[test]
710 fn test_question_deserializes_without_recommended() {
711 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 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 let text = format_answers(&answers, false);
780 assert_eq!(text, "q1: ");
781 }
782}