diff --git a/docs/skills-guide.md b/docs/skills-guide.md
index aa36479..47441c9 100644
--- a/docs/skills-guide.md
+++ b/docs/skills-guide.md
@@ -96,27 +96,58 @@ AI 在调用 Skill 时遵循以下规范:
```json
{
+ "kind": "choice_or_text",
"prompt": "请选择下一步操作",
"options": [
- {"value": "a", "label": "方案 A"},
- {"value": "b", "label": "方案 B"}
+ {"value": "a", "label": "方案 A", "description": "适合风险较低、改动较小的情况"},
+ {"value": "b", "label": "方案 B", "description": "适合需要更完整验证的情况"}
],
"default": "a",
"title": "选择",
"allow_cancel": true,
- "allow_custom_input": true,
- "custom_label": "其他(自行输入)",
- "custom_prompt": "请输入自定义内容"
+ "custom": {
+ "label": "其他(自行输入)",
+ "placeholder": "请输入自定义内容"
+ }
}
```
+推荐约定:
+
+- `single_select`:只能选择预设项,不要传 `custom`
+- `text_input`:只允许自由输入,不要传 `options`
+- `choice_or_text`:既给 `options`,也给 `custom`,用于“候选项可能不完整”的场景
+
### ask_user 返回
-- 用户选中某项:返回 JSON(`status=selected`)
+- 用户选中某项:返回结构化结果(`status=selected`),并保留选中的 value/label
+- 用户输入自定义内容:返回结构化结果(`status=custom`),并保留输入文本;可通过 `answer_type=text` 区分于预设选项
- 用户取消或交互不可用:任务会暂停并提示用户继续方式。你可以:
- 直接回复选项 value/编号/文字
- 输入 `; 使用默认继续`(或 `; continue with default`)明确使用默认值继续
-- 若启用 `allow_custom_input`,UI 会提供“自定义输入”行,用户可直接输入内容并回车,返回 `status=custom`
+- 若使用 `choice_or_text`,会显示支持自定义输入的对话框;用户可直接输入内容并回车,返回 `status=custom`
+- 若为选项提供 `description`,UI 和暂停提示会把说明展示在选项下方
+
+### 推荐调用示例
+
+```json
+{
+ "kind": "choice_or_text",
+ "prompt": "请选择一个水果,或输入列表中没有的水果",
+ "title": "水果选择",
+ "options": [
+ {"value": "apple", "label": "苹果"},
+ {"value": "banana", "label": "香蕉"},
+ {"value": "orange", "label": "橙子"}
+ ],
+ "default": "apple",
+ "custom": {
+ "label": "其他水果",
+ "placeholder": "输入自定义水果名称"
+ },
+ "allow_cancel": true
+}
+```
## 开发新 Skill
diff --git a/src/aish/config.py b/src/aish/config.py
index 5b0fc69..a044a2d 100644
--- a/src/aish/config.py
+++ b/src/aish/config.py
@@ -155,10 +155,6 @@ class TUISettings(BaseModel):
)
show_time: bool = Field(default=True, description="Show time in status bar")
show_cwd: bool = Field(default=True, description="Show current directory in status bar")
- inline_ui: bool = Field(
- default=True,
- description="Use inline UI for selections (ask_user) at bottom of screen",
- )
class ConfigModel(BaseModel):
diff --git a/src/aish/i18n/de-DE.yaml b/src/aish/i18n/de-DE.yaml
index 5beb4c8..3a36587 100644
--- a/src/aish/i18n/de-DE.yaml
+++ b/src/aish/i18n/de-DE.yaml
@@ -196,8 +196,8 @@ shell:
ask_user:
title: "Option auswählen"
custom_label: "Benutzerdefiniert:"
- hint_select: "↑/↓ zum Bewegen • Enter zum Bestätigen • Esc zum Abbrechen"
- hint_custom: "Zum Eingeben tippen • Enter zum Bestätigen • Tab zu den Optionen • Esc zum Abbrechen"
+ hint_select: "Enter zum Auswählen · ↑/↓ zum Navigieren · Esc zum Abbrechen"
+ hint_custom: "Enter zum Auswählen · ↑/↓ zum Navigieren · Esc zum Abbrechen"
paused:
title: "⏸ Benutzereingabe erforderlich (Aufgabe pausiert)"
prompt: "Frage: {prompt}"
diff --git a/src/aish/i18n/en-US.yaml b/src/aish/i18n/en-US.yaml
index 726c424..e4b9162 100644
--- a/src/aish/i18n/en-US.yaml
+++ b/src/aish/i18n/en-US.yaml
@@ -298,15 +298,15 @@ shell:
ask_user:
title: "Select an option"
custom_label: "Custom:"
- hint_select: "↑/↓ to move • Enter to confirm • Esc to cancel"
- hint_custom: "Type to input custom value • Enter to confirm • Tab to options • Esc to cancel"
+ hint_select: "Enter to confirm · ↑/↓ to navigate · Esc to cancel"
+ hint_custom: "Enter to confirm · ↑/↓ to navigate · Esc to cancel"
paused:
title: "⏸ User input required (task paused)"
prompt: "Question: {prompt}"
reason: "Reason: {reason}"
options_header: "Options:"
custom_input: "You can also reply with any custom value."
- how_to: "Reply with an option value (or number), or type `; continue with default` (default: {default})."
+ how_to: "Reply with an option value (or number), or type `; continue with default` (default: {default}). Option descriptions are shown beneath each choice when available."
diagnose_cancelled: " └─ ❌ System diagnosis cancelled"
diff --git a/src/aish/i18n/es-ES.yaml b/src/aish/i18n/es-ES.yaml
index 0ae1cda..83cc113 100644
--- a/src/aish/i18n/es-ES.yaml
+++ b/src/aish/i18n/es-ES.yaml
@@ -196,8 +196,8 @@ shell:
ask_user:
title: "Selecciona una opción"
custom_label: "Personalizado:"
- hint_select: "↑/↓ para moverte • Enter para confirmar • Esc para cancelar"
- hint_custom: "Escribe para introducir un valor • Enter para confirmar • Tab para ir a las opciones • Esc para cancelar"
+ hint_select: "Enter para elegir · ↑/↓ para navegar · Esc para cancelar"
+ hint_custom: "Enter para elegir · ↑/↓ para navegar · Esc para cancelar"
paused:
title: "⏸ Se requiere entrada del usuario (tarea en pausa)"
prompt: "Pregunta: {prompt}"
diff --git a/src/aish/i18n/fr-FR.yaml b/src/aish/i18n/fr-FR.yaml
index 087e0e3..b85866c 100644
--- a/src/aish/i18n/fr-FR.yaml
+++ b/src/aish/i18n/fr-FR.yaml
@@ -196,8 +196,8 @@ shell:
ask_user:
title: "Selectionnez une option"
custom_label: "Personnalise :"
- hint_select: "↑/↓ pour se deplacer • Enter pour confirmer • Esc pour annuler"
- hint_custom: "Tapez pour saisir une valeur • Enter pour confirmer • Tab pour aller aux options • Esc pour annuler"
+ hint_select: "Entree pour choisir · ↑/↓ pour naviguer · Esc pour annuler"
+ hint_custom: "Entree pour choisir · ↑/↓ pour naviguer · Esc pour annuler"
paused:
title: "⏸ Saisie utilisateur requise (tache en pause)"
prompt: "Question : {prompt}"
diff --git a/src/aish/i18n/ja-JP.yaml b/src/aish/i18n/ja-JP.yaml
index 64581e3..dd76eef 100644
--- a/src/aish/i18n/ja-JP.yaml
+++ b/src/aish/i18n/ja-JP.yaml
@@ -196,8 +196,8 @@ shell:
ask_user:
title: "オプションを選択"
custom_label: "カスタム:"
- hint_select: "↑/↓ で移動 • Enter で確定 • Esc でキャンセル"
- hint_custom: "入力して値を指定 • Enter で確定 • Tab で選択肢へ移動 • Esc でキャンセル"
+ hint_select: "Enter で選択 ・ ↑/↓ で移動 ・ Esc でキャンセル"
+ hint_custom: "Enter で選択 ・ ↑/↓ で移動 ・ Esc でキャンセル"
paused:
title: "⏸ ユーザー入力が必要です(タスクは一時停止中)"
prompt: "質問: {prompt}"
diff --git a/src/aish/i18n/zh-CN.yaml b/src/aish/i18n/zh-CN.yaml
index 7850761..0842a62 100644
--- a/src/aish/i18n/zh-CN.yaml
+++ b/src/aish/i18n/zh-CN.yaml
@@ -298,15 +298,15 @@ shell:
ask_user:
title: "请选择"
custom_label: "自定义:"
- hint_select: "↑/↓ 选择 • Enter 确认 • Esc 取消"
- hint_custom: "直接输入自定义内容 • Enter 确认 • Tab 切换选项 • Esc 取消"
+ hint_select: "按 Enter 键选择 · 按 ↑/↓ 键导航 · 按 Esc 键取消"
+ hint_custom: "按 Enter 键选择 · 按 ↑/↓ 键导航 · 按 Esc 键取消"
paused:
title: "⏸ 需要你的选择(任务已暂停)"
prompt: "问题:{prompt}"
reason: "原因:{reason}"
options_header: "可选项:"
custom_input: "你也可以直接回复任意自定义内容。"
- how_to: "你可以直接回复选项 value(或编号),或输入 `; 使用默认继续` / `; continue with default`(默认:{default})。"
+ how_to: "你可以直接回复选项 value(或编号),或输入 `; 使用默认继续` / `; continue with default`(默认:{default})。如有选项说明,会显示在选项下方。"
diagnose_cancelled: " └─ ❌ 系统诊断已取消"
diff --git a/src/aish/interaction/__init__.py b/src/aish/interaction/__init__.py
new file mode 100644
index 0000000..ab602e9
--- /dev/null
+++ b/src/aish/interaction/__init__.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from .ask_user import (
+ apply_interaction_response_to_data,
+ AskUserRequestBuilder,
+ AskUserInteractionAdapter,
+)
+from .models import (
+ InteractionAnswer,
+ InteractionAnswerType,
+ InteractionCustomConfig,
+ InteractionKind,
+ InteractionOption,
+ InteractionRequest,
+ InteractionResponse,
+ InteractionSource,
+ InteractionStatus,
+ InteractionValidation,
+)
+from .service import InteractionService
+
+__all__ = [
+ "AskUserRequestBuilder",
+ "AskUserInteractionAdapter",
+ "apply_interaction_response_to_data",
+ "InteractionAnswer",
+ "InteractionAnswerType",
+ "InteractionCustomConfig",
+ "InteractionKind",
+ "InteractionOption",
+ "InteractionRequest",
+ "InteractionResponse",
+ "InteractionService",
+ "InteractionSource",
+ "InteractionStatus",
+ "InteractionValidation",
+]
\ No newline at end of file
diff --git a/src/aish/interaction/ask_user.py b/src/aish/interaction/ask_user.py
new file mode 100644
index 0000000..ecbfce9
--- /dev/null
+++ b/src/aish/interaction/ask_user.py
@@ -0,0 +1,252 @@
+from __future__ import annotations
+
+import json
+import uuid
+
+from aish.i18n import t
+from aish.tools.result import ToolResult
+
+from .models import (
+ InteractionAnswerType,
+ InteractionCustomConfig,
+ InteractionKind,
+ InteractionOption,
+ InteractionRequest,
+ InteractionResponse,
+ InteractionSource,
+ InteractionStatus,
+ InteractionValidation,
+)
+
+
+class AskUserRequestBuilder:
+ @staticmethod
+ def pick_text_default(default: object) -> str | None:
+ if isinstance(default, str):
+ return default
+ return None
+
+ @staticmethod
+ def normalize_options(options: object) -> list[InteractionOption]:
+ if not isinstance(options, list):
+ return []
+
+ normalized: list[InteractionOption] = []
+ for item in options:
+ if not isinstance(item, dict):
+ continue
+ value = item.get("value")
+ label = item.get("label")
+ if not isinstance(value, str) or not value.strip():
+ continue
+ if not isinstance(label, str) or not label.strip():
+ continue
+ description = item.get("description")
+ normalized.append(
+ InteractionOption(
+ value=value.strip(),
+ label=label.strip(),
+ description=description.strip()
+ if isinstance(description, str) and description.strip()
+ else None,
+ )
+ )
+ return normalized
+
+ @staticmethod
+ def pick_default(default: object, options: list[InteractionOption]) -> str:
+ fallback = options[0].value if options else ""
+ if isinstance(default, str) and default in {option.value for option in options}:
+ return default
+ return fallback
+
+ @classmethod
+ def from_tool_args(
+ cls,
+ *,
+ kind: str,
+ prompt: str,
+ options: object = None,
+ default: str | None = None,
+ title: str | None = None,
+ required: bool = True,
+ allow_cancel: bool = True,
+ metadata: object = None,
+ placeholder: str | None = None,
+ validation: object = None,
+ custom: object = None,
+ interaction_id: str | None = None,
+ ) -> InteractionRequest:
+ interaction_kind = InteractionKind(kind)
+ normalized_options = cls.normalize_options(options)
+ default_value = cls.pick_default(default, normalized_options)
+ if interaction_kind == InteractionKind.TEXT_INPUT:
+ default_value = cls.pick_text_default(default)
+
+ request_metadata = dict(metadata) if isinstance(metadata, dict) else {}
+
+ request_placeholder = (
+ placeholder.strip()
+ if isinstance(placeholder, str) and placeholder.strip()
+ else None
+ )
+
+ request_validation = (
+ InteractionValidation.from_dict(validation)
+ if isinstance(validation, dict)
+ else None
+ )
+
+ request_custom = None
+ if interaction_kind == InteractionKind.CHOICE_OR_TEXT:
+ custom_payload = custom if isinstance(custom, dict) else {}
+ label = str(
+ custom_payload.get("label") or t("shell.ask_user.custom_label")
+ ).strip()
+ custom_placeholder = custom_payload.get("placeholder")
+ request_custom = InteractionCustomConfig(
+ label=label,
+ placeholder=(
+ str(custom_placeholder).strip()
+ if isinstance(custom_placeholder, str) and custom_placeholder.strip()
+ else request_placeholder
+ ),
+ submit_mode=str(custom_payload.get("submit_mode") or "inline"),
+ )
+ elif interaction_kind == InteractionKind.TEXT_INPUT:
+ request_custom = None
+ if request_validation is None:
+ request_validation = InteractionValidation(required=required, min_length=1)
+
+ if interaction_kind in (
+ InteractionKind.SINGLE_SELECT,
+ InteractionKind.CHOICE_OR_TEXT,
+ ):
+ request_placeholder = None
+
+ return InteractionRequest(
+ id=interaction_id or f"interaction_{uuid.uuid4().hex[:12]}",
+ kind=interaction_kind,
+ title=title,
+ prompt=prompt,
+ required=bool(required),
+ allow_cancel=bool(allow_cancel),
+ source=InteractionSource(type="tool", name="ask_user"),
+ metadata=request_metadata,
+ options=normalized_options,
+ default=default_value,
+ placeholder=request_placeholder,
+ validation=request_validation,
+ custom=request_custom,
+ )
+
+class AskUserInteractionAdapter:
+ @staticmethod
+ def to_tool_result(
+ request: InteractionRequest,
+ response: InteractionResponse,
+ ) -> ToolResult:
+ if response.status == InteractionStatus.SUBMITTED and response.answer is not None:
+ if response.answer.type == InteractionAnswerType.OPTION:
+ label = response.answer.label or response.answer.value
+ return ToolResult(
+ ok=True,
+ output=f"User selected: {label}",
+ data={
+ "value": response.answer.value,
+ "label": label,
+ "status": "selected",
+ "interaction_id": response.interaction_id,
+ "answer_type": response.answer.type.value,
+ },
+ meta={
+ "interaction_id": response.interaction_id,
+ "interaction_status": response.status.value,
+ },
+ )
+ if response.answer.type == InteractionAnswerType.TEXT:
+ return ToolResult(
+ ok=True,
+ output=f"User input: {response.answer.value}",
+ data={
+ "value": response.answer.value,
+ "label": response.answer.label or response.answer.value,
+ "status": "custom",
+ "interaction_id": response.interaction_id,
+ "answer_type": response.answer.type.value,
+ },
+ meta={
+ "interaction_id": response.interaction_id,
+ "interaction_status": response.status.value,
+ },
+ )
+
+ reason = response.reason or response.status.value
+ pause_text = AskUserInteractionAdapter.build_pause_message(
+ request=request,
+ reason=reason,
+ )
+ return ToolResult(
+ ok=False,
+ output=pause_text,
+ meta={
+ "kind": "user_input_required",
+ "reason": reason,
+ "prompt": request.prompt,
+ "default": request.default,
+ "options": [option.to_dict() for option in request.options],
+ "interaction_id": response.interaction_id,
+ "interaction_status": response.status.value,
+ },
+ stop_tool_chain=True,
+ )
+
+ @staticmethod
+ def build_pause_message(*, request: InteractionRequest, reason: str) -> str:
+ lines: list[str] = []
+ lines.append(t("shell.ask_user.paused.title"))
+ lines.append(t("shell.ask_user.paused.prompt", prompt=request.prompt))
+ lines.append(t("shell.ask_user.paused.reason", reason=reason))
+ lines.append(t("shell.ask_user.paused.options_header"))
+ for index, option in enumerate(request.options, start=1):
+ lines.append(f" {index}. {option.label} ({option.value})")
+ if option.description:
+ lines.append(f" {option.description}")
+ if request.kind in (InteractionKind.CHOICE_OR_TEXT, InteractionKind.TEXT_INPUT):
+ lines.append(t("shell.ask_user.paused.custom_input"))
+ cancel_hint = request.metadata.get("cancel_hint")
+ if isinstance(cancel_hint, str) and cancel_hint.strip():
+ lines.append("")
+ lines.append(cancel_hint.strip())
+ lines.append("")
+ lines.append(
+ t(
+ "shell.ask_user.paused.how_to",
+ default=request.default or "",
+ )
+ )
+ lines.append("")
+ context = {
+ "kind": "ask_user_context",
+ "interaction_id": request.id,
+ "prompt": request.prompt,
+ "default": request.default or "",
+ "options": [option.to_dict() for option in request.options],
+ "suggested_continue_commands": [
+ "; continue with default",
+ "; 使用默认继续",
+ ],
+ }
+ lines.append("```json")
+ lines.append(json.dumps(context, ensure_ascii=False))
+ lines.append("```")
+ return "\n".join(lines).strip()
+
+
+def apply_interaction_response_to_data(
+ data: dict[str, object],
+ response: InteractionResponse,
+) -> None:
+ data["interaction_response"] = response.to_dict()
+ data.pop("selected_value", None)
+ data.pop("custom_input", None)
\ No newline at end of file
diff --git a/src/aish/interaction/models.py b/src/aish/interaction/models.py
new file mode 100644
index 0000000..36925cc
--- /dev/null
+++ b/src/aish/interaction/models.py
@@ -0,0 +1,251 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any
+
+
+class InteractionKind(str, Enum):
+ SINGLE_SELECT = "single_select"
+ TEXT_INPUT = "text_input"
+ CHOICE_OR_TEXT = "choice_or_text"
+ CONFIRM = "confirm"
+
+
+class InteractionStatus(str, Enum):
+ SUBMITTED = "submitted"
+ CANCELLED = "cancelled"
+ DISMISSED = "dismissed"
+ UNAVAILABLE = "unavailable"
+
+
+class InteractionAnswerType(str, Enum):
+ OPTION = "option"
+ TEXT = "text"
+ CONFIRM = "confirm"
+
+
+@dataclass(frozen=True)
+class InteractionSource:
+ type: str
+ name: str
+
+ def to_dict(self) -> dict[str, str]:
+ return {"type": self.type, "name": self.name}
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionSource":
+ return cls(
+ type=str(data.get("type") or "tool"),
+ name=str(data.get("name") or "ask_user"),
+ )
+
+
+@dataclass(frozen=True)
+class InteractionOption:
+ value: str
+ label: str
+ description: str | None = None
+
+ def to_dict(self) -> dict[str, str]:
+ item = {"value": self.value, "label": self.label}
+ if self.description:
+ item["description"] = self.description
+ return item
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionOption":
+ description = data.get("description")
+ return cls(
+ value=str(data.get("value") or ""),
+ label=str(data.get("label") or ""),
+ description=str(description) if isinstance(description, str) else None,
+ )
+
+
+@dataclass(frozen=True)
+class InteractionValidation:
+ required: bool = True
+ min_length: int | None = None
+
+ def to_dict(self) -> dict[str, Any]:
+ data: dict[str, Any] = {"required": self.required}
+ if self.min_length is not None:
+ data["min_length"] = self.min_length
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionValidation":
+ min_length = data.get("min_length")
+ return cls(
+ required=bool(data.get("required", True)),
+ min_length=min_length if isinstance(min_length, int) else None,
+ )
+
+
+@dataclass(frozen=True)
+class InteractionCustomConfig:
+ label: str
+ placeholder: str | None = None
+ submit_mode: str = "inline"
+
+ def to_dict(self) -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "label": self.label,
+ "submit_mode": self.submit_mode,
+ }
+ if self.placeholder is not None:
+ data["placeholder"] = self.placeholder
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionCustomConfig":
+ placeholder = data.get("placeholder")
+ return cls(
+ label=str(data.get("label") or ""),
+ placeholder=str(placeholder) if isinstance(placeholder, str) else None,
+ submit_mode=str(data.get("submit_mode") or "inline"),
+ )
+
+
+@dataclass(frozen=True)
+class InteractionAnswer:
+ type: InteractionAnswerType
+ value: str
+ label: str | None = None
+
+ def to_dict(self) -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "type": self.type.value,
+ "value": self.value,
+ }
+ if self.label is not None:
+ data["label"] = self.label
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionAnswer":
+ label = data.get("label")
+ return cls(
+ type=InteractionAnswerType(
+ str(data.get("type") or InteractionAnswerType.TEXT.value)
+ ),
+ value=str(data.get("value") or ""),
+ label=str(label) if isinstance(label, str) else None,
+ )
+
+
+@dataclass(frozen=True)
+class InteractionRequest:
+ id: str
+ kind: InteractionKind
+ prompt: str
+ title: str | None = None
+ required: bool = True
+ allow_cancel: bool = True
+ source: InteractionSource = field(
+ default_factory=lambda: InteractionSource(type="tool", name="ask_user")
+ )
+ metadata: dict[str, Any] = field(default_factory=dict)
+ options: list[InteractionOption] = field(default_factory=list)
+ default: str | None = None
+ placeholder: str | None = None
+ validation: InteractionValidation | None = None
+ custom: InteractionCustomConfig | None = None
+
+ def get_option_by_value(self, value: str) -> InteractionOption | None:
+ for option in self.options:
+ if option.value == value:
+ return option
+ return None
+
+ def to_dict(self) -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "id": self.id,
+ "kind": self.kind.value,
+ "prompt": self.prompt,
+ "required": self.required,
+ "allow_cancel": self.allow_cancel,
+ "source": self.source.to_dict(),
+ "metadata": dict(self.metadata),
+ "options": [option.to_dict() for option in self.options],
+ }
+ if self.title is not None:
+ data["title"] = self.title
+ if self.default is not None:
+ data["default"] = self.default
+ if self.placeholder is not None:
+ data["placeholder"] = self.placeholder
+ if self.validation is not None:
+ data["validation"] = self.validation.to_dict()
+ if self.custom is not None:
+ data["custom"] = self.custom.to_dict()
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionRequest":
+ source_data = data.get("source") if isinstance(data.get("source"), dict) else {}
+ validation_data = (
+ data.get("validation") if isinstance(data.get("validation"), dict) else None
+ )
+ custom_data = data.get("custom") if isinstance(data.get("custom"), dict) else None
+ options_data = data.get("options") if isinstance(data.get("options"), list) else []
+ return cls(
+ id=str(data.get("id") or ""),
+ kind=InteractionKind(
+ str(data.get("kind") or InteractionKind.SINGLE_SELECT.value)
+ ),
+ prompt=str(data.get("prompt") or ""),
+ title=str(data.get("title")) if isinstance(data.get("title"), str) else None,
+ required=bool(data.get("required", True)),
+ allow_cancel=bool(data.get("allow_cancel", True)),
+ source=InteractionSource.from_dict(source_data),
+ metadata=dict(data.get("metadata") or {}),
+ options=[
+ InteractionOption.from_dict(option)
+ for option in options_data
+ if isinstance(option, dict)
+ ],
+ default=str(data.get("default")) if isinstance(data.get("default"), str) else None,
+ placeholder=str(data.get("placeholder")) if isinstance(data.get("placeholder"), str) else None,
+ validation=InteractionValidation.from_dict(validation_data)
+ if validation_data is not None
+ else None,
+ custom=InteractionCustomConfig.from_dict(custom_data)
+ if custom_data is not None
+ else None,
+ )
+
+
+@dataclass(frozen=True)
+class InteractionResponse:
+ interaction_id: str
+ status: InteractionStatus
+ answer: InteractionAnswer | None = None
+ reason: str | None = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "interaction_id": self.interaction_id,
+ "status": self.status.value,
+ "metadata": dict(self.metadata),
+ }
+ if self.answer is not None:
+ data["answer"] = self.answer.to_dict()
+ if self.reason is not None:
+ data["reason"] = self.reason
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "InteractionResponse":
+ answer_data = data.get("answer") if isinstance(data.get("answer"), dict) else None
+ return cls(
+ interaction_id=str(data.get("interaction_id") or ""),
+ status=InteractionStatus(
+ str(data.get("status") or InteractionStatus.DISMISSED.value)
+ ),
+ answer=InteractionAnswer.from_dict(answer_data) if answer_data is not None else None,
+ reason=str(data.get("reason")) if isinstance(data.get("reason"), str) else None,
+ metadata=dict(data.get("metadata") or {}),
+ )
\ No newline at end of file
diff --git a/src/aish/interaction/service.py b/src/aish/interaction/service.py
new file mode 100644
index 0000000..06041e9
--- /dev/null
+++ b/src/aish/interaction/service.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from .models import InteractionRequest, InteractionResponse, InteractionStatus
+
+
+class InteractionService:
+ def __init__(
+ self,
+ renderer: Callable[[InteractionRequest], InteractionResponse],
+ ) -> None:
+ self._renderer = renderer
+
+ def request(self, request: InteractionRequest) -> InteractionResponse:
+ try:
+ return self._renderer(request)
+ except KeyboardInterrupt:
+ raise
+ except Exception as exc:
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="error",
+ metadata={"exception_type": type(exc).__name__},
+ )
\ No newline at end of file
diff --git a/src/aish/llm.py b/src/aish/llm.py
index f382ea3..06e7059 100644
--- a/src/aish/llm.py
+++ b/src/aish/llm.py
@@ -16,6 +16,7 @@
from aish.config import ConfigModel
from aish.context_manager import ContextManager, MemoryType
from aish.exception import is_litellm_exception, redact_secrets
+from aish.interaction import InteractionRequest, InteractionResponse, InteractionStatus
from aish.interruption import ShellState
from aish.litellm_loader import load_litellm
from aish.providers.registry import get_provider_for_model
@@ -76,7 +77,7 @@ class LLMEventType(Enum):
TOOL_EXECUTION_END = "tool_execution_end"
ERROR = "error"
TOOL_CONFIRMATION_REQUIRED = "tool_confirmation_required"
- ASK_USER_REQUIRED = "ask_user_required"
+ INTERACTION_REQUIRED = "interaction_required"
CANCELLED = "cancelled"
@@ -362,7 +363,7 @@ def __init__(
self.edit_file_tool = EditFileTool()
from aish.tools.ask_user import AskUserTool
- self.ask_user_tool = AskUserTool(request_choice=self.request_user_choice)
+ self.ask_user_tool = AskUserTool(request_interaction=self.request_interaction)
self.skill_tool = SkillTool(
skill_manager=self.skill_manager,
prompt_manager=self.prompt_manager,
@@ -759,37 +760,55 @@ def request_confirmation(
# If any error occurs during confirmation, use default
return default_on_timeout
- def request_user_choice(self, data: dict) -> tuple[str | None, str]:
- """Request a user choice via the shell UI.
-
- Returns:
- (selected_value, status)
- status in {"selected","cancelled","unavailable","error"}.
- """
+ def request_interaction(
+ self,
+ request: InteractionRequest,
+ ) -> InteractionResponse:
+ """Request a user interaction via the shell UI."""
# Non-interactive / no UI callback available.
if not self.event_callback:
- return None, "unavailable"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="unavailable",
+ )
try:
import sys
if not (sys.stdin.isatty() and sys.stdout.isatty()):
- return None, "unavailable"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="unavailable",
+ )
except Exception:
# Conservatively treat as unavailable.
- return None, "unavailable"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="unavailable",
+ )
try:
- # Shell handler is expected to mutate data["selected_value"].
- self.emit_event(LLMEventType.ASK_USER_REQUIRED, data)
- selected_value = data.get("selected_value")
- if isinstance(selected_value, str) and selected_value:
- return selected_value, "selected"
- return None, "cancelled"
+ event_data = {"interaction_request": request.to_dict()}
+ self.emit_event(LLMEventType.INTERACTION_REQUIRED, event_data)
+ response_payload = event_data.get("interaction_response")
+ if isinstance(response_payload, dict):
+ return InteractionResponse.from_dict(response_payload)
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.CANCELLED,
+ reason="cancelled",
+ )
except KeyboardInterrupt:
raise
except Exception:
- return None, "error"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="error",
+ )
def _get_langfuse_metadata(self, generation_type: str) -> dict:
"""Generate Langfuse metadata for better observability"""
diff --git a/src/aish/shell.py b/src/aish/shell.py
index 9efab0a..963aaea 100644
--- a/src/aish/shell.py
+++ b/src/aish/shell.py
@@ -44,6 +44,7 @@
from .help_manager import HelpManager
from .history_manager import HistoryManager
from .i18n import t
+from .interaction import InteractionRequest, InteractionResponse, InteractionStatus
from .interruption import (InterruptionManager, PromptConfig,
ShellState)
from .llm import LLMCallbackResult, LLMEvent, LLMEventType, LLMSession
@@ -67,9 +68,7 @@
from .shell_enhanced.shell_prompt_io import \
get_user_input as _prompt_get_user_input
from .shell_enhanced.shell_prompt_io import \
- handle_ask_user_required as _prompt_handle_ask_user_required
-from .shell_enhanced.shell_prompt_io import \
- handle_ask_user_required_inline as _prompt_handle_ask_user_required_inline
+ handle_interaction_required as _prompt_handle_interaction_required
from .shell_enhanced.shell_prompt_io import \
handle_tool_confirmation_required as \
_prompt_handle_tool_confirmation_required
@@ -207,7 +206,7 @@ def __init__(
LLMEventType.TOOL_EXECUTION_END: self.handle_tool_execution_end,
LLMEventType.ERROR: self.handle_error_event,
LLMEventType.TOOL_CONFIRMATION_REQUIRED: self.handle_tool_confirmation_required,
- LLMEventType.ASK_USER_REQUIRED: self.handle_ask_user_required,
+ LLMEventType.INTERACTION_REQUIRED: self.handle_interaction_required,
LLMEventType.CANCELLED: self.handle_processing_cancelled,
}
self._llm_event_router = LLMEventRouter(self.event_handlers)
@@ -3489,41 +3488,19 @@ def handle_tool_confirmation_required(self, event: LLMEvent) -> LLMCallbackResul
"""Handle tool confirmation required event - display confirmation dialog and get user response"""
return _prompt_handle_tool_confirmation_required(self, event)
- def handle_ask_user_required(self, event: LLMEvent) -> LLMCallbackResult:
- """Handle ask_user event - show interactive single-choice UI.
+ def handle_interaction_required(self, event: LLMEvent) -> LLMCallbackResult:
+ """Handle interaction events with a single prompt UI."""
+ return _prompt_handle_interaction_required(self, event)
- Uses inline UI at bottom of screen if config.tui.inline_ui is True,
- otherwise uses the traditional modal dialog.
- """
- # Check if inline UI is enabled in config (default to True)
- try:
- inline_ui = getattr(getattr(self.config, "tui", None), "inline_ui", True)
- except Exception:
- inline_ui = True
- if inline_ui:
- return _prompt_handle_ask_user_required_inline(self, event)
- return _prompt_handle_ask_user_required(self, event)
-
- def request_user_choice(self, data: dict) -> tuple[str | None, str]:
- """Request a user choice via the shell UI.
+ def request_interaction(
+ self,
+ request: InteractionRequest,
+ ) -> InteractionResponse:
+ """Request a user interaction via the shell UI.
This method is used by plan_agent to get user input for plan confirmation.
- Args:
- data: Dictionary containing:
- - prompt: The question to ask
- - options: List of option dicts with 'value' and 'label'
- - default: Default option value
- - title: Optional title for the selection
- - allow_cancel: Whether to allow cancellation
- - allow_custom_input: Whether to allow custom input
-
- Returns:
- (selected_value, status) where status is one of:
- - "selected": User made a selection
- - "cancelled": User cancelled
- - "unavailable": UI not available (non-TTY)
- - "error": An error occurred
+ Returns the normalized interaction response.
"""
import sys
@@ -3538,32 +3515,39 @@ def request_user_choice(self, data: dict) -> tuple[str | None, str]:
# If no TTY but we have a running shell, try anyway
# (This handles the case where plan_agent runs in ThreadPoolExecutor)
if not has_tty and not self.running:
- return None, "unavailable"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="unavailable",
+ )
try:
# Create event and handle it
+ event_data = {"interaction_request": request.to_dict()}
event = LLMEvent(
- event_type=LLMEventType.ASK_USER_REQUIRED,
- data=data,
+ event_type=LLMEventType.INTERACTION_REQUIRED,
+ data=event_data,
timestamp=time.time(),
)
- self.handle_ask_user_required(event)
+ self.handle_interaction_required(event)
- # Check if user made a selection
- selected_value = data.get("selected_value")
- if isinstance(selected_value, str) and selected_value:
- return selected_value, "selected"
+ response_payload = event_data.get("interaction_response")
+ if isinstance(response_payload, dict):
+ return InteractionResponse.from_dict(response_payload)
- # Check for custom input
- custom_input = data.get("custom_input")
- if isinstance(custom_input, str) and custom_input.strip():
- return custom_input.strip(), "selected"
-
- return None, "cancelled"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.CANCELLED,
+ reason="cancelled",
+ )
except KeyboardInterrupt:
raise
except Exception:
- return None, "error"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="error",
+ )
def _display_security_panel(self, data: dict, panel_mode: str = "confirm"):
"""Display rich security panel for AI tool calls."""
diff --git a/src/aish/shell_enhanced/shell_llm_events.py b/src/aish/shell_enhanced/shell_llm_events.py
index 6f31b35..33487e4 100644
--- a/src/aish/shell_enhanced/shell_llm_events.py
+++ b/src/aish/shell_enhanced/shell_llm_events.py
@@ -24,7 +24,7 @@ def handle(self, event: LLMEvent) -> LLMCallbackResult:
result = handler(event)
if event.event_type in {
LLMEventType.TOOL_CONFIRMATION_REQUIRED,
- LLMEventType.ASK_USER_REQUIRED,
+ LLMEventType.INTERACTION_REQUIRED,
}:
if isinstance(result, LLMCallbackResult):
return result
diff --git a/src/aish/shell_enhanced/shell_prompt_io.py b/src/aish/shell_enhanced/shell_prompt_io.py
index c73cb7b..50bec88 100644
--- a/src/aish/shell_enhanced/shell_prompt_io.py
+++ b/src/aish/shell_enhanced/shell_prompt_io.py
@@ -14,6 +14,11 @@
from ..cancellation import CancellationReason
from ..i18n import t
+from ..interaction import (InteractionAnswer, InteractionAnswerType,
+ InteractionKind,
+ InteractionRequest, InteractionResponse,
+ InteractionStatus,
+ apply_interaction_response_to_data)
from ..interruption import InterruptAction, PromptConfig, ShellState
from ..llm import LLMCallbackResult, LLMEvent
@@ -386,10 +391,8 @@ def handle_tool_confirmation_required(shell: Any, event: LLMEvent) -> LLMCallbac
return LLMCallbackResult.CONTINUE
-def handle_ask_user_required(shell: Any, event: LLMEvent) -> LLMCallbackResult:
- """Handle ask_user event - show interactive single-choice UI."""
+def _prepare_interaction_prompt(shell: Any) -> None:
self = shell
- # Stop any active Live/animation before prompting.
self._stop_animation()
if self.current_live:
try:
@@ -397,96 +400,153 @@ def handle_ask_user_required(shell: Any, event: LLMEvent) -> LLMCallbackResult:
self.current_live.stop()
finally:
self.current_live = None
-
self._finalize_content_preview()
- data = event.data or {}
- prompt = str(data.get("prompt") or "")
- options = data.get("options") if isinstance(data.get("options"), list) else []
- default_value = data.get("default")
- if not isinstance(default_value, str):
- default_value = None
- title = str(data.get("title") or t("shell.ask_user.title"))
- allow_cancel = bool(data.get("allow_cancel", True))
- allow_custom_input = bool(data.get("allow_custom_input", False))
- custom_label_raw = data.get("custom_label")
- custom_label = str(custom_label_raw or t("shell.ask_user.custom_label"))
- if isinstance(custom_label_raw, str) and custom_label_raw.strip():
- label_text = custom_label_raw.strip()
- else:
- label_text = custom_label.strip()
- values: list[tuple[str, str]] = []
- for item in options:
- if not isinstance(item, dict):
- continue
- value = item.get("value")
- label = item.get("label")
- if isinstance(value, str) and value and isinstance(label, str) and label:
- values.append((value, label))
+def _build_interaction_response(
+ request: Any,
+ selected_value: str | None,
+) -> InteractionResponse:
+ if isinstance(selected_value, str) and selected_value:
+ selected_option = request.get_option_by_value(selected_value)
+ answer = InteractionAnswer(
+ type=InteractionAnswerType.OPTION
+ if selected_option is not None
+ else InteractionAnswerType.TEXT,
+ value=selected_option.value if selected_option is not None else selected_value,
+ label=selected_option.label if selected_option is not None else selected_value,
+ )
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ answer=answer,
+ )
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.CANCELLED,
+ reason="cancelled",
+ )
+
+
+def _request_allows_custom_input(request: Any) -> bool:
+ return request.kind in (
+ InteractionKind.CHOICE_OR_TEXT,
+ InteractionKind.TEXT_INPUT,
+ )
+
+
+def render_interaction_modal(shell: Any, request: Any) -> InteractionResponse:
+ self = shell
+ prompt = request.prompt
+ options = [option.to_dict() for option in request.options]
+ default_value = request.default
+ title = str(request.title or t("shell.ask_user.title"))
+ allow_cancel = request.allow_cancel
+ allow_custom_input = _request_allows_custom_input(request)
+ label_text = str(t("shell.ask_user.custom_label"))
+ custom_prompt = request.placeholder
+ if request.custom is not None:
+ label_text = request.custom.label
+ custom_prompt = request.custom.placeholder
+
+ values = [(item["value"], item["label"]) for item in options]
+ description_by_value = {
+ item["value"]: item.get("description", "") for item in options
+ }
+ if not values and not allow_custom_input:
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.DISMISSED,
+ reason="dismissed",
+ )
+
+ selected_index = 0
+ if default_value:
+ for index, (value, _) in enumerate(values):
+ if value == default_value:
+ selected_index = index
+ break
selected_value: str | None = None
try:
- from functools import partial
-
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
- from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
- from prompt_toolkit.key_binding.defaults import load_key_bindings
+ from prompt_toolkit.filters import Condition
+ from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
- from prompt_toolkit.layout.containers import Float, FloatContainer
+ from prompt_toolkit.layout.containers import ConditionalContainer
from prompt_toolkit.layout.dimension import D
from prompt_toolkit.styles import Style
from prompt_toolkit.utils import get_cwidth
- from prompt_toolkit.widgets import Box, RadioList
- # Flush any pending key presses (e.g., the Enter used to submit the last prompt)
- # to avoid instantly selecting/exiting the dialog.
try:
if sys.stdin.isatty():
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
except Exception:
pass
- radio_list = RadioList(values=values, default=default_value)
custom_buffer = Buffer() if allow_custom_input else None
+ if (
+ custom_buffer is not None
+ and request.kind == InteractionKind.TEXT_INPUT
+ and isinstance(default_value, str)
+ ):
+ custom_buffer.text = default_value
+ state = {
+ "selected_index": selected_index,
+ "custom_active": allow_custom_input and not values,
+ }
+ custom_placeholder_text = custom_prompt or label_text
+
+ def _is_custom_selected() -> bool:
+ return allow_custom_input and bool(state["custom_active"])
+
+ def _sync_focus(event) -> None:
+ if allow_custom_input and custom_input_field is not None and _is_custom_selected():
+ event.app.layout.focus(custom_input_field)
+ else:
+ event.app.layout.focus(options_window)
kb = KeyBindings()
- @kb.add("tab", eager=True)
- def _next(event):
- if allow_custom_input and custom_input_field is not None:
- if event.app.layout.has_focus(custom_input_field):
- event.app.layout.focus(radio_list)
- else:
- event.app.layout.focus(custom_input_field)
+ @kb.add("up", eager=True)
+ def _move_up(event):
+ if _is_custom_selected():
+ if values:
+ state["custom_active"] = False
+ state["selected_index"] = min(max(0, len(values) - 1), state["selected_index"])
+ _sync_focus(event)
+ event.app.invalidate()
+ return
+ if values:
+ state["selected_index"] = max(0, state["selected_index"] - 1)
+ _sync_focus(event)
+ event.app.invalidate()
- @kb.add("s-tab", eager=True)
- def _prev(event):
- if allow_custom_input and custom_input_field is not None:
- if event.app.layout.has_focus(custom_input_field):
- event.app.layout.focus(radio_list)
- else:
- event.app.layout.focus(custom_input_field)
+ @kb.add("down", eager=True)
+ def _move_down(event):
+ if _is_custom_selected():
+ return
+ if values and state["selected_index"] < len(values) - 1:
+ state["selected_index"] = min(len(values) - 1, state["selected_index"] + 1)
+ _sync_focus(event)
+ event.app.invalidate()
+ return
+ if allow_custom_input:
+ state["custom_active"] = True
+ _sync_focus(event)
+ event.app.invalidate()
@kb.add("enter", eager=True)
def _select(event):
- if (
- allow_custom_input
- and custom_buffer is not None
- and custom_input_field is not None
- ):
- if event.app.layout.has_focus(custom_input_field):
- text_value = (custom_buffer.text or "").strip()
- if text_value:
- event.app.exit(result=text_value)
- return
- # Ensure the currently highlighted item becomes the current value.
- try:
- radio_list._handle_enter()
- except Exception:
- pass
- event.app.exit(result=radio_list.current_value)
+ if allow_custom_input and custom_buffer is not None and _is_custom_selected():
+ text_value = (custom_buffer.text or "").strip()
+ if text_value:
+ event.app.exit(result=text_value)
+ return
+ return
+ if values:
+ event.app.exit(result=values[state["selected_index"]][0])
if allow_cancel:
@@ -502,41 +562,155 @@ def _cancel(event):
def _current_max_visible() -> int:
return self._compute_ask_user_max_visible(
- total_options=len(values),
+ total_options=max(1, len(values)),
term_rows=(self._read_terminal_size() or (24, 80))[0],
allow_custom_input=allow_custom_input,
)
- options_window = Box(
- body=radio_list,
+ def _regular_visible_range() -> tuple[int, int]:
+ total_regular = len(values)
+ if total_regular <= 0:
+ return (0, 0)
+
+ visible_count = _current_max_visible()
+ visible_regular = max(1, visible_count)
+
+ selected_regular = min(state["selected_index"], total_regular - 1)
+ start_index = max(0, selected_regular - visible_regular + 1)
+ if selected_regular < start_index:
+ start_index = selected_regular
+ if start_index + visible_regular > total_regular:
+ start_index = max(0, total_regular - visible_regular)
+ end_index = min(total_regular, start_index + visible_regular)
+ return (start_index, end_index)
+
+ def _build_option_lines() -> list[tuple[str, str]]:
+ start_index, end_index = _regular_visible_range()
+
+ fragments: list[tuple[str, str]] = []
+ if start_index > 0:
+ fragments.append(("class:hint", " ^ 更多选项\n"))
+
+ for index in range(start_index, end_index):
+ is_selected = index == state["selected_index"] and not _is_custom_selected()
+ value, label = values[index]
+ description = description_by_value.get(value, "")
+ prefix = ">" if is_selected else " "
+ style = "class:option.selected" if is_selected else "class:option"
+ fragments.append((style, f"{prefix} {index + 1}. {label}\n"))
+ if description:
+ desc_style = (
+ "class:option.description.selected"
+ if is_selected
+ else "class:option.description"
+ )
+ fragments.append((desc_style, f" {description}\n"))
+
+ if end_index < len(values):
+ fragments.append(("class:hint", " v 更多选项\n"))
+
+ return fragments
+
+ def _visible_option_rows() -> int:
+ start_index, end_index = _regular_visible_range()
+
+ rows = max(0, end_index - start_index)
+ rows += sum(
+ 1
+ for index in range(start_index, end_index)
+ if description_by_value.get(values[index][0], "")
+ )
+ if start_index > 0:
+ rows += 1
+ if end_index < len(values):
+ rows += 1
+ return rows
+
+ def _separator_text() -> str:
+ term_cols = (self._read_terminal_size() or (24, 80))[1]
+ return "─" * max(20, term_cols - 1)
+
+ options_window = Window(
+ content=FormattedTextControl(
+ text=_build_option_lines,
+ focusable=True,
+ show_cursor=False,
+ ),
+ wrap_lines=True,
+ dont_extend_height=True,
height=lambda: D(
- min=3,
- preferred=_current_max_visible(),
- max=_current_max_visible(),
+ min=0,
+ preferred=_visible_option_rows(),
+ max=_visible_option_rows(),
),
)
- custom_input_label_window = None
+ custom_row_window = None
custom_input_field = None
- if allow_custom_input and custom_buffer is not None:
- term_cols = (self._read_terminal_size() or (24, 80))[1]
- max_label_width = max(8, term_cols // 3)
- label_width = min(get_cwidth(label_text) + 1, max_label_width)
- custom_input_label_window = Window(
+ if allow_custom_input:
+ max_label_width = max(8, (self._read_terminal_size() or (24, 80))[1] // 3)
+ label_width = min(max(8, get_cwidth(label_text) + 1), max_label_width)
+ _ = label_width
+ custom_header_window = Window(
content=FormattedTextControl(
- text=f"{label_text} ", style="class:input.label"
+ text=lambda: [
+ (
+ "class:option.selected" if _is_custom_selected() else "class:option",
+ f"> {label_text}" if _is_custom_selected() else f" {label_text}",
+ )
+ ],
+ show_cursor=False,
),
dont_extend_height=True,
- width=D(preferred=label_width, min=label_width),
+ wrap_lines=True,
)
- custom_input_field = Window(
- content=BufferControl(buffer=custom_buffer, focusable=True),
- height=1,
- style="class:input",
+
+ custom_body_prefix = Window(
+ content=FormattedTextControl(text=" "),
+ dont_extend_height=True,
+ width=D(preferred=4, min=4, max=4),
)
- custom_row = HSplit(
+
+ if custom_buffer is not None:
+ custom_input_field = Window(
+ content=BufferControl(buffer=custom_buffer, focusable=True),
+ height=1,
+ style="class:input",
+ )
+
+ custom_placeholder_body = VSplit(
[
- VSplit([custom_input_label_window, custom_input_field], padding=1),
+ custom_body_prefix,
+ Window(
+ content=FormattedTextControl(
+ text=custom_placeholder_text,
+ style="class:option.placeholder",
+ ),
+ dont_extend_height=True,
+ wrap_lines=True,
+ ),
+ ],
+ padding=0,
+ )
+
+ custom_input_body = VSplit(
+ [custom_body_prefix, custom_input_field]
+ if custom_input_field is not None
+ else [custom_body_prefix],
+ padding=0,
+ )
+
+ custom_row_window = HSplit(
+ [
+ custom_header_window,
+ ConditionalContainer(
+ content=custom_placeholder_body,
+ filter=Condition(lambda: not _is_custom_selected()),
+ ),
+ ConditionalContainer(
+ content=custom_input_body,
+ filter=Condition(_is_custom_selected),
+ ),
],
padding=0,
)
@@ -552,152 +726,59 @@ def _current_max_visible() -> int:
dont_extend_height=True,
)
- body_items = [prompt_window, options_window]
- if allow_custom_input and custom_buffer is not None:
- body_items.append(custom_row)
- body_items.append(hint_window)
-
- body = HSplit(body_items, padding=1)
-
- rounded = {
- "tl": "╭",
- "tr": "╮",
- "bl": "╰",
- "br": "╯",
- "h": "─",
- "v": "│",
- }
- fill = partial(Window, style="class:frame.border")
-
- top_row_with_title = VSplit(
- [
- fill(width=1, height=1, char=rounded["tl"]),
- fill(char=rounded["h"]),
- fill(width=1, height=1, char=rounded["v"]),
- Window(
- FormattedTextControl(lambda: f" {title} "),
- style="class:frame.label",
- dont_extend_width=True,
- ),
- fill(width=1, height=1, char=rounded["v"]),
- fill(char=rounded["h"]),
- fill(width=1, height=1, char=rounded["tr"]),
- ],
+ separator_window_top = Window(
+ content=FormattedTextControl(text=_separator_text, style="class:separator"),
+ dont_extend_height=True,
height=1,
)
-
- top_row_without_title = VSplit(
- [
- fill(width=1, height=1, char=rounded["tl"]),
- fill(char=rounded["h"]),
- fill(width=1, height=1, char=rounded["tr"]),
- ],
+ separator_window_bottom = Window(
+ content=FormattedTextControl(text=_separator_text, style="class:separator"),
+ dont_extend_height=True,
height=1,
)
- top_row = top_row_with_title if title else top_row_without_title
-
- middle_row = VSplit(
- [
- fill(width=1, char=rounded["v"]),
- body,
- fill(width=1, char=rounded["v"]),
- ]
+ title_window = Window(
+ content=FormattedTextControl(text=f"[ {title} ]", style="class:title"),
+ wrap_lines=True,
+ dont_extend_height=True,
)
- bottom_row = VSplit(
- [
- fill(width=1, height=1, char=rounded["bl"]),
- fill(char=rounded["h"]),
- fill(width=1, height=1, char=rounded["br"]),
- ],
- height=1,
- )
+ body_items = [title_window, separator_window_top, prompt_window, Window(height=1, char="")]
+ if values:
+ body_items.append(options_window)
+ if custom_row_window is not None:
+ body_items.append(custom_row_window)
+ body_items.append(separator_window_bottom)
+ body_items.append(hint_window)
- frame_container = HSplit(
- [top_row, middle_row, bottom_row],
- style="class:frame",
- )
+ body = HSplit(body_items, padding=0)
style = Style.from_dict(
{
- "frame.border": "#5f5f5f",
- "frame.label": "bold #7aa2f7",
- "radio-list": "fg:#c0caf5",
- "radio-selected": "reverse",
- "radio-checked": "bold #7dcfff",
+ "title": "bold",
+ "separator": "fg:#6c7086",
+ "option": "",
+ "option.selected": "bold fg:#7dcfff",
+ "option.description": "fg:#7a8499",
+ "option.description.selected": "fg:#9ccfd8",
+ "option.placeholder": "fg:#7a8499",
+ "input": "",
"hint": "fg:#7a8499",
- "input.label": "fg:#9aa5ce",
- "input": "fg:#c0caf5",
}
)
- focus_target = radio_list
- if allow_custom_input and custom_input_field is not None:
- focus_target = custom_input_field
-
- def _line_count(text: str, width: int) -> int:
- if width <= 0:
- return 1
- count = 1
- current = 0
- for ch in text:
- if ch == "\n":
- count += 1
- current = 0
- continue
- w = get_cwidth(ch)
- if current + w > width:
- count += 1
- current = w
- else:
- current += w
- return max(1, count)
-
- def _dialog_height() -> int:
- rows, cols = self._read_terminal_size() or (24, 80)
- max_visible = self._compute_ask_user_max_visible(
- total_options=len(values),
- term_rows=rows,
- allow_custom_input=allow_custom_input,
- )
- inner_width = max(20, cols - 4)
- prompt_lines = _line_count(prompt, inner_width)
- hint_lines = _line_count(hint_text, inner_width)
- custom_row_lines = 1 if allow_custom_input else 0
- item_count = 2 + (1 if allow_custom_input else 0) + 1
- padding_lines = max(0, item_count - 1)
- min_height = (
- prompt_lines
- + max_visible
- + custom_row_lines
- + hint_lines
- + padding_lines
- + 2
- )
- return max(6, min(rows, min_height))
-
- # Overlay the dialog on top of existing content to avoid line shifts on every keypress.
- overlay = FloatContainer(
- content=Window(),
- floats=[
- Float(
- content=frame_container,
- left=0,
- right=0,
- top=0,
- height=_dialog_height,
- transparent=False,
- )
- ],
+ focus_target = (
+ custom_input_field
+ if _is_custom_selected() and custom_input_field is not None
+ else options_window
)
app = Application(
- layout=Layout(overlay, focused_element=focus_target),
- key_bindings=merge_key_bindings([load_key_bindings(), kb]),
+ layout=Layout(body, focused_element=focus_target),
+ key_bindings=kb,
full_screen=False,
style=style,
- mouse_support=True,
+ mouse_support=False,
)
try:
app.input.flush()
@@ -721,10 +802,7 @@ def _watch_ask_user_resize() -> None:
except Exception:
pass
- threading.Thread(
- target=_watch_ask_user_resize,
- daemon=True,
- ).start()
+ threading.Thread(target=_watch_ask_user_resize, daemon=True).start()
try:
while True:
@@ -739,184 +817,21 @@ def _watch_ask_user_resize() -> None:
except Exception:
selected_value = None
- # Mutate event.data so LLMSession can read the selection without changing callback return types.
- try:
- data["selected_value"] = selected_value
- except Exception:
- pass
-
- return LLMCallbackResult.CONTINUE
-
+ return _build_interaction_response(request, selected_value)
-def handle_ask_user_required_inline(shell: Any, event: LLMEvent) -> LLMCallbackResult:
- """Handle ask_user event with status bar style UI.
-
- This displays a multi-line selection UI at the bottom of the screen,
- styled like the status bar with gray background.
- """
- self = shell
-
- # Guard: check if data already has selected_value (prevents duplicate calls)
- if "selected_value" in event.data and event.data["selected_value"] is not None:
- return LLMCallbackResult.CONTINUE
-
- # Stop any active Live/animation before prompting.
- self._stop_animation()
- if self.current_live:
- try:
- self.current_live.update("", refresh=True)
- self.current_live.stop()
- finally:
- self.current_live = None
-
- self._finalize_content_preview()
+def handle_interaction_required(shell: Any, event: LLMEvent) -> LLMCallbackResult:
+ """Handle interaction event with modal UI."""
data = event.data or {}
- prompt = str(data.get("prompt") or "")
- options = data.get("options") if isinstance(data.get("options"), list) else []
- default_value = data.get("default")
- if not isinstance(default_value, str):
- default_value = None
- title = str(data.get("title") or t("shell.ask_user.title"))
- allow_cancel = bool(data.get("allow_cancel", True))
-
- # Normalize options
- values: list[tuple[str, str]] = []
- for item in options:
- if not isinstance(item, dict):
- continue
- value = item.get("value")
- label = item.get("label")
- if isinstance(value, str) and value and isinstance(label, str) and label:
- values.append((value, label))
-
- if not values:
- data["selected_value"] = None
+ request_payload = data.get("interaction_request")
+ if not isinstance(request_payload, dict):
return LLMCallbackResult.CONTINUE
+ request = InteractionRequest.from_dict(request_payload)
+ _prepare_interaction_prompt(shell)
+ response = render_interaction_modal(shell, request)
- # Set initial selection
- selected_index = 0
- if default_value:
- for i, (v, _) in enumerate(values):
- if v == default_value:
- selected_index = i
- break
-
- selected_value: str | None = None
-
- try:
- from prompt_toolkit import Application
- from prompt_toolkit.formatted_text import HTML
- from prompt_toolkit.key_binding import KeyBindings
- from prompt_toolkit.layout import HSplit, Layout, Window
- from prompt_toolkit.layout.controls import FormattedTextControl
- from prompt_toolkit.styles import Style
-
- # Flush any pending key presses
- try:
- if sys.stdin.isatty():
- termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
- except Exception:
- pass
-
- # Selection state
- state = {"index": selected_index}
-
- kb = KeyBindings()
-
- @kb.add("up", eager=True)
- def _up(event):
- state["index"] = max(0, state["index"] - 1)
- event.app.invalidate()
-
- @kb.add("down", eager=True)
- def _down(event):
- state["index"] = min(len(values) - 1, state["index"] + 1)
- event.app.invalidate()
-
- @kb.add("enter", eager=True)
- def _select(event):
- event.app.exit(result=values[state["index"]][0])
-
- if allow_cancel:
-
- @kb.add("escape", eager=True)
- def _cancel(event):
- event.app.exit(result=None)
-
- def get_content():
- """Build status bar style content for selection UI."""
- import html as html_module
- from prompt_toolkit.formatted_text import merge_formatted_text
-
- lines = []
-
- # Title line with status bar style
- title_text = title or prompt
- if title_text:
- # Escape special characters for HTML
- safe_title = html_module.escape(title_text)
- lines.append(HTML(f'\n'))
-
- # Option lines with status bar style (gray background)
- for idx, (value, label) in enumerate(values):
- # Escape special characters for HTML
- safe_label = html_module.escape(label)
- if idx == state["index"]:
- # Selected: cyan foreground, bold, with arrow
- lines.append(
- HTML(f'\n')
- )
- else:
- # Unselected: dim foreground
- lines.append(HTML(f'\n'))
-
- # Hint line with status bar style
- hint_parts = [" "]
- hint_parts.append("↑↓ Select")
- hint_parts.append(" · Enter Confirm")
- if allow_cancel:
- hint_parts.append(" · Esc Cancel")
- hint = "".join(hint_parts)
- lines.append(HTML(f''))
-
- return merge_formatted_text(lines)
-
- # Create a dynamic control that updates on each render
- control = FormattedTextControl(get_content)
- window = Window(content=control, dont_extend_height=True, style="class:status-bar")
- root = HSplit([window])
-
- # Style with status bar appearance
- style = Style.from_dict(
- {
- "status-bar": "bg:#1e1e1e",
- }
- )
-
- app = Application(
- layout=Layout(root),
- key_bindings=kb,
- full_screen=False,
- style=style,
- )
-
- try:
- app.input.flush()
- app.input.flush_keys()
- except Exception:
- pass
-
- selected_value = app.run(in_thread=True)
-
- except KeyboardInterrupt:
- raise
- except Exception:
- selected_value = None
-
- # Mutate event.data so LLMSession can read the selection
try:
- event.data["selected_value"] = selected_value
+ apply_interaction_response_to_data(data, response)
except Exception:
pass
diff --git a/src/aish/tools/ask_user.py b/src/aish/tools/ask_user.py
index 925508a..d5e7f8d 100644
--- a/src/aish/tools/ask_user.py
+++ b/src/aish/tools/ask_user.py
@@ -1,16 +1,21 @@
from __future__ import annotations
-import json
-import sys
-from typing import Callable, Optional
+from collections.abc import Callable
-from aish.i18n import t
+from aish.interaction import (
+ AskUserRequestBuilder,
+ AskUserInteractionAdapter,
+ InteractionKind,
+ InteractionRequest,
+ InteractionResponse,
+ InteractionService,
+)
from aish.tools.base import ToolBase
from aish.tools.result import ToolResult
class AskUserTool(ToolBase):
- """Ask the user to choose one option.
+ """Ask the user for structured input.
Cancellation or unavailable interactive UI MUST pause the task and ask the user
to decide how to proceed (manual selection or continue with default).
@@ -18,14 +23,18 @@ class AskUserTool(ToolBase):
def __init__(
self,
- request_choice: Callable[[dict], tuple[Optional[str], str]],
+ request_interaction: Callable[[InteractionRequest], InteractionResponse],
) -> None:
super().__init__(
name="ask_user",
description=(
"\n".join(
[
- "Ask the user to choose one option from a list.",
+ "Ask the user for one of three interaction kinds:",
+ "- single_select: choose exactly one predefined option.",
+ "- text_input: enter free-form text only.",
+ "- choice_or_text: choose a predefined option or enter custom text.",
+ "Returns structured output so callers can distinguish selected options from custom text.",
"If the UI is unavailable or the user cancels, the task will pause and require user input.",
]
)
@@ -33,13 +42,22 @@ def __init__(
parameters={
"type": "object",
"properties": {
+ "id": {
+ "type": "string",
+ "description": "Optional interaction id. If omitted, one is generated.",
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["single_select", "text_input", "choice_or_text"],
+ "description": "Interaction type: single_select, text_input, or choice_or_text.",
+ },
"prompt": {
"type": "string",
"description": "Question/description shown to the user.",
},
"options": {
"type": "array",
- "description": "List of options to choose from (must be non-empty).",
+ "description": "Predefined options. Required for single_select and choice_or_text; omit for text_input.",
"items": {
"type": "object",
"properties": {
@@ -53,230 +71,105 @@ def __init__(
},
"default": {
"type": "string",
- "description": "Default option value used when user chooses to continue with default later.",
+ "description": "Default value used when present and valid for the interaction kind.",
},
"title": {
"type": "string",
"description": "Optional UI title.",
},
+ "required": {
+ "type": "boolean",
+ "description": "Whether answering is required.",
+ "default": True,
+ },
"allow_cancel": {
"type": "boolean",
"description": "Whether user can cancel/ESC.",
"default": True,
},
- "cancel_hint": {
- "type": "string",
- "description": "Optional custom hint shown when the tool pauses due to cancel/unavailable.",
+ "metadata": {
+ "type": "object",
+ "description": "Optional metadata carried with the interaction request.",
+ "additionalProperties": True,
},
- "allow_custom_input": {
- "type": "boolean",
- "description": "Whether to allow the user to input a custom value.",
- "default": False,
- },
- "custom_label": {
+ "placeholder": {
"type": "string",
- "description": "Label for the custom input option (when allow_custom_input=true).",
+ "description": "Placeholder text for text_input, or fallback placeholder for choice_or_text custom input.",
},
- "custom_prompt": {
- "type": "string",
- "description": "Prompt shown when asking for a custom input value.",
+ "validation": {
+ "type": "object",
+ "description": "Optional validation config for text input interactions.",
+ "properties": {
+ "required": {"type": "boolean"},
+ "min_length": {"type": "integer"},
+ },
+ "additionalProperties": False,
+ },
+ "custom": {
+ "type": "object",
+ "description": "Custom text entry config for choice_or_text interactions. Do not provide this for single_select.",
+ "properties": {
+ "label": {"type": "string"},
+ "placeholder": {"type": "string"},
+ "submit_mode": {"type": "string"},
+ },
+ "additionalProperties": False,
},
},
- "required": ["prompt", "options"],
+ "required": ["kind", "prompt"],
},
)
- self._request_choice = request_choice
-
- @staticmethod
- def _normalize_options(options: object) -> list[dict[str, str]]:
- if not isinstance(options, list):
- return []
- normalized: list[dict[str, str]] = []
- for item in options:
- if not isinstance(item, dict):
- continue
- value = item.get("value")
- label = item.get("label")
- if not isinstance(value, str) or not value.strip():
- continue
- if not isinstance(label, str) or not label.strip():
- continue
- normalized.append({"value": value.strip(), "label": label.strip()})
- return normalized
-
- @staticmethod
- def _pick_default(default: object, options: list[dict[str, str]]) -> str:
- if options:
- fallback = options[0]["value"]
- else:
- fallback = ""
- if isinstance(default, str) and default in {o["value"] for o in options}:
- return default
- return fallback
-
- def _build_pause_message(
- self,
- *,
- prompt: str,
- options: list[dict[str, str]],
- default_value: str,
- reason: str,
- cancel_hint: str | None,
- allow_custom_input: bool,
- ) -> str:
- lines: list[str] = []
- lines.append(t("shell.ask_user.paused.title"))
- lines.append(t("shell.ask_user.paused.prompt", prompt=prompt))
- lines.append(t("shell.ask_user.paused.reason", reason=reason))
- lines.append(t("shell.ask_user.paused.options_header"))
- for idx, opt in enumerate(options, start=1):
- lines.append(f" {idx}. {opt['label']} ({opt['value']})")
- if allow_custom_input:
- lines.append(t("shell.ask_user.paused.custom_input"))
- if cancel_hint and str(cancel_hint).strip():
- lines.append("")
- lines.append(str(cancel_hint).strip())
- lines.append("")
- lines.append(
- t(
- "shell.ask_user.paused.how_to",
- default=default_value,
- )
+ self._interaction_service = InteractionService(
+ renderer=request_interaction
)
- lines.append("")
- # Include structured context to help the model reliably continue on the next user turn.
- context = {
- "kind": "ask_user_context",
- "prompt": prompt,
- "default": default_value,
- "options": options,
- "suggested_continue_commands": [
- "; continue with default",
- "; 使用默认继续",
- ],
- }
- lines.append("```json")
- lines.append(json.dumps(context, ensure_ascii=False))
- lines.append("```")
- return "\n".join(lines).strip()
def __call__(
self,
+ kind: str,
prompt: str,
- options: list[dict],
+ options: list[dict] | None = None,
default: str | None = None,
title: str | None = None,
+ required: bool = True,
allow_cancel: bool = True,
- cancel_hint: str | None = None,
- allow_custom_input: bool = False,
- custom_label: str | None = None,
- custom_prompt: str | None = None,
+ metadata: dict | None = None,
+ placeholder: str | None = None,
+ validation: dict | None = None,
+ custom: dict | None = None,
+ id: str | None = None,
) -> ToolResult:
- normalized_options = self._normalize_options(options)
- if not normalized_options:
+ request = AskUserRequestBuilder.from_tool_args(
+ kind=kind,
+ prompt=prompt,
+ options=options,
+ default=default,
+ title=title,
+ required=required,
+ allow_cancel=allow_cancel,
+ metadata=metadata,
+ placeholder=placeholder,
+ validation=validation,
+ custom=custom,
+ interaction_id=id,
+ )
+
+ if request.kind not in {
+ InteractionKind.SINGLE_SELECT,
+ InteractionKind.TEXT_INPUT,
+ InteractionKind.CHOICE_OR_TEXT,
+ }:
return ToolResult(
ok=False,
- output="Error: options must be a non-empty list of {value,label}.",
+ output=f"Error: unsupported ask_user kind: {request.kind.value}.",
meta={"kind": "invalid_args"},
)
- default_value = self._pick_default(default, normalized_options)
-
- # Fast fail for non-interactive environments.
- if not (sys.stdin.isatty() and sys.stdout.isatty()):
- reason = "unavailable"
- pause_text = self._build_pause_message(
- prompt=prompt,
- options=normalized_options,
- default_value=default_value,
- reason=reason,
- cancel_hint=cancel_hint,
- allow_custom_input=allow_custom_input,
- )
+ if not request.options and request.kind != InteractionKind.TEXT_INPUT:
return ToolResult(
ok=False,
- output=pause_text,
- meta={
- "kind": "user_input_required",
- "reason": reason,
- "prompt": prompt,
- "default": default_value,
- "options": normalized_options,
- },
- stop_tool_chain=True,
- )
-
- try:
- selected, status = self._request_choice(
- {
- "prompt": prompt,
- "options": normalized_options,
- "default": default_value,
- "title": title,
- "allow_cancel": bool(allow_cancel),
- "allow_custom_input": bool(allow_custom_input),
- "custom_label": custom_label,
- "custom_prompt": custom_prompt,
- }
- )
- except KeyboardInterrupt:
- raise
- except Exception:
- selected, status = None, "error"
-
- allowed_values = {o["value"] for o in normalized_options}
- if isinstance(selected, str) and selected in allowed_values:
- label_lookup = {o["value"]: o["label"] for o in normalized_options}
- label = label_lookup.get(selected, selected)
- # Return a clear, LLM-friendly message instead of JSON
- # Store the structured data in the data field for potential future use
- return ToolResult(
- ok=True,
- output=f"User selected: {label}",
- data={
- "value": selected,
- "label": label,
- "status": "selected",
- },
- )
- if (
- allow_custom_input
- and isinstance(selected, str)
- and selected.strip()
- and selected not in allowed_values
- ):
- return ToolResult(
- ok=True,
- output=f"User input: {selected}",
- data={
- "value": selected,
- "label": selected,
- "status": "custom",
- },
+ output="Error: options must be a non-empty list of {value,label} for selection interactions.",
+ meta={"kind": "invalid_args"},
)
- reason = (
- "cancelled"
- if status == "cancelled"
- else ("unavailable" if status == "unavailable" else "error")
- )
- pause_text = self._build_pause_message(
- prompt=prompt,
- options=normalized_options,
- default_value=default_value,
- reason=reason,
- cancel_hint=cancel_hint,
- allow_custom_input=allow_custom_input,
- )
- return ToolResult(
- ok=False,
- output=pause_text,
- meta={
- "kind": "user_input_required",
- "reason": reason,
- "prompt": prompt,
- "default": default_value,
- "options": normalized_options,
- },
- stop_tool_chain=True,
- )
+ response = self._interaction_service.request(request)
+ return AskUserInteractionAdapter.to_tool_result(request, response)
diff --git a/src/aish/tools/skill.py b/src/aish/tools/skill.py
index 539e045..3db747e 100644
--- a/src/aish/tools/skill.py
+++ b/src/aish/tools/skill.py
@@ -29,7 +29,7 @@
- NEVER just announce or mention a skill in your text response without actually calling this tool
- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
- Do not invoke a skill that is already running
-- If a skill requires user feedback/choice, use the `ask_user` tool. You can enable custom input via `allow_custom_input`. If the user cancels or UI is unavailable, the task pauses
+- If a skill requires user feedback/choice, use the `ask_user` tool with explicit interaction kinds: `single_select` for strict option-only choice, `text_input` for free text only, and `choice_or_text` for option-or-custom input. Use `custom` only with `choice_or_text`. If the user cancels or UI is unavailable, the task pauses
"""
diff --git a/tests/test_ask_user_tool.py b/tests/test_ask_user_tool.py
index 41b389d..67905d8 100644
--- a/tests/test_ask_user_tool.py
+++ b/tests/test_ask_user_tool.py
@@ -5,6 +5,9 @@
from aish.config import ConfigModel
from aish.context_manager import ContextManager
+from aish.interaction import (InteractionAnswer, InteractionAnswerType,
+ InteractionKind, InteractionRequest,
+ InteractionResponse, InteractionStatus)
from aish.llm import (LLMCallbackResult, LLMSession, ToolDispatchOutcome,
ToolDispatchStatus)
from aish.skills import SkillManager
@@ -18,13 +21,22 @@ def test_ask_user_tool_selected(monkeypatch):
monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
- def request_choice(data):
- assert data["prompt"] == "pick one"
- assert data["default"] == "a"
- return "b", "selected"
+ def request_interaction(request: InteractionRequest) -> InteractionResponse:
+ assert request.prompt == "pick one"
+ assert request.default == "a"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ answer=InteractionAnswer(
+ type=InteractionAnswerType.OPTION,
+ value="b",
+ label="B",
+ ),
+ )
- tool = AskUserTool(request_choice=request_choice)
+ tool = AskUserTool(request_interaction=request_interaction)
result = tool(
+ kind="single_select",
prompt="pick one",
options=[
{"value": "a", "label": "A"},
@@ -45,14 +57,19 @@ def test_ask_user_tool_cancelled_pauses(monkeypatch):
monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
- def request_choice(_data):
- return None, "cancelled"
+ def request_interaction(request: InteractionRequest) -> InteractionResponse:
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.CANCELLED,
+ reason="cancelled",
+ )
- tool = AskUserTool(request_choice=request_choice)
+ tool = AskUserTool(request_interaction=request_interaction)
result = tool(
+ kind="single_select",
prompt="pick one",
options=[
- {"value": "a", "label": "A"},
+ {"value": "a", "label": "A", "description": "Alpha option"},
{"value": "b", "label": "B"},
],
default="a",
@@ -62,15 +79,55 @@ def request_choice(_data):
assert result.meta.get("kind") == "user_input_required"
assert result.meta.get("reason") == "cancelled"
assert "continue with default" in result.output
+ assert "Alpha option" in result.output
+
+def test_ask_user_tool_preserves_option_descriptions(monkeypatch):
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
-def test_ask_user_tool_unavailable_pauses():
- # Ensure deterministic "unavailable" across environments.
- monkeypatch = pytest.MonkeyPatch()
- monkeypatch.setattr(sys.stdin, "isatty", lambda: False, raising=False)
- monkeypatch.setattr(sys.stdout, "isatty", lambda: False, raising=False)
- tool = AskUserTool(request_choice=lambda _data: ("a", "selected"))
+ captured: dict[str, object] = {}
+
+ def request_interaction(request: InteractionRequest) -> InteractionResponse:
+ captured.update(request.to_dict())
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ answer=InteractionAnswer(
+ type=InteractionAnswerType.OPTION,
+ value="a",
+ label="A",
+ ),
+ )
+
+ tool = AskUserTool(request_interaction=request_interaction)
result = tool(
+ kind="single_select",
+ prompt="pick one",
+ options=[
+ {"value": "a", "label": "A", "description": "Alpha option"},
+ {"value": "b", "label": "B"},
+ ],
+ default="a",
+ )
+
+ assert result.ok is True
+ assert captured["options"] == [
+ {"value": "a", "label": "A", "description": "Alpha option"},
+ {"value": "b", "label": "B"},
+ ]
+
+
+def test_ask_user_tool_respects_interaction_response_status():
+ tool = AskUserTool(
+ request_interaction=lambda request: InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.UNAVAILABLE,
+ reason="unavailable",
+ )
+ )
+ result = tool(
+ kind="single_select",
prompt="pick one",
options=[
{"value": "a", "label": "A"},
@@ -81,24 +138,31 @@ def test_ask_user_tool_unavailable_pauses():
assert result.ok is False
assert result.meta.get("kind") == "user_input_required"
assert result.meta.get("reason") == "unavailable"
- monkeypatch.undo()
def test_ask_user_tool_custom_input_allowed(monkeypatch):
monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
- def request_choice(_data):
- return "mango", "selected"
+ def request_interaction(request: InteractionRequest) -> InteractionResponse:
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ answer=InteractionAnswer(
+ type=InteractionAnswerType.TEXT,
+ value="mango",
+ ),
+ )
- tool = AskUserTool(request_choice=request_choice)
+ tool = AskUserTool(request_interaction=request_interaction)
result = tool(
+ kind="choice_or_text",
prompt="pick one",
options=[
{"value": "a", "label": "A"},
{"value": "b", "label": "B"},
],
- allow_custom_input=True,
+ custom={"label": "Other"},
)
assert result.ok is True
@@ -106,6 +170,120 @@ def request_choice(_data):
assert result.output == "User input: mango"
assert payload["value"] == "mango"
assert payload["status"] == "custom"
+ assert payload["answer_type"] == "text"
+
+
+def test_ask_user_tool_text_input_mode(monkeypatch):
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
+
+ def request_interaction(request: InteractionRequest) -> InteractionResponse:
+ assert request.kind == InteractionKind.TEXT_INPUT
+ assert request.options == []
+ assert request.placeholder == "Enter fruit name"
+ return InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ answer=InteractionAnswer(
+ type=InteractionAnswerType.TEXT,
+ value="dragonfruit",
+ ),
+ )
+
+ tool = AskUserTool(request_interaction=request_interaction)
+ result = tool(
+ kind="text_input",
+ prompt="Type a fruit",
+ placeholder="Enter fruit name",
+ )
+
+ assert result.ok is True
+ payload = result.data
+ assert result.output == "User input: dragonfruit"
+ assert payload["value"] == "dragonfruit"
+ assert payload["status"] == "custom"
+ assert payload["answer_type"] == "text"
+
+
+def test_request_interaction_prefers_standard_response(monkeypatch):
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
+
+ config = ConfigModel(model="test-model", api_key="test-key")
+
+ def event_callback(event):
+ event.data["interaction_response"] = {
+ "interaction_id": "interaction_1",
+ "status": "submitted",
+ "answer": {
+ "type": "option",
+ "value": "b",
+ "label": "B",
+ },
+ }
+ return LLMCallbackResult.CONTINUE
+
+ session = LLMSession(
+ config=config,
+ skill_manager=SkillManager(),
+ event_callback=event_callback,
+ )
+
+ response = session.request_interaction(
+ InteractionRequest.from_dict(
+ {
+ "id": "interaction_1",
+ "kind": "single_select",
+ "prompt": "pick one",
+ "required": True,
+ "allow_cancel": True,
+ "source": {"type": "tool", "name": "ask_user"},
+ "metadata": {},
+ "options": [
+ {"value": "a", "label": "A"},
+ {"value": "b", "label": "B"},
+ ],
+ }
+ )
+ )
+
+ assert response.status.value == "submitted"
+ assert response.answer is not None
+ assert response.answer.type == InteractionAnswerType.OPTION
+ assert response.answer.value == "b"
+
+
+def test_request_interaction_returns_cancelled_without_response(monkeypatch):
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
+ monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
+
+ config = ConfigModel(model="test-model", api_key="test-key")
+
+ def event_callback(event):
+ return LLMCallbackResult.CONTINUE
+
+ session = LLMSession(
+ config=config,
+ skill_manager=SkillManager(),
+ event_callback=event_callback,
+ )
+
+ response = session.request_interaction(
+ InteractionRequest.from_dict(
+ {
+ "id": "interaction_2",
+ "kind": "single_select",
+ "prompt": "pick one",
+ "required": True,
+ "allow_cancel": True,
+ "source": {"type": "tool", "name": "ask_user"},
+ "metadata": {},
+ "options": [{"value": "a", "label": "A"}],
+ }
+ )
+ )
+
+ assert response.status == InteractionStatus.CANCELLED
@pytest.mark.anyio
@@ -118,7 +296,10 @@ async def test_handle_tool_calls_ask_user_user_input_required_breaks(monkeypatch
{
"id": "call_1",
"type": "function",
- "function": {"name": "ask_user", "arguments": "{}"},
+ "function": {
+ "name": "ask_user",
+ "arguments": '{"kind":"single_select","prompt":"pick one","options":[{"value":"a","label":"A"}]}'
+ },
},
{
"id": "call_2",
diff --git a/tests/test_interaction_ask_user.py b/tests/test_interaction_ask_user.py
new file mode 100644
index 0000000..61736fb
--- /dev/null
+++ b/tests/test_interaction_ask_user.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+from aish.interaction import (
+ AskUserRequestBuilder,
+ AskUserInteractionAdapter,
+ InteractionAnswer,
+ InteractionAnswerType,
+ InteractionKind,
+ InteractionRequest,
+ InteractionResponse,
+ InteractionService,
+ InteractionStatus,
+ apply_interaction_response_to_data,
+)
+
+
+def test_ask_user_request_builder_builds_choice_or_text_request():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="choice_or_text",
+ prompt="pick one",
+ options=[
+ {"value": "a", "label": "A", "description": "Alpha option"},
+ {"value": "b", "label": "B"},
+ ],
+ default="missing",
+ custom={"label": "Other", "placeholder": "Type here"},
+ metadata={"cancel_hint": "custom cancel hint"},
+ )
+
+ assert request.kind == InteractionKind.CHOICE_OR_TEXT
+ assert request.default == "a"
+ assert request.custom is not None
+ assert request.custom.label == "Other"
+ assert request.custom.placeholder == "Type here"
+ assert request.options[0].description == "Alpha option"
+ assert request.metadata["cancel_hint"] == "custom cancel hint"
+
+
+def test_ask_user_request_builder_keeps_single_select_strict():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="single_select",
+ prompt="pick one",
+ options=[
+ {"value": "a", "label": "A"},
+ {"value": "b", "label": "B"},
+ ],
+ default="b",
+ placeholder="ignored",
+ custom={"label": "Other", "placeholder": "Should not appear"},
+ )
+
+ assert request.kind == InteractionKind.SINGLE_SELECT
+ assert request.default == "b"
+ assert request.custom is None
+ assert request.placeholder is None
+
+
+def test_ask_user_request_builder_builds_text_input_request():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="text_input",
+ prompt="type a fruit",
+ placeholder="Enter fruit name",
+ default="dragonfruit",
+ )
+
+ assert request.kind == InteractionKind.TEXT_INPUT
+ assert request.options == []
+ assert request.custom is None
+ assert request.default == "dragonfruit"
+ assert request.placeholder == "Enter fruit name"
+ assert request.validation is not None
+ assert request.validation.required is True
+ assert request.validation.min_length == 1
+
+
+def test_apply_interaction_response_writes_standard_payload_only():
+ data: dict[str, object] = {
+ "selected_value": "stale",
+ "custom_input": "stale",
+ }
+ response = InteractionResponse(
+ interaction_id="interaction_1",
+ status=InteractionStatus.SUBMITTED,
+ answer=InteractionAnswer(
+ type=InteractionAnswerType.TEXT,
+ value="mango",
+ ),
+ )
+
+ apply_interaction_response_to_data(data, response)
+
+ assert data["interaction_response"] == response.to_dict()
+ assert "selected_value" not in data
+ assert "custom_input" not in data
+
+
+def test_interaction_request_round_trips_via_dict():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="choice_or_text",
+ prompt="pick one",
+ options=[{"value": "a", "label": "A", "description": "Alpha option"}],
+ default="a",
+ custom={"label": "Other", "placeholder": "Type here"},
+ )
+
+ restored = InteractionRequest.from_dict(request.to_dict())
+
+ assert restored.id == request.id
+ assert restored.kind == request.kind
+ assert restored.options[0].description == "Alpha option"
+ assert restored.custom is not None
+ assert restored.custom.placeholder == "Type here"
+
+
+def test_interaction_service_delegates_to_renderer_without_tty_gate():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="single_select",
+ prompt="pick one",
+ options=[{"value": "a", "label": "A"}],
+ )
+ service = InteractionService(
+ renderer=lambda _request: InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ )
+ )
+
+ response = service.request(request)
+
+ assert response.interaction_id == request.id
+ assert response.status == InteractionStatus.SUBMITTED
+
+
+def test_ask_user_adapter_builds_pause_message_for_cancelled():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="choice_or_text",
+ prompt="pick one",
+ options=[
+ {"value": "a", "label": "A", "description": "Alpha option"},
+ {"value": "b", "label": "B"},
+ ],
+ default="a",
+ custom={"label": "Other"},
+ )
+ response = InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.CANCELLED,
+ reason="cancelled",
+ )
+
+ result = AskUserInteractionAdapter.to_tool_result(request, response)
+
+ assert result.ok is False
+ assert result.meta["kind"] == "user_input_required"
+ assert result.meta["interaction_id"] == request.id
+ assert "Alpha option" in result.output
+ assert "continue with default" in result.output
+
+
+def test_ask_user_adapter_builds_selected_tool_result():
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="single_select",
+ prompt="pick one",
+ options=[{"value": "a", "label": "A"}],
+ default="a",
+ )
+ response = InteractionResponse(
+ interaction_id=request.id,
+ status=InteractionStatus.SUBMITTED,
+ answer=InteractionAnswer(
+ type=InteractionAnswerType.OPTION,
+ value="a",
+ label="A",
+ ),
+ )
+
+ result = AskUserInteractionAdapter.to_tool_result(request, response)
+
+ assert result.ok is True
+ assert result.output == "User selected: A"
+ assert result.data["interaction_id"] == request.id
+ assert result.meta["interaction_status"] == "submitted"
\ No newline at end of file
diff --git a/tests/test_shell.py b/tests/test_shell.py
index d0d6eb4..99fd95c 100644
--- a/tests/test_shell.py
+++ b/tests/test_shell.py
@@ -12,6 +12,8 @@
from aish.config import ConfigModel
from aish.context_manager import MemoryType
+from aish.interaction import AskUserRequestBuilder
+from aish.llm import LLMCallbackResult, LLMEvent, LLMEventType
from aish.providers.openai_codex import OPENAI_CODEX_PROVIDER_ADAPTER
from aish.security.security_manager import SecurityDecision
from aish.security.security_policy import RiskLevel
@@ -85,6 +87,40 @@ def test_init_with_custom_model(self):
shell = make_shell(config)
assert shell.llm_session.model == "gpt-4"
+ @pytest.mark.parametrize(
+ ("kind", "custom"),
+ [
+ ("single_select", None),
+ ("choice_or_text", {"label": "Other"}),
+ ],
+ )
+ def test_handle_interaction_required_uses_single_prompt_ui(self, kind, custom):
+ config = ConfigModel(model="test-model")
+ shell = make_shell(config)
+ request = AskUserRequestBuilder.from_tool_args(
+ kind=kind,
+ prompt="pick one",
+ options=[
+ {"value": "a", "label": "A"},
+ {"value": "b", "label": "B"},
+ ],
+ custom=custom,
+ )
+ event = LLMEvent(
+ event_type=LLMEventType.INTERACTION_REQUIRED,
+ data={"interaction_request": request.to_dict()},
+ timestamp=0.0,
+ )
+
+ with patch(
+ "aish.shell._prompt_handle_interaction_required",
+ return_value=LLMCallbackResult.CONTINUE,
+ ) as mock_modal:
+ result = shell.handle_interaction_required(event)
+
+ assert result == LLMCallbackResult.CONTINUE
+ mock_modal.assert_called_once_with(shell, event)
+
@pytest.mark.asyncio
async def test_model_command_show_current(self):
"""/model should show current model"""
diff --git a/tests/test_shell_llm_event_router.py b/tests/test_shell_llm_event_router.py
index f306054..35e0175 100644
--- a/tests/test_shell_llm_event_router.py
+++ b/tests/test_shell_llm_event_router.py
@@ -27,18 +27,18 @@ def test_router_uses_handler_for_confirmation_event():
def test_router_uses_handler_for_ask_user_event():
router = LLMEventRouter(
{
- LLMEventType.ASK_USER_REQUIRED: lambda _event: LLMCallbackResult.CANCEL,
+ LLMEventType.INTERACTION_REQUIRED: lambda _event: LLMCallbackResult.CANCEL,
}
)
- result = router.handle(make_event(LLMEventType.ASK_USER_REQUIRED))
+ result = router.handle(make_event(LLMEventType.INTERACTION_REQUIRED))
assert result == LLMCallbackResult.CANCEL
def test_router_ignores_non_callback_result_for_ask_user():
router = LLMEventRouter(
{
- LLMEventType.ASK_USER_REQUIRED: lambda _event: "ignored",
+ LLMEventType.INTERACTION_REQUIRED: lambda _event: "ignored",
}
)
- result = router.handle(make_event(LLMEventType.ASK_USER_REQUIRED))
+ result = router.handle(make_event(LLMEventType.INTERACTION_REQUIRED))
assert result == LLMCallbackResult.CONTINUE
diff --git a/tests/test_shell_prompt_io.py b/tests/test_shell_prompt_io.py
index f125270..f5dd769 100644
--- a/tests/test_shell_prompt_io.py
+++ b/tests/test_shell_prompt_io.py
@@ -6,10 +6,11 @@
from rich.console import Console
+from aish.interaction import AskUserRequestBuilder
from aish.llm import LLMCallbackResult, LLMEvent, LLMEventType
from aish.shell_enhanced.shell_prompt_io import (
display_security_panel,
- handle_ask_user_required,
+ handle_interaction_required,
handle_tool_confirmation_required,
)
@@ -50,21 +51,65 @@ def _is_ui_resize_enabled(self) -> bool:
return False
-def test_handle_ask_user_required_sets_selected_value():
+def test_handle_interaction_required_sets_interaction_response():
shell = _DummyShell()
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="choice_or_text",
+ prompt="Pick one",
+ options=[
+ {"value": "opt1", "label": "Option 1"},
+ {"value": "opt2", "label": "Option 2"},
+ ],
+ default="opt1",
+ custom={"label": "Other", "placeholder": "This is intentionally very long to avoid squeezing input space"},
+ )
event = LLMEvent(
- event_type=LLMEventType.ASK_USER_REQUIRED,
- data={
- "prompt": "Pick one",
- "options": [
- {"value": "opt1", "label": "Option 1"},
- {"value": "opt2", "label": "Option 2"},
- ],
- "default": "opt1",
- "allow_cancel": True,
- "allow_custom_input": True,
- "custom_prompt": "This is intentionally very long to avoid squeezing input space",
- },
+ event_type=LLMEventType.INTERACTION_REQUIRED,
+ data={"interaction_request": request.to_dict()},
+ timestamp=time.time(),
+ )
+
+ class _DummyApp:
+ def __init__(self, *args, **kwargs) -> None:
+ class _Input:
+ @staticmethod
+ def flush() -> None:
+ return
+
+ @staticmethod
+ def flush_keys() -> None:
+ return
+
+ self.input = _Input()
+
+ def run(self, in_thread: bool = True) -> str:
+ _ = in_thread
+ return "opt2"
+
+ with patch("prompt_toolkit.Application", _DummyApp):
+ result = handle_interaction_required(shell, event)
+
+ assert result == LLMCallbackResult.CONTINUE
+ response_payload = event.data.get("interaction_response")
+ assert isinstance(response_payload, dict)
+ assert response_payload.get("interaction_id") == request.id
+ assert response_payload.get("answer", {}).get("value") == "opt2"
+
+
+def test_handle_interaction_required_reads_interaction_request_payload():
+ shell = _DummyShell()
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="single_select",
+ prompt="Pick one",
+ options=[
+ {"value": "opt1", "label": "Option 1"},
+ {"value": "opt2", "label": "Option 2", "description": "Second option"},
+ ],
+ default="opt1",
+ )
+ event = LLMEvent(
+ event_type=LLMEventType.INTERACTION_REQUIRED,
+ data={"interaction_request": request.to_dict()},
timestamp=time.time(),
)
@@ -86,10 +131,105 @@ def run(self, in_thread: bool = True) -> str:
return "opt2"
with patch("prompt_toolkit.Application", _DummyApp):
- result = handle_ask_user_required(shell, event)
+ result = handle_interaction_required(shell, event)
+
+ assert result == LLMCallbackResult.CONTINUE
+ response_payload = event.data.get("interaction_response")
+ assert isinstance(response_payload, dict)
+ assert response_payload.get("interaction_id") == request.id
+ assert response_payload.get("status") == "submitted"
+
+
+def test_handle_interaction_required_supports_text_input():
+ shell = _DummyShell()
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="text_input",
+ prompt="Type a fruit",
+ placeholder="Enter fruit name",
+ )
+ event = LLMEvent(
+ event_type=LLMEventType.INTERACTION_REQUIRED,
+ data={"interaction_request": request.to_dict()},
+ timestamp=time.time(),
+ )
+
+ class _DummyApp:
+ def __init__(self, *args, **kwargs) -> None:
+ class _Input:
+ @staticmethod
+ def flush() -> None:
+ return
+
+ @staticmethod
+ def flush_keys() -> None:
+ return
+
+ self.input = _Input()
+
+ def run(self, in_thread: bool = True) -> str:
+ _ = in_thread
+ return "dragonfruit"
+
+ with patch("prompt_toolkit.Application", _DummyApp):
+ result = handle_interaction_required(shell, event)
+
+ assert result == LLMCallbackResult.CONTINUE
+ response_payload = event.data.get("interaction_response")
+ assert isinstance(response_payload, dict)
+ assert response_payload.get("interaction_id") == request.id
+ assert response_payload.get("answer", {}).get("type") == "text"
+ assert response_payload.get("answer", {}).get("value") == "dragonfruit"
+
+
+def test_handle_interaction_required_prefills_text_input_default():
+ shell = _DummyShell()
+ request = AskUserRequestBuilder.from_tool_args(
+ kind="text_input",
+ prompt="Type a fruit",
+ placeholder="Enter fruit name",
+ default="kiwi",
+ )
+ event = LLMEvent(
+ event_type=LLMEventType.INTERACTION_REQUIRED,
+ data={"interaction_request": request.to_dict()},
+ timestamp=time.time(),
+ )
+
+ class _CapturingBuffer:
+ instances: list["_CapturingBuffer"] = []
+
+ def __init__(self, *args, **kwargs) -> None:
+ _ = args, kwargs
+ self.text = ""
+ self.__class__.instances.append(self)
+
+ class _DummyApp:
+ def __init__(self, *args, **kwargs) -> None:
+ class _Input:
+ @staticmethod
+ def flush() -> None:
+ return
+
+ @staticmethod
+ def flush_keys() -> None:
+ return
+
+ self.input = _Input()
+
+ def run(self, in_thread: bool = True) -> str:
+ _ = in_thread
+ return _CapturingBuffer.instances[-1].text
+
+ with patch("prompt_toolkit.buffer.Buffer", _CapturingBuffer), patch(
+ "prompt_toolkit.Application", _DummyApp
+ ):
+ result = handle_interaction_required(shell, event)
assert result == LLMCallbackResult.CONTINUE
- assert event.data.get("selected_value") == "opt2"
+ response_payload = event.data.get("interaction_response")
+ assert isinstance(response_payload, dict)
+ assert response_payload.get("interaction_id") == request.id
+ assert response_payload.get("answer", {}).get("value") == "kiwi"
def test_display_security_panel_shows_fallback_rule_details(monkeypatch):