feat(question): implement AskUserQuestion tool support

- Add question card builder and answer handling in feishu.py
- Extend SDKSession with pending question state and answer method
- Update card callback handler to support question answers
- Add test cases for question flow and card responses
- Document usage with test_can_use_tool_ask.py example
This commit is contained in:
Yuyao Huang 2026-04-02 08:52:50 +08:00
parent 26746335c4
commit 72ebf3b75d
8 changed files with 542 additions and 120 deletions

View File

@ -18,6 +18,7 @@ When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**:
- `test_query_read_edit.py``query()` one-shot: Read + Edit with `allowed_tools` - `test_query_read_edit.py``query()` one-shot: Read + Edit with `allowed_tools`
- `test_client_write_resume.py``ClaudeSDKClient`: Write + session resume via `resume=session_id` - `test_client_write_resume.py``ClaudeSDKClient`: Write + session resume via `resume=session_id`
- `test_hooks_audit_deny.py` — Hooks: `PostToolUse` audit + `PreToolUse` deny - `test_hooks_audit_deny.py` — Hooks: `PostToolUse` audit + `PreToolUse` deny
- `test_can_use_tool_ask.py``can_use_tool`: intercept `AskUserQuestion`, pre-fill answers via `updated_input`
### Key rules (verified by testing) ### Key rules (verified by testing)

View File

@ -162,6 +162,12 @@ class SessionManager:
if session and session.sdk_session: if session and session.sdk_session:
await session.sdk_session.approve(approved) await session.sdk_session.approve(approved)
async def answer_question(self, conv_id: str, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's answers."""
session = self._sessions.get(conv_id)
if session and session.sdk_session:
await session.sdk_session.answer_question(answers)
# --- Close, list, permission --- # --- Close, list, permission ---
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool: async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:

View File

@ -48,6 +48,7 @@ class SessionProgress:
last_result: str = "" last_result: str = ""
error: str = "" error: str = ""
pending_approval: str = "" # non-empty → waiting for approval, value is tool description pending_approval: str = "" # non-empty → waiting for approval, value is tool description
pending_question: dict | None = None # non-None → waiting for user answer to AskUserQuestion
class SDKSession: class SDKSession:
@ -97,6 +98,10 @@ class SDKSession:
self._pending_approval: asyncio.Future | None = None self._pending_approval: asyncio.Future | None = None
self._pending_approval_desc: str = "" self._pending_approval_desc: str = ""
# AskUserQuestion mechanism
self._pending_question: asyncio.Future | None = None
self._pending_question_data: dict | None = None # {questions: [...], conv_id: ...}
async def start(self) -> None: async def start(self) -> None:
"""Create and connect the ClaudeSDKClient, start the message loop.""" """Create and connect the ClaudeSDKClient, start the message loop."""
from agent.sdk_hooks import build_hooks from agent.sdk_hooks import build_hooks
@ -169,6 +174,7 @@ class SDKSession:
last_result=self._last_result[:1000], last_result=self._last_result[:1000],
error=self._error, error=self._error,
pending_approval=self._pending_approval_desc, pending_approval=self._pending_approval_desc,
pending_question=self._pending_question_data,
) )
async def interrupt(self) -> None: async def interrupt(self) -> None:
@ -189,6 +195,15 @@ class SDKSession:
if self._pending_approval and not self._pending_approval.done(): if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result(approved) self._pending_approval.set_result(approved)
async def answer_question(self, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's selected answers.
Args:
answers: maps question text selected option label.
"""
if self._pending_question and not self._pending_question.done():
self._pending_question.set_result(answers)
async def close(self) -> None: async def close(self) -> None:
"""Disconnect and clean up.""" """Disconnect and clean up."""
if self._message_loop_task and not self._message_loop_task.done(): if self._message_loop_task and not self._message_loop_task.done():
@ -280,15 +295,64 @@ class SDKSession:
async def _permission_callback( async def _permission_callback(
self, tool_name: str, input_data: dict, context: ToolPermissionContext self, tool_name: str, input_data: dict, context: ToolPermissionContext
) -> PermissionResult: ) -> PermissionResult:
"""can_use_tool — send approval card to Feishu, wait for card callback.""" """can_use_tool — route to question card or approval card based on tool type."""
# Auto-allow read-only tools # Auto-allow read-only tools
if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"): if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"):
return PermissionResultAllow() return PermissionResultAllow()
# AskUserQuestion: show options to user, collect answer, return via updated_input
if tool_name == "AskUserQuestion":
return await self._handle_ask_user_question(input_data)
if not self.chat_id: if not self.chat_id:
return PermissionResultAllow() return PermissionResultAllow()
# Send approval card # Regular tools: approval flow
return await self._handle_tool_approval(tool_name, input_data)
async def _handle_ask_user_question(self, input_data: dict) -> PermissionResult:
"""Handle AskUserQuestion: send question card, wait for answer, return updated_input."""
if not self.chat_id:
return PermissionResultAllow()
questions = input_data.get("questions", [])
if not questions:
return PermissionResultAllow()
from bot.feishu import send_card, build_question_card
# Build and send question card
self._pending_question_data = {"questions": questions, "conv_id": self.conv_id}
card = build_question_card(
conv_id=self.conv_id,
questions=questions,
)
await send_card(self.chat_id, "chat_id", card)
# Wait for user's answer (via card callback or text reply)
loop = asyncio.get_running_loop()
self._pending_question = loop.create_future()
try:
answers = await asyncio.wait_for(
self._pending_question, timeout=APPROVAL_TIMEOUT
)
except asyncio.TimeoutError:
answers = {}
from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 问题超时,已跳过。")
finally:
self._pending_question_data = None
# Pre-fill answers in the tool input
modified_input = dict(input_data)
if "answers" not in modified_input or not isinstance(modified_input.get("answers"), dict):
modified_input["answers"] = {}
modified_input["answers"].update(answers)
return PermissionResultAllow(updated_input=modified_input)
async def _handle_tool_approval(self, tool_name: str, input_data: dict) -> PermissionResult:
"""Handle regular tool approval: send approval card, wait for approve/deny."""
from bot.feishu import send_card, build_approval_card from bot.feishu import send_card, build_approval_card
summary = self._format_tool_summary(tool_name, input_data) summary = self._format_tool_summary(tool_name, input_data)
@ -302,7 +366,6 @@ class SDKSession:
) )
await send_card(self.chat_id, "chat_id", card) await send_card(self.chat_id, "chat_id", card)
# Wait for card callback or text reply y/n
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
self._pending_approval = loop.create_future() self._pending_approval = loop.create_future()
try: try:
@ -312,18 +375,14 @@ class SDKSession:
except asyncio.TimeoutError: except asyncio.TimeoutError:
approved = False approved = False
from bot.feishu import send_markdown from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 审批超时,已自动拒绝。") await send_markdown(self.chat_id, "chat_id", "⏰ 审批超时,已自动拒绝。")
finally: finally:
self._pending_approval_desc = "" self._pending_approval_desc = ""
from agent.audit import log_permission_decision from agent.audit import log_permission_decision
log_permission_decision( log_permission_decision(
conv_id=self.conv_id, conv_id=self.conv_id, tool_name=tool_name,
tool_name=tool_name, tool_input=input_data, approved=approved,
tool_input=input_data,
approved=approved,
) )
if approved: if approved:
return PermissionResultAllow() return PermissionResultAllow()

View File

@ -190,6 +190,64 @@ def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int
} }
def build_question_card(conv_id: str, questions: list[dict]) -> dict:
"""Build a question card for AskUserQuestion (schema 2.0).
Each question's options become buttons. The first question is shown
prominently; multi-question support shows them sequentially.
"""
elements: list[dict] = []
for i, q in enumerate(questions):
question_text = q.get("question", "")
header = q.get("header", "")
options = q.get("options", [])
if header:
elements.append({
"tag": "markdown",
"content": f"**{header}**\n{question_text}",
})
else:
elements.append({
"tag": "markdown",
"content": f"**{question_text}**",
})
for opt in options:
label = opt.get("label", "")
desc = opt.get("description", "")
button_text = f"{label}{desc}" if desc else label
# Truncate long button text
if len(button_text) > 60:
button_text = button_text[:57] + "..."
elements.append({
"tag": "button",
"text": {"tag": "plain_text", "content": button_text},
"type": "default",
"value": {
"action": "answer_question",
"conv_id": conv_id,
"question": question_text,
"answer": label,
},
})
elements.append({
"tag": "div",
"text": {"tag": "plain_text", "content": "也可直接输入文字回复"},
})
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
"template": "blue",
},
"body": {"elements": elements},
}
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None: async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
""" """
Upload a local file to Feishu and send it as a file message. Upload a local file to Feishu and send it as a file message.

View File

@ -148,6 +148,27 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
await send_text(chat_id, "chat_id", label) await send_text(chat_id, "chat_id", label)
return return
# Text answer fallback: any text reply when a question is pending
from orchestrator.agent import agent as _agent
from agent.manager import manager as _manager
conv_id = _agent.get_active_conv(user_id)
if conv_id:
session = _manager._sessions.get(conv_id)
if (
session
and session.sdk_session
and session.sdk_session._pending_question
and not session.sdk_session._pending_question.done()
and session.sdk_session._pending_question_data
):
# Use text as answer to the first pending question
questions = session.sdk_session._pending_question_data.get("questions", [])
if questions:
q_text = questions[0].get("question", "")
await _manager.answer_question(conv_id, {q_text: text.strip()})
await send_text(chat_id, "chat_id", f"✅ 已回答: {text.strip()}")
return
from config import ROUTER_MODE from config import ROUTER_MODE
if ROUTER_MODE: if ROUTER_MODE:
from router.nodes import get_node_registry from router.nodes import get_node_registry
@ -215,77 +236,108 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
logger.info("RAW CustomizedEvent: %s", marshaled[:500]) logger.info("RAW CustomizedEvent: %s", marshaled[:500])
def _handle_card_action(data: lark.CustomizedEvent) -> dict | None: def _handle_card_action(data: "P2CardActionTrigger") -> "P2CardActionTriggerResponse":
"""Handle Feishu card button clicks (approval approve/deny). """Handle Feishu card button clicks via register_p2_card_action_trigger.
Per docs/feishu/card_callback_communication.md: Per docs/feishu/card_callback_communication.md:
- Must respond within 3 seconds - Must respond within 3 seconds
- Return toast + updated card to give user visual feedback - Return P2CardActionTriggerResponse with toast + updated card
""" """
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard, CallBackToast, P2CardActionTriggerResponse,
)
def _response(toast_type: str, toast_text: str, card_data: dict) -> P2CardActionTriggerResponse:
resp = P2CardActionTriggerResponse()
toast = CallBackToast()
toast.type = toast_type
toast.content = toast_text
resp.toast = toast
card = CallBackCard()
card.type = "raw"
card.data = card_data
resp.card = card
return resp
def _empty_response() -> P2CardActionTriggerResponse:
return P2CardActionTriggerResponse()
try: try:
marshaled = lark.JSON.marshal(data) event = data.event
if not marshaled: if not event:
return None return _empty_response()
payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled action = event.action
event = payload.get("event", {}) if not action:
action = event.get("action", {}) return _empty_response()
value = action.get("value", {})
action_type = value.get("action") # "approve" or "deny" value: dict = action.value or {}
action_type = value.get("action")
conv_id = value.get("conv_id") conv_id = value.get("conv_id")
if not action_type or not conv_id: if not action_type or not conv_id:
logger.debug("Card action without action/conv_id: %s", value) logger.debug("Card action without action/conv_id: %s", value)
return None return _empty_response()
approved = action_type == "approve" operator_open_id = (event.operator.open_id or "") if event.operator else ""
operator_open_id = event.get("operator", {}).get("open_id", "")
logger.info( logger.info(
"Card action: %s for session %s by %s", "Card action: %s for session %s by ...%s",
action_type, conv_id, operator_open_id[-8:], action_type, conv_id, operator_open_id[-8:],
) )
# Dispatch approval to SDKSession (async, fire-and-forget) # --- AskUserQuestion answer ---
if action_type == "answer_question":
question = value.get("question", "")
answer = value.get("answer", "")
if _main_loop:
asyncio.run_coroutine_threadsafe(
_handle_question_answer_async(conv_id, question, answer), _main_loop
)
return _response(
"success", f"已选择: {answer}",
{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
"template": "green",
},
"body": {
"elements": [
{"tag": "markdown", "content": f"**{question}**\n\n✅ 已选择: **{answer}**"},
],
},
},
)
# --- Tool approval ---
approved = action_type == "approve"
if _main_loop: if _main_loop:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
_handle_approval_async(conv_id, approved), _main_loop _handle_approval_async(conv_id, approved), _main_loop
) )
# Respond to callback within 3s: toast + updated card showing result
if approved: if approved:
toast_type, toast_text = "success", "✅ 已批准" toast_type, toast_text = "success", "✅ 已批准"
card_status = "✅ **已批准**" card_status, template = "✅ **已批准**", "green"
template = "green"
else: else:
toast_type, toast_text = "warning", "❌ 已拒绝" toast_type, toast_text = "warning", "❌ 已拒绝"
card_status = "❌ **已拒绝**" card_status, template = "❌ **已拒绝**", "red"
template = "red"
return { return _response(
"toast": { toast_type, toast_text,
"type": toast_type, {
"content": toast_text, "schema": "2.0",
}, "header": {
"card": { "title": {"tag": "plain_text", "content": "🔐 权限审批"},
"type": "raw", "template": template,
"data": {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": template,
},
"body": {
"elements": [
{"tag": "markdown", "content": card_status},
],
},
}, },
"body": {"elements": [{"tag": "markdown", "content": card_status}]},
}, },
} )
except Exception: except Exception:
logger.exception("Error handling card action") logger.exception("Error handling card action")
return None return P2CardActionTriggerResponse()
async def _handle_approval_async(conv_id: str, approved: bool) -> None: async def _handle_approval_async(conv_id: str, approved: bool) -> None:
@ -294,13 +346,19 @@ async def _handle_approval_async(conv_id: str, approved: bool) -> None:
await manager.approve(conv_id, approved) await manager.approve(conv_id, approved)
async def _handle_question_answer_async(conv_id: str, question: str, answer: str) -> None:
"""Process a question answer from card callback."""
from agent.manager import manager
await manager.answer_question(conv_id, {question: answer})
def build_event_handler() -> lark.EventDispatcherHandler: def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks.""" """Construct the EventDispatcherHandler with all registered callbacks."""
handler = ( handler = (
lark.EventDispatcherHandler.builder("", "") lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message) .register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any) .register_p1_customized_event("im.message.receive_v1", _handle_any)
.register_p1_customized_event("card.action.trigger", _handle_card_action) .register_p2_card_action_trigger(_handle_card_action)
.build() .build()
) )
return handler return handler

View File

@ -0,0 +1,160 @@
"""Example: can_use_tool callback — intercept AskUserQuestion and provide answers.
Demonstrates:
- can_use_tool callback for tool permission control
- Detecting AskUserQuestion tool calls
- Using PermissionResultAllow(updated_input=...) to pre-fill user answers
- Auto-allowing read-only tools
This pattern is used by the secretary model to forward CC questions
to Feishu users and relay their responses back.
Usage:
cd docs/claude
../../.venv/Scripts/python test_can_use_tool_ask.py
"""
from __future__ import annotations
import asyncio
import json
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
# Simulated user responses (in production, these come from Feishu card callbacks)
SIMULATED_ANSWERS: dict[str, str] = {}
permission_log: list[dict] = []
async def permission_callback(tool_name, input_data, context):
"""can_use_tool callback that intercepts AskUserQuestion.
For AskUserQuestion:
- Extracts questions and options from input_data
- Provides pre-filled answers via updated_input
- Returns PermissionResultAllow with the modified input
For other tools:
- Read-only tools: auto-allow
- Write tools: auto-allow (for testing)
"""
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
permission_log.append({
"tool": tool_name,
"input_keys": list(input_data.keys()),
})
if tool_name == "AskUserQuestion":
questions = input_data.get("questions", [])
print(f"\n 🔔 AskUserQuestion intercepted! ({len(questions)} questions)")
# Build answers dict: {question_text: selected_option_label}
answers = {}
for q in questions:
question_text = q.get("question", "")
options = q.get("options", [])
multi = q.get("multiSelect", False)
print(f" Q: {question_text}")
for opt in options:
print(f" - {opt['label']}: {opt.get('description', '')[:60]}")
# In production: send card to Feishu, wait for user selection
# Here: use first option as simulated answer
if options:
selected = SIMULATED_ANSWERS.get(question_text, options[0]["label"])
answers[question_text] = selected
print(f" → Selected: {selected}")
# Pre-fill answers in the tool input via updated_input
modified_input = dict(input_data)
if "answers" not in modified_input:
modified_input["answers"] = {}
if isinstance(modified_input["answers"], dict):
modified_input["answers"].update(answers)
print(f" → updated_input.answers = {answers}")
return PermissionResultAllow(updated_input=modified_input)
# Auto-allow everything else for this test
return PermissionResultAllow()
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
SystemMessage,
TextBlock,
ToolUseBlock,
)
tmpdir = make_tmpdir("ask-test-")
try:
print("--- Test: can_use_tool intercepts AskUserQuestion ---")
print(f" tmpdir: {tmpdir}")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
permission_mode="default",
can_use_tool=permission_callback,
max_turns=5,
env=auth_env(),
)
# Ask Claude to ask the user a question — this will trigger AskUserQuestion
prompt = (
"I need you to ask the user a question using the AskUserQuestion tool. "
"Ask them: 'Which programming language do you prefer?' "
"with options: 'Python' (great for AI), 'TypeScript' (great for web). "
"Then tell me what they chose."
)
print(f"\n Prompt: {prompt[:100]}...")
async with ClaudeSDKClient(opts) as client:
await client.query(prompt)
ask_seen = False
result_text = ""
async for msg in client.receive_response():
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(block, ToolUseBlock):
if block.name == "AskUserQuestion":
ask_seen = True
print(f" [ToolUse] AskUserQuestion called!")
else:
print(f" [ToolUse] {block.name}")
elif isinstance(msg, ResultMessage):
result_text = msg.result or ""
print(f"\n Result: {result_text[:300]}")
print(f"\n Permission log ({len(permission_log)} entries):")
for entry in permission_log:
print(f" {entry['tool']}: keys={entry['input_keys']}")
if ask_seen:
print("\n ✅ PASS: AskUserQuestion was intercepted by can_use_tool")
else:
print("\n ⚠️ AskUserQuestion was not called (Claude may have answered directly)")
print(" This is OK — it means Claude didn't need to ask.")
return 0
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -41,11 +41,16 @@ def feishu_calls():
# ── Singleton state resets ─────────────────────────────────────────────────── # ── Singleton state resets ───────────────────────────────────────────────────
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_manager(): def reset_manager(tmp_path):
from agent.manager import manager from agent.manager import manager
import agent.manager as mgr_mod
# Redirect persistence to tmp_path
original_file = mgr_mod.PERSISTENCE_FILE
mgr_mod.PERSISTENCE_FILE = tmp_path / "sessions.json"
manager._sessions.clear() manager._sessions.clear()
yield yield
manager._sessions.clear() manager._sessions.clear()
mgr_mod.PERSISTENCE_FILE = original_file
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -71,8 +76,12 @@ def reset_task_runner():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_scheduler(): def reset_scheduler(tmp_path):
from agent.scheduler import scheduler from agent.scheduler import scheduler
import agent.scheduler as sched_mod
# Redirect persistence to tmp_path so tests don't pollute production data
original_file = sched_mod.PERSISTENCE_FILE
sched_mod.PERSISTENCE_FILE = tmp_path / "scheduled_jobs.json"
for task in list(getattr(scheduler, "_tasks", {}).values()): for task in list(getattr(scheduler, "_tasks", {}).values()):
task.cancel() task.cancel()
scheduler._jobs.clear() scheduler._jobs.clear()
@ -80,6 +89,7 @@ def reset_scheduler():
for task in list(getattr(scheduler, "_tasks", {}).values()): for task in list(getattr(scheduler, "_tasks", {}).values()):
task.cancel() task.cancel()
scheduler._jobs.clear() scheduler._jobs.clear()
sched_mod.PERSISTENCE_FILE = original_file
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@ -787,83 +787,153 @@ class TestBuildApprovalCard:
class TestCardCallbackResponse: class TestCardCallbackResponse:
"""Test _handle_card_action returns proper callback response per """Test _handle_card_action returns proper P2CardActionTriggerResponse per
docs/feishu/card_callback_communication.md.""" docs/feishu/card_callback_communication.md."""
def _make_card_event(self, action: str, conv_id: str) -> object: def _make_trigger(self, action: str, conv_id: str, **extra) -> "P2CardActionTrigger":
"""Create a mock CustomizedEvent with card action payload.""" from lark_oapi.event.callback.model.p2_card_action_trigger import (
import lark_oapi as lark CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
mock_event = MagicMock() )
payload = json.dumps({ value = {"action": action, "conv_id": conv_id, **extra}
"schema": "2.0", act = CallBackAction()
"header": { act.value = value
"event_id": "evt_123", act.tag = "button"
"event_type": "card.action.trigger", op = CallBackOperator()
}, op.open_id = "ou_test_user"
"event": { data = P2CardActionTriggerData()
"operator": { data.action = act
"open_id": "ou_test_user_123", data.operator = op
}, trigger = P2CardActionTrigger()
"action": { trigger.event = data
"value": {"action": action, "conv_id": conv_id}, return trigger
"tag": "button",
},
},
})
# lark.JSON.marshal returns the JSON string
with patch("lark_oapi.JSON.marshal", return_value=payload):
yield payload
def test_approve_returns_toast_and_card(self): def test_approve_returns_toast_and_card(self):
from bot.handler import _handle_card_action from bot.handler import _handle_card_action
payload = json.dumps({ trigger = self._make_trigger("approve", "c1")
"event": { with patch("bot.handler._main_loop", new=MagicMock()):
"operator": {"open_id": "ou_test"}, resp = _handle_card_action(trigger)
"action": {"value": {"action": "approve", "conv_id": "c1"}, "tag": "button"}, assert resp.toast is not None
}, assert resp.toast.type == "success"
}) assert "批准" in resp.toast.content
with patch("lark_oapi.JSON.marshal", return_value=payload), \ assert resp.card is not None
patch("bot.handler._main_loop", new=MagicMock()): assert resp.card.type == "raw"
result = _handle_card_action(MagicMock()) assert resp.card.data["header"]["template"] == "green"
assert result is not None
# Toast
assert result["toast"]["type"] == "success"
assert "批准" in result["toast"]["content"]
# Updated card
assert result["card"]["type"] == "raw"
card_data = result["card"]["data"]
assert card_data["schema"] == "2.0"
assert card_data["header"]["template"] == "green"
assert "批准" in card_data["body"]["elements"][0]["content"]
def test_deny_returns_warning_toast(self): def test_deny_returns_warning_toast(self):
from bot.handler import _handle_card_action from bot.handler import _handle_card_action
payload = json.dumps({ trigger = self._make_trigger("deny", "c1")
"event": { with patch("bot.handler._main_loop", new=MagicMock()):
"operator": {"open_id": "ou_test"}, resp = _handle_card_action(trigger)
"action": {"value": {"action": "deny", "conv_id": "c1"}, "tag": "button"}, assert resp.toast.type == "warning"
}, assert "拒绝" in resp.toast.content
}) assert resp.card.data["header"]["template"] == "red"
with patch("lark_oapi.JSON.marshal", return_value=payload), \
patch("bot.handler._main_loop", new=MagicMock()):
result = _handle_card_action(MagicMock())
assert result is not None def test_missing_value_returns_empty_response(self):
assert result["toast"]["type"] == "warning"
assert "拒绝" in result["toast"]["content"]
assert result["card"]["data"]["header"]["template"] == "red"
def test_missing_value_returns_none(self):
from bot.handler import _handle_card_action from bot.handler import _handle_card_action
payload = json.dumps({ from lark_oapi.event.callback.model.p2_card_action_trigger import (
"event": { CallBackAction, P2CardActionTrigger, P2CardActionTriggerData,
"action": {"value": {}, "tag": "button"}, )
}, act = CallBackAction()
}) act.value = {} # no action/conv_id
with patch("lark_oapi.JSON.marshal", return_value=payload): data = P2CardActionTriggerData()
result = _handle_card_action(MagicMock()) data.action = act
assert result is None trigger = P2CardActionTrigger()
trigger.event = data
resp = _handle_card_action(trigger)
assert resp.toast is None
assert resp.card is None
def test_answer_question_returns_success_toast(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger(
"answer_question", "c1",
question="Which lang?", answer="Python"
)
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "success"
assert "Python" in resp.toast.content
assert "Python" in resp.card.data["body"]["elements"][0]["content"]
# ===========================================================================
# 8c. AskUserQuestion flow
# ===========================================================================
class TestAskUserQuestion:
def test_build_question_card(self):
from bot.feishu import build_question_card
questions = [
{
"question": "Which language?",
"header": "Language",
"options": [
{"label": "Python", "description": "Great for AI"},
{"label": "TypeScript", "description": "Great for web"},
],
"multiSelect": False,
}
]
card = build_question_card("c1", questions)
assert card["schema"] == "2.0"
assert "提问" in card["header"]["title"]["content"]
elements = card["body"]["elements"]
buttons = [e for e in elements if e["tag"] == "button"]
assert len(buttons) == 2
assert buttons[0]["value"]["action"] == "answer_question"
assert buttons[0]["value"]["question"] == "Which language?"
assert buttons[0]["value"]["answer"] == "Python"
assert buttons[1]["value"]["answer"] == "TypeScript"
@pytest.mark.asyncio
async def test_answer_question_resolves_future(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_question = loop.create_future()
await s.answer_question({"Which language?": "Python"})
assert s._pending_question.done()
assert s._pending_question.result() == {"Which language?": "Python"}
def test_card_callback_answer_question(self):
from bot.handler import _handle_card_action
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
)
act = CallBackAction()
act.value = {"action": "answer_question", "conv_id": "c1",
"question": "Which lang?", "answer": "Python"}
act.tag = "button"
op = CallBackOperator()
op.open_id = "ou_test"
data = P2CardActionTriggerData()
data.action = act
data.operator = op
trigger = P2CardActionTrigger()
trigger.event = data
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "success"
assert "Python" in resp.toast.content
assert "Python" in resp.card.data["body"]["elements"][0]["content"]
def test_progress_shows_pending_question(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._busy = True
s._started_at = time.time()
s._pending_question_data = {
"questions": [{"question": "Pick a color?", "options": [{"label": "Red"}, {"label": "Blue"}]}],
"conv_id": "c1",
}
p = s.get_progress()
assert p.pending_question is not None
assert p.pending_question["questions"][0]["question"] == "Pick a color?"
# =========================================================================== # ===========================================================================