diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d0fb0c74..2bd39dece6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 403e2cc559..910021ac80 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/README.md b/README.md index 4ef02eab5f..091083fa84 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index ee2e14fd7d..d6216c8b6e 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -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 ---------------------------------------------------------- diff --git a/backend/docs/IM_CHANNEL_CONNECTIONS.md b/backend/docs/IM_CHANNEL_CONNECTIONS.md index fc7137490e..382c42453d 100644 --- a/backend/docs/IM_CHANNEL_CONNECTIONS.md +++ b/backend/docs/IM_CHANNEL_CONNECTIONS.md @@ -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 diff --git a/backend/tests/test_feishu_card_size.py b/backend/tests/test_feishu_card_size.py new file mode 100644 index 0000000000..4a2b8955d5 --- /dev/null +++ b/backend/tests/test_feishu_card_size.py @@ -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