deer-flow/backend/tests/blocking_io/test_dingtalk_card_token.py
Hyeonsang Cho 2c8892c810
fix(channels): deliver the DingTalk reply when an AI card request fails (#7781)
* fix(channels): deliver the DingTalk reply when an AI card request fails

dingtalk-stream's AICardReplier logs a failed card request and returns
as if it succeeded; its create call returns the card ID it generated
before the request. The channel stored that ID, so neither the running
reply's text fallback nor send()'s sampleMarkdown fallback could run,
every streamed update failed silently, and the final answer never
reached DingTalk.

Send createAndDeliver and the streaming update through the channel's
httpx client with its token, using the bodies the SDK sends, and raise
on HTTP and transport failures so both fallbacks run. The SDK replier,
its per-card map and the token shim it needed are removed.

* docs(changelog): reference #7781 for the DingTalk AI card fallback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 10:34:03 +08:00

67 lines
2.9 KiB
Python

"""Regression anchor: DingTalk AI card calls must not fetch a token on the loop.
dingtalk-stream's ``get_access_token()`` refreshes through ``requests.post``
with no timeout. Card creation (``_send_running_reply``) and card streaming
(``send``) both run on the Gateway loop, so a card call through the SDK's
``AICardReplier`` blocked the loop for the whole refresh, about every two
hours. The channel now sends card requests through its own httpx client with
its own token.
This anchor drives the real channel methods with a real
``DingTalkStreamClient`` whose token cache is empty, under the strict gate, and
asserts the card requests reached the local OpenAPI peer with the channel's
token. The SDK's endpoints point away from the real API (its token endpoint at
the discard port), so a call that slipped back to the SDK fails fast instead of
leaving the machine or hanging the loop.
"""
from __future__ import annotations
import pytest
from dingtalk_stream import card_replier, stream
from dingtalk_stream.chatbot import ChatbotMessage
from dingtalk_stream.credential import Credential
from dingtalk_stream.stream import DingTalkStreamClient
from support.local_sockets import LocalHttpServer
from app.channels import dingtalk as dingtalk_module
from app.channels.dingtalk import _CONVERSATION_TYPE_P2P, DingTalkChannel
from app.channels.message_bus import MessageBus
pytestmark = pytest.mark.asyncio
_UNREACHABLE_SDK_ENDPOINT = "http://127.0.0.1:9"
def _channel() -> DingTalkChannel:
channel = DingTalkChannel(MessageBus(), config={"card_template_id": "tpl_123"})
channel._client_id, channel._client_secret = "id", "secret"
channel._dingtalk_client = DingTalkStreamClient(Credential("id", "secret"))
return channel
def _incoming() -> ChatbotMessage:
incoming = ChatbotMessage()
incoming.conversation_type = _CONVERSATION_TYPE_P2P
incoming.sender_staff_id = "user_001"
return incoming
async def test_card_create_and_stream_never_fetch_the_sdk_token_on_the_loop(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(stream, "DINGTALK_OPENAPI_ENDPOINT", _UNREACHABLE_SDK_ENDPOINT)
async with LocalHttpServer(body={"accessToken": "tok", "expireIn": 7200}) as api:
base = api.url.removesuffix("/open")
monkeypatch.setattr(dingtalk_module, "DINGTALK_API_BASE", base)
monkeypatch.setattr(card_replier, "DINGTALK_OPENAPI_ENDPOINT", base)
channel = _channel()
track = await channel._create_and_deliver_card("Working on it...", chatbot_message=_incoming())
assert track
await channel._stream_update_card(track, "done", is_finalize=True)
assert [(method, path, headers.get("x-acs-dingtalk-access-token")) for method, path, headers in api.calls] == [
("POST", "/v1.0/oauth2/accessToken", None),
("POST", "/v1.0/card/instances/createAndDeliver", "tok"),
("PUT", "/v1.0/card/streaming", "tok"),
]