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:
parent
26746335c4
commit
72ebf3b75d
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
134
bot/handler.py
134
bot/handler.py
@ -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,
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"type": "raw",
|
|
||||||
"data": {
|
|
||||||
"schema": "2.0",
|
"schema": "2.0",
|
||||||
"header": {
|
"header": {
|
||||||
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
|
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
|
||||||
"template": template,
|
"template": template,
|
||||||
},
|
},
|
||||||
"body": {
|
"body": {"elements": [{"tag": "markdown", "content": card_status}]},
|
||||||
"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
|
||||||
|
|||||||
160
docs/claude/test_can_use_tool_ask.py
Normal file
160
docs/claude/test_can_use_tool_ask.py
Normal 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()))
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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?"
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user