deer-flow/backend/tests/test_feishu_card_interaction.py
wuyi-github 5a563b4e99
feat(channels): add thread-scoped discard and lifecycle helpers for Feishu pending card registry (#7357)
* feat(channels): add thread-level discard and pruning helpers for Feishu card registry

Enhance PendingCardRegistry with thread-scoped retirement (discard_thread), explicit stale card pruning (prune_expired), and read-only inspection (get_pending_entry). Add matching unit tests covering multi-card lifecycles.

Relates to #1199

* feat(channels): connect thread-scoped card retirement to /new lifecycle
2026-10-24 11:41:49 +08:00

904 lines
41 KiB
Python

"""Tests for the Feishu interactive-card clarification module (outbound half).
Rendering, ownership, one-time consumption and bounded registry lifecycle.
"""
from __future__ import annotations
import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.feishu_card_interaction import (
MAX_CARD_OPTIONS,
ClarificationCardRequest,
PendingCardRegistry,
build_button_card,
)
from app.channels.message_bus import HUMAN_INPUT_RESPONSE_METADATA_KEY, InboundMessage, MessageBus
from app.channels.store import JsonChannelStore
def _metadata(payload: dict | None) -> dict:
return {} if payload is None else {"human_input_request": payload}
def _payload(**overrides) -> dict:
base = {
"version": 1,
"kind": "human_input_request",
"source": "ask_clarification",
"request_id": "clarification:call_1",
"question": "Pick one",
"input_mode": "choice_with_other",
"options": [
{"id": "option-1", "label": "Alpha", "value": "Alpha"},
{"id": "option-2", "label": "Beta", "value": "Beta"},
],
}
base.update(overrides)
return base
class TestClarificationCardRequestExtraction:
def test_absent_payload_returns_none(self):
assert ClarificationCardRequest.from_metadata(_metadata(None)) is None
def test_free_text_mode_returns_none(self):
assert ClarificationCardRequest.from_metadata(_metadata(_payload(input_mode="free_text", options=[]))) is None
def test_form_mode_returns_none(self):
assert ClarificationCardRequest.from_metadata(_metadata(_payload(version=2, input_mode="form", fields=[{"key": "x"}]))) is None
def test_empty_options_returns_none(self):
assert ClarificationCardRequest.from_metadata(_metadata(_payload(options=[]))) is None
def test_overflow_options_returns_none(self):
options = [{"id": f"option-{i}", "label": f"L{i}", "value": f"L{i}"} for i in range(1, MAX_CARD_OPTIONS + 2)]
assert ClarificationCardRequest.from_metadata(_metadata(_payload(options=options))) is None
def test_choice_with_options_extracts(self):
req = ClarificationCardRequest.from_metadata(_metadata(_payload()))
assert req is not None
assert req.request_id == "clarification:call_1"
assert req.question == "Pick one"
assert req.options == ("Alpha", "Beta")
def test_custom_option_ids_pass_through_to_buttons(self):
req = ClarificationCardRequest.from_metadata(
_metadata(
_payload(
options=[
{"id": "approve", "label": "批准", "value": "批准"},
{"id": "reject", "label": "拒绝", "value": "拒绝"},
]
)
)
)
assert req is not None
card = json.loads(build_button_card(req))
actions = next(e for e in card["elements"] if e["tag"] == "action")["actions"]
assert [a["value"]["option_id"] for a in actions] == ["approve", "reject"]
def test_malformed_payload_returns_none(self):
assert ClarificationCardRequest.from_metadata(_metadata({"kind": "other"})) is None
assert ClarificationCardRequest.from_metadata({"human_input_request": "not-a-dict"}) is None
class TestBuildButtonCard:
def test_card_preserves_context_before_question(self):
req = ClarificationCardRequest.from_metadata(_metadata(_payload(question="是否继续?", context="这将**永久删除**生产表中的数据。\n请先确认已有备份。")))
assert req is not None
card = json.loads(build_button_card(req))
markdown = next(element for element in card["elements"] if element["tag"] == "markdown")
assert markdown["content"] == "这将**永久删除**生产表中的数据。\n请先确认已有备份。\n\n是否继续?"
@pytest.mark.parametrize("context", [None, "", " \t\n ", 42, ["background"]])
def test_missing_or_invalid_context_keeps_question_only(self, context):
req = ClarificationCardRequest.from_metadata(_metadata(_payload(context=context)))
assert req is not None
card = json.loads(build_button_card(req))
markdown = next(element for element in card["elements"] if element["tag"] == "markdown")
assert markdown["content"] == "Pick one"
def test_card_structure_binds_buttons_to_request(self):
req = ClarificationCardRequest.from_metadata(_metadata(_payload()))
assert req is not None
assert req.option_ids == ("option-1", "option-2")
card = json.loads(build_button_card(req))
assert card["config"]["update_multi"] is True
assert card["header"]["title"]["content"]
elements = card["elements"]
markdown = next(e for e in elements if e["tag"] == "markdown")
assert "Pick one" in markdown["content"]
actions = next(e for e in elements if e["tag"] == "action")["actions"]
assert len(actions) == 2
for i, (action, label) in enumerate(zip(actions, ("Alpha", "Beta")), start=1):
assert action["tag"] == "button"
assert action["text"]["content"] == label
assert action["value"]["callback_token"] == req.callback_token
assert action["value"]["option_id"] == f"option-{i}"
assert action["value"]["option_value"] == label
assert actions[0]["type"] == "primary"
assert actions[1]["type"] == "default"
class TestPendingCardRegistry:
def _entry(self, request_id: str = "clarification:call_1", **overrides) -> dict:
entry = {
"callback_token": "callback-1",
"request_id": request_id,
"source": "ask_clarification",
"thread_id": "thread-1",
"chat_id": "chat-1",
"originator_open_id": "ou_originator",
"options": ("Alpha", "Beta"),
"option_ids": ("option-1", "option-2"),
}
entry.update(overrides)
return entry
def test_register_then_consume_returns_entry_once(self):
registry = PendingCardRegistry()
registry.register(**self._entry(topic_id="topic-1", connection_id="conn-1", owner_user_id="deer-user-1"))
entry, reason = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert reason is None and entry is not None
assert entry["originator_open_id"] == "ou_originator"
assert entry["topic_id"] == "topic-1"
assert entry["connection_id"] == "conn-1"
assert entry["owner_user_id"] == "deer-user-1"
entry2, _ = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry2 is None # one-time
def test_same_request_id_keeps_independent_cards(self):
registry = PendingCardRegistry()
registry.register(**self._entry())
registry.register(**self._entry(callback_token="callback-2", originator_open_id="ou_new"))
first, _ = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert first is not None and first["originator_open_id"] == "ou_originator"
entry, _ = registry.consume_if_originator("callback-2", "ou_new", option_id="option-1", option_value="Alpha")
assert entry is not None
assert entry["originator_open_id"] == "ou_new"
def test_duplicate_callback_token_cannot_replace_a_pending_card(self):
registry = PendingCardRegistry()
registry.register(**self._entry())
with pytest.raises(ValueError, match="already owns"):
registry.register(**self._entry(request_id="clarification:other"))
entry, _ = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is not None and entry["request_id"] == "clarification:call_1"
def test_register_prunes_expired_cards_without_clicks(self, monkeypatch):
clock = [100.0]
monkeypatch.setattr("app.channels.feishu_card_interaction.time.time", lambda: clock[0])
registry = PendingCardRegistry(ttl_seconds=10)
registry.register(**self._entry())
clock[0] += 11
registry.register(**self._entry(callback_token="callback-2"))
assert len(registry._entries) == 1
entry, _ = registry.consume_if_originator("callback-2", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is not None
def test_capacity_retires_oldest_card_and_keeps_new_cards_usable(self):
registry = PendingCardRegistry(max_entries=2)
for index in range(3):
registry.register(**self._entry(callback_token=f"callback-{index}"))
oldest, _ = registry.consume_if_originator("callback-0", "ou_originator", option_id="option-1", option_value="Alpha")
assert oldest is None
for index in (1, 2):
entry, _ = registry.consume_if_originator(f"callback-{index}", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is not None
def test_discard_thread_retires_all_matching_cards(self):
registry = PendingCardRegistry()
registry.register(**self._entry(callback_token="cb-1", thread_id="thread-target"))
registry.register(**self._entry(callback_token="cb-2", thread_id="thread-target"))
registry.register(**self._entry(callback_token="cb-3", thread_id="thread-other"))
assert len(registry) == 3
pruned = registry.discard_thread("thread-target")
assert pruned == 2
assert len(registry) == 1
entry, _ = registry.consume_if_originator("cb-3", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is not None
def test_prune_expired_removes_stale_cards_and_returns_count(self):
registry = PendingCardRegistry(ttl_seconds=10)
registry.register(**self._entry(callback_token="cb-fresh"))
registry.register(**self._entry(callback_token="cb-stale"))
registry._entries["cb-stale"]["created_at"] -= 15
cleaned = registry.prune_expired()
assert cleaned == 1
assert len(registry) == 1
assert "cb-fresh" in registry._entries
assert "cb-stale" not in registry._entries
def test_get_pending_entry_provides_read_only_view(self):
registry = PendingCardRegistry()
registry.register(**self._entry(callback_token="cb-inspect", thread_id="th-100"))
view = registry.get_pending_entry("cb-inspect")
assert view is not None
assert view["thread_id"] == "th-100"
assert len(registry) == 1
consumed, _ = registry.consume_if_originator("cb-inspect", "ou_originator", option_id="option-1", option_value="Alpha")
assert consumed is not None
assert registry.get_pending_entry("cb-inspect") is None
class TestFeishuChannelButtonCardWiring:
"""Channel-level wiring: when the flag and payload line up, final
clarification outbounds go out as button cards; everything else is
byte-identical to today's plain-text path."""
def _channel(self, *, enabled: bool, tmp_path):
from unittest.mock import AsyncMock
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus
channel = FeishuChannel(MessageBus(), {"app_id": "test", "app_secret": "test", "card_interaction_enabled": enabled, "channel_store": JsonChannelStore(path=tmp_path / "routes.json")})
channel._reply_card = AsyncMock(return_value="om_card_final")
channel._update_card = AsyncMock()
channel._add_reaction = AsyncMock()
channel._remember_thread_mapping = AsyncMock()
return channel
def _clarification_outbound(self, *, with_options: bool = True):
from app.channels.message_bus import (
HUMAN_INPUT_REQUEST_METADATA_KEY,
PENDING_CLARIFICATION_METADATA_KEY,
OutboundMessage,
)
metadata = {
PENDING_CLARIFICATION_METADATA_KEY: True,
"user_id": "ou_originator",
"topic_id": "topic-1",
"message_id": "om_src",
}
if with_options:
metadata[HUMAN_INPUT_REQUEST_METADATA_KEY] = {
"version": 1,
"kind": "human_input_request",
"source": "ask_clarification",
"request_id": "clarification:call_1",
"question": "Pick one",
"input_mode": "choice_with_other",
"options": [
{"id": "option-1", "label": "Alpha", "value": "Alpha"},
{"id": "option-2", "label": "Beta", "value": "Beta"},
],
}
else:
metadata[HUMAN_INPUT_REQUEST_METADATA_KEY] = {
"version": 1,
"kind": "human_input_request",
"source": "ask_clarification",
"request_id": "clarification:call_2",
"question": "Say anything",
"input_mode": "free_text",
}
return OutboundMessage(
channel_name="feishu",
chat_id="chat-1",
thread_id="thread-1",
text="Pick one",
is_final=True,
thread_ts="om_src",
metadata=metadata,
)
@pytest.mark.asyncio
async def test_flag_on_options_renders_button_card_and_registers(self, tmp_path):
channel = self._channel(enabled=True, tmp_path=tmp_path)
await channel._send_card_message(self._clarification_outbound())
card_json = channel._reply_card.call_args.args[1]
card = json.loads(card_json)
assert any(e.get("tag") == "action" for e in card["elements"])
token = next(e for e in card["elements"] if e["tag"] == "action")["actions"][0]["value"]["callback_token"]
entry, reason = channel._card_registry.consume_if_originator(token, "ou_originator", option_id="option-1", option_value="Alpha")
assert reason is None and entry is not None
assert entry["originator_open_id"] == "ou_originator"
assert entry["thread_id"] == "thread-1"
assert entry["card_message_id"] == "om_card_final"
@pytest.mark.asyncio
async def test_flag_off_falls_back_to_plain_card(self, tmp_path):
channel = self._channel(enabled=False, tmp_path=tmp_path)
await channel._send_card_message(self._clarification_outbound())
# Plain-text path: markdown text, no raw-card bypass, nothing registered.
text_arg = channel._reply_card.call_args.args[1]
assert "Pick one" in text_arg
assert '"action"' not in text_arg
assert channel._reply_card.call_args.kwargs.get("raw_card_content") is not True
entry, _ = channel._card_registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is None
@pytest.mark.asyncio
async def test_flag_on_free_text_falls_back_to_plain_card(self, tmp_path):
channel = self._channel(enabled=True, tmp_path=tmp_path)
await channel._send_card_message(self._clarification_outbound(with_options=False))
text_arg = channel._reply_card.call_args.args[1]
assert '"action"' not in text_arg
assert channel._reply_card.call_args.kwargs.get("raw_card_content") is not True
entry, _ = channel._card_registry.consume_if_originator("clarification:call_2", "ou_originator", option_id="option-1", option_value="x")
assert entry is None
@pytest.mark.asyncio
async def test_running_card_is_updated_into_button_card(self, tmp_path):
channel = self._channel(enabled=True, tmp_path=tmp_path)
channel._running_card_ids["om_src"] = "om_running"
await channel._send_card_message(self._clarification_outbound())
card_json = channel._update_card.call_args.args[1]
card = json.loads(card_json)
assert any(e.get("tag") == "action" for e in card["elements"])
token = next(e for e in card["elements"] if e["tag"] == "action")["actions"][0]["value"]["callback_token"]
entry, _ = channel._card_registry.consume_if_originator(token, "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is not None
assert entry["card_message_id"] == "om_running"
class TestConsumeIfOriginator:
"""Atomic validate-and-consume for card clicks. Identity mismatch must NOT
consume the entry (the real originator may still click later)."""
def _registry_with_entry(self):
registry = PendingCardRegistry()
registry.register(
callback_token="callback-1",
request_id="clarification:call_1",
source="ask_clarification",
thread_id="thread-1",
chat_id="chat-1",
originator_open_id="ou_originator",
options=("Alpha", "Beta"),
option_ids=("option-1", "option-2"),
card_message_id="om_card_1",
)
return registry
def test_originator_consumes_and_gets_entry(self):
registry = self._registry_with_entry()
entry, reason = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert reason is None
assert entry is not None
assert entry["card_message_id"] == "om_card_1"
# replayed click: entry already gone
entry2, reason2 = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry2 is None
assert reason2 == "unknown"
def test_non_originator_rejected_without_consuming(self):
registry = self._registry_with_entry()
entry, reason = registry.consume_if_originator("callback-1", "ou_someone_else", option_id="option-1", option_value="Alpha")
assert entry is None
assert reason == "not_originator"
# the real originator can still click afterwards
entry2, reason2 = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert reason2 is None
assert entry2 is not None
def test_unknown_request_id(self):
registry = self._registry_with_entry()
entry, reason = registry.consume_if_originator("clarification:nope", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is None
assert reason == "unknown"
def test_expired_entry_is_unknown(self):
registry = self._registry_with_entry()
future = time.time() + 31 * 60
entry, reason = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha", now=future)
assert entry is None
assert reason == "unknown"
def test_unknown_option_value_rejected_without_consuming(self):
registry = self._registry_with_entry()
entry, reason = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="注入值")
assert entry is None
assert reason == "unknown"
# the real button value still works afterwards
entry2, reason2 = registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert reason2 is None and entry2 is not None
def _make_card_action_event(*, callback_token: str, option_value: str, option_id: str = "option-1", operator_open_id: str = "ou_originator"):
"""Build a minimal P2CardActionTrigger-shaped stand-in."""
from unittest.mock import MagicMock
event = MagicMock()
event.event.action.value = {"callback_token": callback_token, "option_id": option_id, "option_value": option_value}
event.event.operator.open_id = operator_open_id
return event
class TestOnCardActionValidation:
"""Sync layer of the card-action callback: validation + toast, no state change."""
def _channel(self):
from unittest.mock import MagicMock
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus
channel = FeishuChannel(MessageBus(), {"app_id": "t", "app_secret": "t", "card_interaction_enabled": True})
channel._card_registry.register(
callback_token="callback-1",
request_id="clarification:call_1",
source="ask_clarification",
thread_id="thread-1",
chat_id="chat-1",
originator_open_id="ou_originator",
options=("Alpha", "Beta"),
option_ids=("option-1", "option-2"),
card_message_id="om_card_1",
)
channel._main_loop = MagicMock()
channel._main_loop.is_running.return_value = True
channel._submit_threadsafe_coroutine = MagicMock(side_effect=lambda coroutine, *args, **kwargs: (coroutine.close(), True)[1])
class _Toast:
pass
class _Resp:
pass
channel._CallBackToast = _Toast
channel._P2CardActionTriggerResponse = _Resp
return channel
def test_non_originator_gets_refusal_toast_and_entry_survives(self):
channel = self._channel()
resp = channel._on_card_action(_make_card_action_event(callback_token="callback-1", option_id="option-1", option_value="Alpha", operator_open_id="ou_other"))
assert resp.toast is not None
assert "发起人" in resp.toast.content
channel._submit_threadsafe_coroutine.assert_not_called()
# entry not consumed: originator can still click
entry, reason = channel._card_registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert reason is None and entry is not None
def test_unknown_request_id_gets_stale_toast(self):
channel = self._channel()
resp = channel._on_card_action(_make_card_action_event(callback_token="unknown", option_id="option-1", option_value="Alpha"))
assert resp.toast is not None
assert "失效" in resp.toast.content
channel._submit_threadsafe_coroutine.assert_not_called()
def test_dispatch_failure_returns_warning_toast(self):
channel = self._channel()
channel._submit_threadsafe_coroutine = MagicMock(side_effect=lambda coroutine, *args, **kwargs: (coroutine.close(), False)[1])
resp = channel._on_card_action(_make_card_action_event(callback_token="callback-1", option_id="option-1", option_value="Alpha"))
assert resp.toast is not None
assert resp.toast.type == "warning"
def test_originator_gets_ack_toast_and_dispatches_async_resolution(self):
channel = self._channel()
resp = channel._on_card_action(_make_card_action_event(callback_token="callback-1", option_id="option-1", option_value="Alpha"))
assert resp.toast is not None
assert "Alpha" in resp.toast.content
channel._submit_threadsafe_coroutine.assert_called_once()
# one-time: the entry is consumed by the click
entry, reason = channel._card_registry.consume_if_originator("callback-1", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is None and reason == "unknown"
class TestResolveCardClick:
"""Async layer: terminal card update + hidden synthetic inbound publication."""
@pytest.mark.asyncio
async def test_publishes_hidden_inbound_and_marks_card_resolved(self):
from unittest.mock import AsyncMock
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import HUMAN_INPUT_RESPONSE_METADATA_KEY, MessageBus
bus = MessageBus()
channel = FeishuChannel(bus, {"app_id": "t", "app_secret": "t", "card_interaction_enabled": True})
channel._update_card = AsyncMock()
published = []
async def capture(msg):
published.append(msg)
return True
channel._publish_inbound_or_drop = capture
entry = {
"request_id": "clarification:call_1",
"source": "ask_clarification",
"thread_id": "thread-1",
"chat_id": "chat-1",
"originator_open_id": "ou_originator",
"options": ("Alpha", "Beta"),
"option_ids": ("option-1", "option-2"),
"card_message_id": "om_card_1",
"topic_id": "om_topic_1",
"connection_id": "conn-1",
"owner_user_id": "deer-user-1",
}
await channel._resolve_card_click(entry, option_id="option-1", option_value="Alpha", operator_open_id="ou_originator")
# terminal card: no buttons, shows the choice
assert channel._update_card.await_count == 1
terminal_text = channel._update_card.await_args.args[1]
assert "Alpha" in terminal_text
# synthetic inbound: hidden human-input response routed at the card message,
# carrying the routing identity the manager keys on (topic + connection).
assert len(published) == 1
inbound = published[0]
assert inbound.chat_id == "chat-1"
assert inbound.user_id == "ou_originator"
assert inbound.thread_ts == "om_card_1"
assert inbound.topic_id == "om_topic_1"
assert inbound.connection_id == "conn-1"
assert inbound.owner_user_id == "deer-user-1"
response = inbound.metadata[HUMAN_INPUT_RESPONSE_METADATA_KEY]
assert response["kind"] == "human_input_response"
assert response["response_kind"] == "option"
assert response["request_id"] == "clarification:call_1"
assert response["value"] == "Alpha"
assert inbound.metadata.get("hide_from_ui") is True
@pytest.mark.asyncio
async def test_dropped_inbound_leaves_card_unresolved(self):
"""If the inbound cannot be published, the card must not flip to a false resolved state."""
from unittest.mock import AsyncMock
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus
channel = FeishuChannel(MessageBus(), {"app_id": "t", "app_secret": "t", "card_interaction_enabled": True})
channel._update_card = AsyncMock()
channel._publish_inbound_or_drop = AsyncMock(return_value=False)
entry = {
"request_id": "clarification:call_1",
"source": "ask_clarification",
"thread_id": "thread-1",
"chat_id": "chat-1",
"originator_open_id": "ou_originator",
"options": ("Alpha",),
"card_message_id": "om_card_1",
}
await channel._resolve_card_click(entry, option_id="option-1", option_value="Alpha", operator_open_id="ou_originator")
channel._update_card.assert_not_awaited()
class TestCardClickEndToEndRouting:
"""Pin the contract the reviewer flagged: the synthetic inbound must carry
the routing identity the manager's thread resolution keys on, so a card
click resumes the clarified thread instead of opening a new one."""
def test_card_answer_resumes_existing_thread(self, tmp_path, monkeypatch):
from app.channels.manager import ChannelManager
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
async def go():
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'channel-connections.db'}", sqlite_dir=str(tmp_path))
repo = ChannelConnectionRepository(get_session_factory(), cipher=ChannelCredentialCipher.from_key("test-channel-key"))
conn = await repo.upsert_connection(
owner_user_id="deer-user-1",
provider="feishu",
external_account_id="ou_originator",
workspace_id="chat-1",
)
# What the channel persisted when the button card was delivered:
# the card's topic maps to the clarified thread.
await repo.set_thread_id(
connection_id=conn["id"],
owner_user_id="deer-user-1",
provider="feishu",
external_conversation_id="chat-1",
thread_id="thread-existing",
external_topic_id="om_topic_1",
)
bus = MessageBus()
store = JsonChannelStore(path=tmp_path / "legacy-store.json")
manager = ChannelManager(bus=bus, store=store, connection_repo=repo)
mock_client = MagicMock()
mock_client.threads.create = AsyncMock(return_value={"thread_id": "thread-existing"})
mock_client.threads.update = AsyncMock(return_value={"thread_id": "thread-existing"})
mock_client.threads.get = AsyncMock(return_value={"thread_id": "thread-existing"})
mock_client.runs.wait = AsyncMock(return_value={"messages": [{"type": "human", "content": "Alpha"}, {"type": "ai", "content": "resumed"}]})
# Feishu is a streaming channel: the run goes through runs.stream,
# whose first positional argument is the thread id.
class _Part:
def __init__(self, event, data):
self.event = event
self.data = data
async def _stream(*args, **kwargs):
yield _Part("values", {"messages": [{"type": "human", "content": "Alpha"}, {"type": "ai", "content": "resumed"}], "artifacts": []})
mock_client.runs.stream = MagicMock(side_effect=lambda *args, **kwargs: _stream(*args, **kwargs))
manager._client = mock_client
# The synthetic inbound exactly as _resolve_card_click builds it.
inbound = InboundMessage(
channel_name="feishu",
chat_id="chat-1",
user_id="ou_originator",
owner_user_id="deer-user-1",
connection_id=conn["id"],
text="Alpha",
thread_ts="om_card_1",
topic_id="om_topic_1",
metadata={
"hide_from_ui": True,
HUMAN_INPUT_RESPONSE_METADATA_KEY: {
"version": 1,
"kind": "human_input_response",
"source": "ask_clarification",
"request_id": "clarification:call_1",
"response_kind": "option",
"option_id": "option-1",
"value": "Alpha",
},
},
)
await manager._handle_chat(inbound)
# The clarified thread is reused — nothing is created anew.
mock_client.threads.create.assert_not_called()
assert mock_client.runs.stream.call_count == 1
assert mock_client.runs.stream.call_args.args[0] == "thread-existing"
# And the posted human message carries the hidden human-input response.
posted = mock_client.runs.stream.call_args.kwargs["input"]["messages"][0]
assert posted["additional_kwargs"]["hide_from_ui"] is True
assert posted["additional_kwargs"][HUMAN_INPUT_RESPONSE_METADATA_KEY]["value"] == "Alpha"
try:
asyncio.run(go())
finally:
asyncio.run(close_engine())
def test_card_answer_passes_bound_identity_admission(self, tmp_path, monkeypatch):
"""Connections-enabled deployments (require_bound_identity=True) re-read the
binding with workspace_id; Feishu binds connections with workspace_id=chat_id,
so the synthetic inbound must carry it or every click is rejected as unbound."""
from app.channels.manager import ChannelManager
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
async def go():
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'cc.db'}", sqlite_dir=str(tmp_path))
repo = ChannelConnectionRepository(get_session_factory(), cipher=ChannelCredentialCipher.from_key("test-channel-key"))
conn = await repo.upsert_connection(
owner_user_id="deer-user-1",
provider="feishu",
external_account_id="ou_originator",
workspace_id="chat-1",
)
await repo.set_thread_id(
connection_id=conn["id"],
owner_user_id="deer-user-1",
provider="feishu",
external_conversation_id="chat-1",
thread_id="thread-existing",
external_topic_id="om_topic_1",
)
bus = MessageBus()
store = JsonChannelStore(path=tmp_path / "legacy-store.json")
manager = ChannelManager(bus=bus, store=store, connection_repo=repo, require_bound_identity=True)
mock_client = MagicMock()
mock_client.threads.create = AsyncMock(return_value={"thread_id": "thread-existing"})
mock_client.threads.update = AsyncMock(return_value={"thread_id": "thread-existing"})
mock_client.threads.get = AsyncMock(return_value={"thread_id": "thread-existing"})
class _Part:
def __init__(self, event, data):
self.event = event
self.data = data
async def _stream(*args, **kwargs):
yield _Part("values", {"messages": [{"type": "human", "content": "Alpha"}, {"type": "ai", "content": "resumed"}], "artifacts": []})
mock_client.runs.stream = MagicMock(side_effect=lambda *args, **kwargs: _stream(*args, **kwargs))
manager._client = mock_client
# Build the inbound through the channel's real click-resolution path,
# then hand it to the manager — this is the production seam.
from app.channels.feishu import FeishuChannel
channel = FeishuChannel(MessageBus(), {"app_id": "t", "app_secret": "t", "card_interaction_enabled": True})
channel._update_card = AsyncMock()
produced = []
async def capture(msg):
produced.append(msg)
return True
channel._publish_inbound_or_drop = capture
entry = {
"request_id": "clarification:call_1",
"source": "ask_clarification",
"thread_id": "thread-existing",
"chat_id": "chat-1",
"originator_open_id": "ou_originator",
"options": ("Alpha", "Beta"),
"option_ids": ("option-1", "option-2"),
"card_message_id": "om_card_1",
"topic_id": "om_topic_1",
"connection_id": conn["id"],
"owner_user_id": "deer-user-1",
}
await channel._resolve_card_click(entry, option_id="option-1", option_value="Alpha", operator_open_id="ou_originator")
assert len(produced) == 1
await manager._handle_chat(produced[0])
mock_client.threads.create.assert_not_called()
assert mock_client.runs.stream.call_count == 1
assert mock_client.runs.stream.call_args.args[0] == "thread-existing"
try:
asyncio.run(go())
finally:
asyncio.run(close_engine())
class TestThreadReplacementRetiresCards:
"""Integration: ``/new`` retires pending cards for the old thread so stale
button clicks are correctly rejected instead of lingering."""
def _channel_with_cards(self, *, thread_id: str = "thread-old"):
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus
channel = FeishuChannel(
MessageBus(),
{"app_id": "t", "app_secret": "t", "card_interaction_enabled": True},
)
# Register two pending cards on the target thread and one on another.
for token_suffix in ("a", "b"):
channel._card_registry.register(
callback_token=f"cb-{token_suffix}",
request_id=f"req-{token_suffix}",
source="ask_clarification",
thread_id=thread_id,
chat_id="chat-1",
originator_open_id="ou_originator",
options=("Alpha", "Beta"),
option_ids=("option-1", "option-2"),
card_message_id=f"om_card_{token_suffix}",
)
channel._card_registry.register(
callback_token="cb-other",
request_id="req-other",
source="ask_clarification",
thread_id="thread-unrelated",
chat_id="chat-1",
originator_open_id="ou_originator",
options=("Alpha",),
option_ids=("option-1",),
card_message_id="om_card_other",
)
return channel
def test_on_thread_replaced_retires_old_thread_cards(self):
channel = self._channel_with_cards()
assert len(channel._card_registry) == 3
channel.on_thread_replaced("thread-old")
# The two cards for the replaced thread are gone.
assert len(channel._card_registry) == 1
entry_a, _ = channel._card_registry.consume_if_originator("cb-a", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry_a is None
entry_b, _ = channel._card_registry.consume_if_originator("cb-b", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry_b is None
# The unrelated thread's card is unaffected.
entry_other, reason = channel._card_registry.consume_if_originator("cb-other", "ou_originator", option_id="option-1", option_value="Alpha")
assert reason is None and entry_other is not None
def test_on_thread_replaced_is_noop_when_card_interaction_disabled(self):
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus
channel = FeishuChannel(
MessageBus(),
{"app_id": "t", "app_secret": "t", "card_interaction_enabled": False},
)
channel._card_registry.register(
callback_token="cb-1",
request_id="req-1",
source="ask_clarification",
thread_id="thread-old",
chat_id="chat-1",
originator_open_id="ou_originator",
options=("Alpha",),
option_ids=("option-1",),
card_message_id="om_card_1",
)
channel.on_thread_replaced("thread-old")
# Card interaction is off, so nothing is retired.
assert len(channel._card_registry) == 1
def test_manager_new_command_notifies_channel(self, tmp_path):
"""The ``/new`` command path in ChannelManager looks up the old thread
and calls ``channel.on_thread_replaced`` before replying."""
from unittest.mock import patch
from app.channels.feishu import FeishuChannel
from app.channels.manager import ChannelManager
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus
from app.channels.store import JsonChannelStore
async def go():
bus = MessageBus()
store = JsonChannelStore(path=tmp_path / "routes.json")
manager = ChannelManager(bus=bus, store=store)
# Pre-seed the thread mapping so /new finds an old thread to replace.
channel = FeishuChannel(
MessageBus(),
{"app_id": "t", "app_secret": "t", "card_interaction_enabled": True},
)
channel._card_registry.register(
callback_token="cb-old",
request_id="req-old",
source="ask_clarification",
thread_id="thread-old",
chat_id="chat-1",
originator_open_id="ou_originator",
options=("Alpha",),
option_ids=("option-1",),
card_message_id="om_card_old",
)
mock_client = MagicMock()
mock_client.threads.create = AsyncMock(return_value={"thread_id": "thread-new"})
manager._client = mock_client
# Simulate: _lookup_thread_id returns the old thread
manager._lookup_thread_id = AsyncMock(return_value="thread-old")
manager._store_thread_id = AsyncMock()
inbound = InboundMessage(
channel_name="feishu",
chat_id="chat-1",
user_id="ou_originator",
text="/new",
msg_type=InboundMessageType.COMMAND,
)
# Patch get_channel_service to return our channel instance
mock_service = MagicMock()
mock_service.get_channel.return_value = channel
with patch("app.channels.service.get_channel_service", return_value=mock_service):
await manager._handle_command(inbound)
# The old thread's card should be retired.
assert len(channel._card_registry) == 0
entry, _ = channel._card_registry.consume_if_originator("cb-old", "ou_originator", option_id="option-1", option_value="Alpha")
assert entry is None
asyncio.run(go())