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:
+12
-2
@@ -41,11 +41,16 @@ def feishu_calls():
|
||||
# ── Singleton state resets ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_manager():
|
||||
def reset_manager(tmp_path):
|
||||
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()
|
||||
yield
|
||||
manager._sessions.clear()
|
||||
mgr_mod.PERSISTENCE_FILE = original_file
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -71,8 +76,12 @@ def reset_task_runner():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_scheduler():
|
||||
def reset_scheduler(tmp_path):
|
||||
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()):
|
||||
task.cancel()
|
||||
scheduler._jobs.clear()
|
||||
@@ -80,6 +89,7 @@ def reset_scheduler():
|
||||
for task in list(getattr(scheduler, "_tasks", {}).values()):
|
||||
task.cancel()
|
||||
scheduler._jobs.clear()
|
||||
sched_mod.PERSISTENCE_FILE = original_file
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
+137
-67
@@ -787,83 +787,153 @@ class TestBuildApprovalCard:
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def _make_card_event(self, action: str, conv_id: str) -> object:
|
||||
"""Create a mock CustomizedEvent with card action payload."""
|
||||
import lark_oapi as lark
|
||||
mock_event = MagicMock()
|
||||
payload = json.dumps({
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_123",
|
||||
"event_type": "card.action.trigger",
|
||||
},
|
||||
"event": {
|
||||
"operator": {
|
||||
"open_id": "ou_test_user_123",
|
||||
},
|
||||
"action": {
|
||||
"value": {"action": action, "conv_id": conv_id},
|
||||
"tag": "button",
|
||||
},
|
||||
},
|
||||
})
|
||||
# lark.JSON.marshal returns the JSON string
|
||||
with patch("lark_oapi.JSON.marshal", return_value=payload):
|
||||
yield payload
|
||||
def _make_trigger(self, action: str, conv_id: str, **extra) -> "P2CardActionTrigger":
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
|
||||
)
|
||||
value = {"action": action, "conv_id": conv_id, **extra}
|
||||
act = CallBackAction()
|
||||
act.value = value
|
||||
act.tag = "button"
|
||||
op = CallBackOperator()
|
||||
op.open_id = "ou_test_user"
|
||||
data = P2CardActionTriggerData()
|
||||
data.action = act
|
||||
data.operator = op
|
||||
trigger = P2CardActionTrigger()
|
||||
trigger.event = data
|
||||
return trigger
|
||||
|
||||
def test_approve_returns_toast_and_card(self):
|
||||
from bot.handler import _handle_card_action
|
||||
payload = json.dumps({
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_test"},
|
||||
"action": {"value": {"action": "approve", "conv_id": "c1"}, "tag": "button"},
|
||||
},
|
||||
})
|
||||
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
|
||||
# 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"]
|
||||
trigger = self._make_trigger("approve", "c1")
|
||||
with patch("bot.handler._main_loop", new=MagicMock()):
|
||||
resp = _handle_card_action(trigger)
|
||||
assert resp.toast is not None
|
||||
assert resp.toast.type == "success"
|
||||
assert "批准" in resp.toast.content
|
||||
assert resp.card is not None
|
||||
assert resp.card.type == "raw"
|
||||
assert resp.card.data["header"]["template"] == "green"
|
||||
|
||||
def test_deny_returns_warning_toast(self):
|
||||
from bot.handler import _handle_card_action
|
||||
payload = json.dumps({
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_test"},
|
||||
"action": {"value": {"action": "deny", "conv_id": "c1"}, "tag": "button"},
|
||||
},
|
||||
})
|
||||
with patch("lark_oapi.JSON.marshal", return_value=payload), \
|
||||
patch("bot.handler._main_loop", new=MagicMock()):
|
||||
result = _handle_card_action(MagicMock())
|
||||
trigger = self._make_trigger("deny", "c1")
|
||||
with patch("bot.handler._main_loop", new=MagicMock()):
|
||||
resp = _handle_card_action(trigger)
|
||||
assert resp.toast.type == "warning"
|
||||
assert "拒绝" in resp.toast.content
|
||||
assert resp.card.data["header"]["template"] == "red"
|
||||
|
||||
assert result is not None
|
||||
assert result["toast"]["type"] == "warning"
|
||||
assert "拒绝" in result["toast"]["content"]
|
||||
assert result["card"]["data"]["header"]["template"] == "red"
|
||||
|
||||
def test_missing_value_returns_none(self):
|
||||
def test_missing_value_returns_empty_response(self):
|
||||
from bot.handler import _handle_card_action
|
||||
payload = json.dumps({
|
||||
"event": {
|
||||
"action": {"value": {}, "tag": "button"},
|
||||
},
|
||||
})
|
||||
with patch("lark_oapi.JSON.marshal", return_value=payload):
|
||||
result = _handle_card_action(MagicMock())
|
||||
assert result is None
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackAction, P2CardActionTrigger, P2CardActionTriggerData,
|
||||
)
|
||||
act = CallBackAction()
|
||||
act.value = {} # no action/conv_id
|
||||
data = P2CardActionTriggerData()
|
||||
data.action = act
|
||||
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?"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user