fix(channels): keep Feishu cards within the card size limit (#7798)

* fix(channels): keep Feishu cards within the card size limit

Card text was unbounded and ASCII-escaped, so an answer of about 4,400 CJK
characters made the card request body exceed Feishu's 30 KB limit (230025).
The final patch and its reply fallback were both rejected, the answer was
lost and the running card stayed on its last streamed text.

Cards are now serialized without ASCII escapes and budgeted by the bytes
their text adds to the request body. A streamed update shows what fits; a
longer final answer continues in up to 10 cards replied to the same source
message, split at line breaks, and a retry resumes after the cards already
delivered.

* docs(changelog): reference #7798 for the Feishu card size fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-11-07 16:52:59 +09:00 committed by GitHub
parent a3d21e5e4d
commit d658b13c79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 437 additions and 51 deletions

View File

@ -24,6 +24,16 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **channels:** Long Feishu/Lark answers are no longer lost. Card text was
unbounded and ASCII-escaped, so an answer of about 4,400 CJK characters made
the card request exceed Feishu's 30 KB limit (`230025`); the final patch and
its reply fallback were both rejected, and the running card stayed on its
last streamed text or a bare `▉`. Cards now send CJK unescaped and are
budgeted by the bytes they add to the request. A streamed update shows what
fits, and a longer final answer continues in up to 10 cards replied to the
same message, split at line breaks; a retry resumes after the cards already
delivered. ([#7798])
- **channels:** WeCom group messages now have their own conversation. Every
message used the sender's userid as both chat and topic ID, so a member's
private chat and every group they wrote in shared one DeerFlow thread
@ -16051,6 +16061,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7782]: https://github.com/bytedance/deer-flow/pull/7782
[#7792]: https://github.com/bytedance/deer-flow/pull/7792
[#7794]: https://github.com/bytedance/deer-flow/pull/7794
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7892]: https://github.com/bytedance/deer-flow/pull/7892
[#7907]: https://github.com/bytedance/deer-flow/pull/7907

View File

@ -23,6 +23,13 @@
### 修复
- **渠道:** 飞书/Lark 的长回答不再丢失。此前卡片文本没有长度上限,并以 ASCII 转义
序列化,约 4,400 个中日韩字符就会让卡片请求超过飞书 30 KB 的限制(`230025`):
最终更新及其回复兜底都会被拒绝,运行中卡片停留在最后一次流式内容或只剩 `▉`。
现在卡片不再转义中日韩字符,并按其在请求中占用的字节计算预算。流式更新只显示能
放下的部分;更长的最终回答会在同一条消息下以最多 10 张卡片按行续发,重试会从
已送达的卡片之后继续。([#7798])
- **渠道:** 企业微信群消息现在拥有独立的会话。此前每条消息都把发送者的 userid
同时用作会话 ID 和话题 ID,因此在没有群绑定加以区分的情况下(免认证模式、
`require_bound_identity: false`,以及旧版群绑定),成员的私聊和其发过言的所有群
@ -12770,5 +12777,6 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7781]: https://github.com/bytedance/deer-flow/pull/7781
[#7792]: https://github.com/bytedance/deer-flow/pull/7792
[#7794]: https://github.com/bytedance/deer-flow/pull/7794
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7782]: https://github.com/bytedance/deer-flow/pull/7782

View File

@ -1498,7 +1498,7 @@ DINGTALK_CLIENT_SECRET=your_client_secret
2. Add permissions: `im:message`, `im:message.p2p_msg:readonly`, `im:resource`.
3. Under **Events**, subscribe to `im.message.receive_v1` and select **Long Connection** mode.
4. Copy the App ID and App Secret. Set `FEISHU_APP_ID` and `FEISHU_APP_SECRET` in `.env` and enable the channel in `config.yaml`.
5. The bot supports inbound text, image, and file messages. Inbound attachment downloads are limited to 20 MB per attachment.
5. The bot supports inbound text, image, and file messages. Inbound attachment downloads are limited to 20 MB per attachment. A reply longer than one card (about 8,000 CJK characters) continues in further cards, up to 10 per answer.
6. _(Optional)_ Interactive clarification cards: set `card_interaction_enabled: true` under `channels.feishu` to let the agent's option-based clarifications render as clickable button cards, preserving any background context before the question. Then, in the developer console, open **Callback Configuration** (回调配置 — a separate tab from **Event Subscriptions**), subscribe to `card.action.trigger` in **Long Connection** mode, and publish a new app version. A click resolves the clarification exactly like typing the choice; only the user who triggered the clarification can operate its card. Button answers are saved in the conversation history, and subsequent clarifications continue in the same thread.
If the SDK repeatedly logs `CERTIFICATE_VERIFY_FAILED`, see [Feishu / Lark TLS troubleshooting](backend/docs/IM_CHANNEL_CONNECTIONS.md#feishu--lark-tls-troubleshooting) to check the Gateway's CA trust configuration.

View File

@ -144,6 +144,98 @@ class _RoutingAttempt:
FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75
FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000
SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview"
# Feishu rejects a card request body over 30 KB (230025), and style tags make
# the stored message larger than the body sent. Card text is budgeted by the
# bytes it adds to that body, leaving room for the other fields and that growth.
FEISHU_CARD_MAX_BYTES = 24 * 1024
# A long final answer continues in further cards, up to this many per reply.
FEISHU_MAX_CARDS_PER_REPLY = 10
_CARD_TRUNCATION_MARKER = "\n\n... (truncated)"
_CARD_CONTINUES_MARKER = "\n\n…"
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
# Characters JSON escapes, measured after the second escape: ``"`` is ``\\\"``.
_ESCAPED_CARD_CHAR_BYTES = {'"': 4, "\\": 4, "\b": 3, "\f": 3, "\n": 3, "\r": 3, "\t": 3}
def _card_char_bytes(char: str) -> int:
escaped = _ESCAPED_CARD_CHAR_BYTES.get(char)
if escaped is not None:
return escaped
code = ord(char)
if code < 0x20:
return 7 # \u00XX, whose backslash is escaped again
if code < 0x80:
return 1
if code < 0x800:
return 2
if code < 0x10000:
return 3 # a lone surrogate is sent as U+FFFD
return 4
def _card_text_bytes(text: str) -> int:
"""Bytes *text* adds to a card request body.
The text is escaped twice: once inside the card JSON, and again as the
request body's ``content`` string. JSON escapes each character on its own,
so the cost of a text is the sum of its characters' costs.
"""
return sum(map(_card_char_bytes, text))
def _card_text_budget() -> int:
return FEISHU_CARD_MAX_BYTES - _card_text_bytes(FeishuChannel._build_card_content(""))
def _fitting_prefix_end(text: str, start: int, budget: int) -> tuple[int, int]:
"""Return the end of the longest prefix of ``text[start:]`` within *budget*, and its last newline."""
used = 0
end = start
last_newline = -1
while end < len(text):
cost = _card_char_bytes(text[end])
if used + cost > budget:
break
used += cost
if text[end] == "\n":
last_newline = end
end += 1
return end, last_newline
def _clip_card_text(text: str, marker: str = _CARD_TRUNCATION_MARKER) -> str:
"""Clip *text* to one card, ending it with *marker* when anything was cut."""
budget = _card_text_budget()
if _card_text_bytes(text) <= budget:
return text
end, _ = _fitting_prefix_end(text, 0, budget - _card_text_bytes(marker))
return text[:end] + marker
def _split_card_text(text: str) -> list[str]:
"""Split *text* into cards, preferring line boundaries so markdown survives.
Each newline stays on the tail of its card, so the cards join back to
the original text. Past ``FEISHU_MAX_CARDS_PER_REPLY`` the last card is
clipped instead.
"""
budget = _card_text_budget()
if _card_text_bytes(text) <= budget:
return [text]
cards: list[str] = []
start = 0
while start < len(text):
if len(cards) == FEISHU_MAX_CARDS_PER_REPLY - 1:
logger.warning("[Feishu] reply of %d characters exceeds %d cards, clipping the last one", len(text), FEISHU_MAX_CARDS_PER_REPLY)
cards.append(_clip_card_text(text[start:]))
break
end, last_newline = _fitting_prefix_end(text, start, budget)
if end < len(text) and last_newline >= start:
end = last_newline + 1
end = max(end, start + 1) # always advance, even past an oversized character
cards.append(text[start:end])
start = end
return cards
def _is_feishu_command(text: str) -> bool:
@ -549,8 +641,10 @@ class FeishuChannel(Channel):
len(msg.text),
)
# Cards already delivered by an earlier attempt are not sent again.
delivered: list[str | None] = []
await self._send_with_retry(
lambda: self._send_card_message(msg),
lambda: self._send_card_message(msg, delivered=delivered),
max_retries=_max_retries,
log_prefix="[Feishu]",
)
@ -867,9 +961,11 @@ class FeishuChannel(Channel):
"""
card = {
"config": {"wide_screen_mode": True, "update_multi": True},
"elements": [{"tag": "markdown", "content": text}],
# Unpaired surrogates cannot be encoded as UTF-8.
"elements": [{"tag": "markdown", "content": _LONE_SURROGATE.sub("\ufffd", text)}],
}
return json.dumps(card)
# ASCII escapes would make each CJK character 6 bytes instead of 3.
return json.dumps(card, ensure_ascii=False)
# -- reaction helpers --------------------------------------------------
@ -949,7 +1045,7 @@ class FeishuChannel(Channel):
metadata: dict[str, Any] | None = None,
) -> str | None:
"""Create the running card and cache its message ID when available."""
running_card_id = await self._reply_card(source_message_id, self._compose_card_text(text, metadata))
running_card_id = await self._reply_card(source_message_id, _clip_card_text(self._compose_card_text(text, metadata), _CARD_CONTINUES_MARKER))
if running_card_id:
self._running_card_ids[source_message_id] = running_card_id
logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id)
@ -1063,8 +1159,14 @@ class FeishuChannel(Channel):
self._remember_pending_clarification(msg, card_message_id, topic_id=topic_id)
await self._remember_thread_mapping(msg, msg.thread_ts, card_message_id)
async def _send_card_message(self, msg: OutboundMessage) -> None:
"""Send or update the Feishu card tied to the current request."""
async def _send_card_message(self, msg: OutboundMessage, *, delivered: list[str | None] | None = None) -> None:
"""Send or update the Feishu card tied to the current request.
A final answer over one card's budget continues in further cards replied
to the same source message; a streamed update shows only what fits.
*delivered* collects the IDs of the final answer's cards across retries.
"""
delivered = [] if delivered is None else delivered
source_message_id = msg.thread_ts
# Option clarifications render as an interactive button card on the
# final outbound; everything else keeps the plain markdown card.
@ -1072,62 +1174,92 @@ class FeishuChannel(Channel):
card_topic_id = await self._bind_card_thread(msg) if card_request else None
if card_topic_id is None:
card_request = None
card_text = build_button_card(card_request) if card_request else self._compose_card_text(msg.text, msg.metadata)
# Button cards arrive as serialized card JSON and bypass the markdown
# wrapper; the plain path keeps its exact historical call signature.
raw_card_kw: dict[str, bool] = {"raw_card_content": True} if card_request else {}
if source_message_id:
running_card_id = self._running_card_ids.get(source_message_id)
awaited_running_card_task = False
if not running_card_id:
running_card_task = self._running_card_tasks.get(source_message_id)
if running_card_task:
awaited_running_card_task = True
running_card_id = await running_card_task
if running_card_id:
try:
await self._update_card(running_card_id, card_text, **raw_card_kw)
except Exception:
if not msg.is_final:
raise
logger.exception(
"[Feishu] failed to patch running card %s, falling back to final reply",
running_card_id,
)
fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw)
await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id)
else:
await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id)
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
if card_request:
cards = [build_button_card(card_request)]
elif msg.is_final:
final_card_id = await self._reply_card(
source_message_id,
card_text,
**raw_card_kw,
)
await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id)
elif awaited_running_card_task:
logger.warning(
"[Feishu] running card task finished without message_id for source=%s, skipping duplicate non-final creation",
source_message_id,
)
cards = _split_card_text(self._compose_card_text(msg.text, msg.metadata))
else:
created_card_id = await self._ensure_running_card(
source_message_id,
msg.text,
metadata=msg.metadata,
)
await self._remember_thread_mapping(msg, source_message_id, created_card_id)
cards = [_clip_card_text(self._compose_card_text(msg.text, msg.metadata), _CARD_CONTINUES_MARKER)]
if not delivered:
await self._send_first_card(msg, cards[0], card_request=card_request, card_topic_id=card_topic_id, delivered=delivered, raw_card_kw=raw_card_kw)
if msg.is_final:
for card_text in cards[len(delivered) :]:
delivered.append(await self._reply_card(source_message_id, card_text))
if len(delivered) > 1:
# A reply to any of the cards continues this thread.
await self._remember_thread_mapping(msg, *delivered[1:])
self._running_card_ids.pop(source_message_id, None)
self._remembered_thread_aliases.pop(source_message_id, None)
await self._add_reaction(source_message_id, "DONE")
return
await self._create_card(msg.chat_id, msg.text)
for card_text in _split_card_text(msg.text)[len(delivered) :]:
await self._create_card(msg.chat_id, card_text)
delivered.append(None)
async def _send_first_card(
self,
msg: OutboundMessage,
card_text: str,
*,
card_request: ClarificationCardRequest | None,
card_topic_id: str | None,
delivered: list[str | None],
raw_card_kw: dict[str, bool],
) -> None:
"""Put *card_text* on the running card, or reply with it; record it in *delivered* when final."""
source_message_id = msg.thread_ts
running_card_id = self._running_card_ids.get(source_message_id)
awaited_running_card_task = False
if not running_card_id:
running_card_task = self._running_card_tasks.get(source_message_id)
if running_card_task:
awaited_running_card_task = True
running_card_id = await running_card_task
if running_card_id:
try:
await self._update_card(running_card_id, card_text, **raw_card_kw)
except Exception:
if not msg.is_final:
raise
logger.exception(
"[Feishu] failed to patch running card %s, falling back to final reply",
running_card_id,
)
fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw)
delivered.append(fallback_card_id)
await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id)
else:
if msg.is_final:
delivered.append(running_card_id)
await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id)
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
elif msg.is_final:
final_card_id = await self._reply_card(
source_message_id,
card_text,
**raw_card_kw,
)
delivered.append(final_card_id)
await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id)
elif awaited_running_card_task:
logger.warning(
"[Feishu] running card task finished without message_id for source=%s, skipping duplicate non-final creation",
source_message_id,
)
else:
created_card_id = await self._ensure_running_card(
source_message_id,
msg.text,
metadata=msg.metadata,
)
await self._remember_thread_mapping(msg, source_message_id, created_card_id)
# -- internal ----------------------------------------------------------

View File

@ -187,6 +187,16 @@ graph TB
Msg --> Stream3 --> Stream --> Mid1 --> Mid2 --> Final
```
Feishu rejects a card whose request body exceeds 30 KB (`230025`). The channel
budgets card text at 24 KB of that body, measured as the bytes the text adds
after both JSON escapes, and sends CJK unescaped (3 bytes per character, about
8,000 per card). A streamed update that no longer fits shows the part that does,
ending in `…`. A longer final answer fills the running card and continues in up
to 10 cards replied to the same source message, split at line breaks; past that
the last card is clipped and marked `... (truncated)`. A retry of a partially
delivered answer resumes after the cards already sent, and a reply to any of the
cards continues the conversation.
For the special GitHub case (`fire_and_forget=True` channel policy), the manager calls `runs.create()` and returns once the run is `pending` — no outbound reply, because GitHub agents post via the `gh` CLI from inside their sandbox. See [GITHUB_AGENTS.md](GITHUB_AGENTS.md) for the full GitHub flow.
## Attachment delivery feedback

View File

@ -0,0 +1,225 @@
"""Feishu cards stay within the 30 KB card request body.
Feishu rejects a larger card (230025, "The length of the message content
reaches its limit"). Card text was unbounded and ASCII-escaped, 6 bytes per CJK
character in the card and 7 in the request body, so an answer of about 4,400
CJK characters failed its final patch and the reply fallback alike: the answer
was lost and the running card stayed on its last streamed text.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody, PatchMessageRequest, PatchMessageRequestBody, ReplyMessageRequest, ReplyMessageRequestBody
from lark_oapi.core.json import JSON
from app.channels.feishu import FEISHU_CARD_MAX_BYTES, FEISHU_MAX_CARDS_PER_REPLY, FeishuChannel, _card_text_bytes, _split_card_text
from app.channels.message_bus import MessageBus, OutboundMessage
from app.channels.store import JsonChannelStore
pytestmark = pytest.mark.asyncio
_FEISHU_CARD_BODY_LIMIT = 30 * 1024
_REJECTED = SimpleNamespace(success=lambda: False, code=230025, msg="The length of the message content reaches its limit", get_log_id=lambda: "log")
def _body_bytes(request) -> int:
return len(JSON.marshal(request.request_body).encode("utf-8"))
def _card_text(request) -> str:
return json.loads(request.request_body.content)["elements"][0]["content"]
class _FeishuApi:
"""Records card requests and rejects any whose body exceeds Feishu's limit."""
def __init__(self) -> None:
self.cards: dict[str, str] = {}
self.replies: list[tuple[str, str]] = []
self.patches: list[tuple[str, str]] = []
self.created: list[tuple[str, str]] = []
self.rejected: list[int] = []
self.fail_next_reply = 0
def _admit(self, request) -> bool:
if _body_bytes(request) > _FEISHU_CARD_BODY_LIMIT:
self.rejected.append(_body_bytes(request))
return False
return True
def reply(self, request):
if self.fail_next_reply:
self.fail_next_reply -= 1
raise ConnectionError("transient")
if not self._admit(request):
return _REJECTED
card_id = f"card-{len(self.cards) + 1}"
self.cards[card_id] = _card_text(request)
self.replies.append((request.message_id, card_id))
return SimpleNamespace(success=lambda: True, data=SimpleNamespace(message_id=card_id))
def patch(self, request):
if not self._admit(request):
return _REJECTED
self.cards[request.message_id] = _card_text(request)
self.patches.append((request.message_id, _card_text(request)))
return SimpleNamespace(success=lambda: True)
def create(self, request):
if not self._admit(request):
return _REJECTED
self.created.append((request.request_body.receive_id, _card_text(request)))
return SimpleNamespace(success=lambda: True)
@pytest.fixture
def feishu(tmp_path, monkeypatch):
monkeypatch.setattr("app.channels.base.asyncio.sleep", AsyncMock())
channel = FeishuChannel(MessageBus(), {"app_id": "offline-test", "app_secret": "offline-test"})
channel.config["channel_store"] = JsonChannelStore(path=tmp_path / "store.json")
for name, cls in (
("_ReplyMessageRequest", ReplyMessageRequest),
("_ReplyMessageRequestBody", ReplyMessageRequestBody),
("_PatchMessageRequest", PatchMessageRequest),
("_PatchMessageRequestBody", PatchMessageRequestBody),
("_CreateMessageRequest", CreateMessageRequest),
("_CreateMessageRequestBody", CreateMessageRequestBody),
):
setattr(channel, name, cls)
api = _FeishuApi()
channel._api_client = MagicMock()
channel._api_client.im.v1.message.reply.side_effect = api.reply
channel._api_client.im.v1.message.patch.side_effect = api.patch
channel._api_client.im.v1.message.create.side_effect = api.create
return SimpleNamespace(channel=channel, api=api)
def _outbound(text: str, *, final: bool, source: str | None = "source-1") -> OutboundMessage:
return OutboundMessage(channel_name="feishu", chat_id="oc-chat", thread_id="thread-1", text=text, is_final=final, thread_ts=source, metadata={"user_id": "ou-user"})
def _long_answer(lines: int = 300) -> str:
# About 18,000 CJK characters: four cards at the budget.
return "".join(f"第{index}段:这是一段很长的中文回答,用来测试飞书卡片的大小限制。\n" * 3 for index in range(lines))
@pytest.mark.parametrize("text", ["plain", "中文回答", 'quote " and \\ backslash', "line\nbreak\ttab\r\b\f", "emoji 😀 and 한국어 é", "\x01control\x7f", "lone \ud83d surrogate", "\u2028 separators \u2029"])
async def test_card_text_bytes_match_the_sdk_request_body(text):
def body(content_text: str) -> int:
request_body = PatchMessageRequestBody.builder().content(FeishuChannel._build_card_content(content_text)).build()
return len(JSON.marshal(request_body).encode("utf-8"))
assert body(text) - body("") == _card_text_bytes(text)
async def test_cjk_card_content_is_not_ascii_escaped():
content = FeishuChannel._build_card_content("中文")
assert "中文" in content
assert json.loads(content)["elements"][0]["content"] == "中文"
async def test_long_text_splits_on_lines_into_cards_that_fit():
answer = _long_answer()
cards = _split_card_text(answer)
assert len(cards) > 1
assert "".join(cards) == answer
assert all(card.endswith("\n") for card in cards[:-1])
for card in cards:
request = PatchMessageRequest.builder().message_id("card").request_body(PatchMessageRequestBody.builder().content(FeishuChannel._build_card_content(card)).build()).build()
assert _body_bytes(request) <= FEISHU_CARD_MAX_BYTES
async def test_text_without_line_breaks_still_splits_within_budget():
answer = "中" * 30_000
cards = _split_card_text(answer)
assert "".join(cards) == answer
assert all(_card_text_bytes(card) <= FEISHU_CARD_MAX_BYTES for card in cards)
async def test_split_is_capped_and_the_last_card_is_clipped():
cards = _split_card_text("中" * 200_000)
assert len(cards) == FEISHU_MAX_CARDS_PER_REPLY
assert cards[-1].endswith("... (truncated)")
assert all(_card_text_bytes(card) <= FEISHU_CARD_MAX_BYTES for card in cards)
async def test_long_final_answer_continues_in_further_cards(feishu):
channel, api = feishu.channel, feishu.api
channel._running_card_ids["source-1"] = "running-card"
api.cards["running-card"] = "thinking..."
answer = _long_answer()
for cut in (len(answer) // 3, 2 * len(answer) // 3):
await channel.send(_outbound(answer[:cut] + " ▉", final=False))
await channel.send(_outbound(answer, final=True))
assert api.rejected == []
continuation_ids = [card_id for source, card_id in api.replies if source == "source-1"]
shown = api.cards["running-card"] + "".join(api.cards[card_id] for card_id in continuation_ids)
assert shown == answer
assert len(continuation_ids) == len(_split_card_text(answer)) - 1
assert "source-1" not in channel._running_card_ids
async def test_streamed_update_over_budget_shows_what_fits(feishu):
channel, api = feishu.channel, feishu.api
channel._running_card_ids["source-1"] = "running-card"
answer = _long_answer()
await channel.send(_outbound(answer + " ▉", final=False))
assert api.rejected == []
assert api.replies == []
shown = api.cards["running-card"]
assert shown.endswith("\n\n…")
assert answer.startswith(shown.removesuffix("\n\n…"))
async def test_running_card_created_from_an_oversized_update_fits(feishu):
channel, api = feishu.channel, feishu.api
await channel.send(_outbound(_long_answer() + " ▉", final=False))
assert api.rejected == []
assert len(api.replies) == 1
assert channel._running_card_ids["source-1"] == api.replies[0][1]
async def test_retry_does_not_repeat_delivered_cards(feishu):
channel, api = feishu.channel, feishu.api
channel._running_card_ids["source-1"] = "running-card"
answer = _long_answer()
# The first continuation fails once; the retry resumes from it.
api.fail_next_reply = 1
await channel.send(_outbound(answer, final=True))
continuation_ids = [card_id for _, card_id in api.replies]
assert len(api.patches) == 1
assert api.cards["running-card"] + "".join(api.cards[card_id] for card_id in continuation_ids) == answer
store = channel.config["channel_store"]
for card_id in continuation_ids:
assert await store.get_thread_id("feishu", "oc-chat", topic_id=card_id) == "thread-1"
async def test_reply_without_running_card_and_push_without_source_split_too(feishu):
channel, api = feishu.channel, feishu.api
answer = _long_answer()
await channel.send(_outbound(answer, final=True))
await channel.send(_outbound(answer, final=True, source=None))
assert api.rejected == []
assert "".join(api.cards[card_id] for _, card_id in api.replies) == answer
assert "".join(text for _, text in api.created) == answer