From 26d6f41f97675c73d4cf23479a019f5dce7f8ae2 Mon Sep 17 00:00:00 2001 From: Louisss Date: Thu, 1 Oct 2026 17:17:45 +0800 Subject: [PATCH] fsapp: roll over oversized Feishu task cards, keep turn numbering (#814) Long Feishu tasks stopped updating around step ~50: _push() swallowed the platform card-size error (230099/11310) and step() ignored the result, so the card silently froze while the agent kept running. - _push() returns (ok, limit); the create path also reports limit=True, since a None msg_id otherwise sends every later push down the failing create path (defect 2 in the issue) - new _rollover() clears steps and bumps page_no, so the continuation card is actually smaller than the overflowing one (defect 1); turn_base keeps Turn numbering contiguous across cards - proactive rollover at 50 steps (measured overflow ~58) instead of waiting for the API to reject the card - done()/fail() roll over and retry before falling back to plain text 11 new tests via the repo's ast/exec pattern; full suite 298 passed (same 3 pre-existing Windows-env failures as before). --- frontends/fsapp.py | 83 +++++++-- frontends/tests/test_fsapp_card_rollover.py | 197 ++++++++++++++++++++ 2 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 frontends/tests/test_fsapp_card_rollover.py diff --git a/frontends/fsapp.py b/frontends/fsapp.py index ad959f660..41302706e 100644 --- a/frontends/fsapp.py +++ b/frontends/fsapp.py @@ -430,19 +430,29 @@ def _send_raw(receive_id, payload, msg_type, rtype): return None +_CARD_LIMIT_MARKERS = ("230099", "11310", "element exceeds the limit") + +def _is_card_limit_error(code, msg): + text = f"{code or ''} {msg or ''}".lower() + return any(m in text for m in _CARD_LIMIT_MARKERS) + def _patch_card(message_id, card_json): + return _patch_card_result(message_id, card_json)[0] + +def _patch_card_result(message_id, card_json): try: body = PatchMessageRequest.builder().message_id(message_id).request_body( PatchMessageRequestBody.builder().content(card_json).build() ).build() r = client.im.v1.message.patch(body) - if not r.success(): - print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") - return r.success() + if r.success(): + return True, False + print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") + return False, _is_card_limit_error(getattr(r, 'code', ''), getattr(r, 'msg', '')) except Exception as e: print(f"[ERROR] patch_card exception: {e}") traceback.print_exc() - return False + return False, False def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"): @@ -633,8 +643,10 @@ def _build_step_detail(resp, tool_calls): class _TaskCard: - """飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。""" + """飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。 + 触达飞书卡片尺寸/元素上限时自动 rollover 到新卡片,step 编号跨卡片连续(#814)。""" _DETAIL_LIMIT = 8000 + _PAGE_STEPS = 50 # 主动 rollover 阈值:实测 8000 字符/步时约 58 步触顶,提前翻页 def __init__(self, receive_id, rid_type): self.rid, self.rtype = receive_id, rid_type @@ -644,6 +656,10 @@ def __init__(self, receive_id, rid_type): self.msg_id = None self.start_fallback_sent = False self.final_fallback_sent = False + self.page_no = 1 + self.turn_no = 0 + self.turn_base = 1 + self.note = None def _step_panel(self, idx, summary, detail): detail = detail or "_(无输出)_" @@ -656,8 +672,13 @@ def _step_panel(self, idx, summary, detail): } def _build(self): - els = [{"tag": "markdown", "content": f"**{self.status}**"}] - for i, (s, d) in enumerate(self.steps, 1): + header = f"**{self.status}**" + if self.page_no > 1: + header += f"\n\n📄 工作卡片 {self.page_no}" + els = [{"tag": "markdown", "content": header}] + if self.note: + els.append({"tag": "markdown", "content": self.note}) + for i, (s, d) in enumerate(self.steps, self.turn_base): els.append(self._step_panel(i, s, d)) if self.final: els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}] @@ -666,11 +687,18 @@ def _build(self): def _push(self): card = self._build() if self.msg_id: - ok = _patch_card(self.msg_id, card) - else: - self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype) - ok = bool(self.msg_id) - return ok + return _patch_card_result(self.msg_id, card) + self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype) + # create 失败同样按 limit 处理:msg_id 为 None 时后续 push 都会走 create, + # 不 rollover 就永远发不出去(#814 缺陷 2) + return bool(self.msg_id), not self.msg_id + + def _rollover(self): + self.page_no += 1 + self.msg_id = None + self.final = None + self.note = "⚠️ 上一张工作卡片达到飞书限制,本页继续展示后续进展。" + self.steps = [] # 不清 steps 的话新卡片和旧的一样大,rollover 等于没做(#814 缺陷 1) def _fallback_text(self, text, *, final=False): attr = "final_fallback_sent" if final else "start_fallback_sent" @@ -682,23 +710,44 @@ def _fallback_text(self, text, *, final=False): # ── 公开接口 ── def start(self): - if not self._push(): + ok, _ = self._push() + if not ok: self._fallback_text("🤔 思考中...") def step(self, summary, detail=""): + self.turn_no += 1 + self.status = f"⏳ 工作中 · Turn {self.turn_no}" + if len(self.steps) >= self._PAGE_STEPS: + self._rollover() + self.turn_base = self.turn_no self.steps.append((summary, detail)) - self.status = f"⏳ 工作中 · Turn {len(self.steps)}" - self._push() + ok, limit = self._push() + if limit: + self._rollover() + self.turn_base = self.turn_no + self.steps = [(summary, detail)] + ok, _ = self._push() def done(self, text): self.status = "✅ 已完成" self.final = text or "_(无文本输出)_" - if not self._push(): + ok, limit = self._push() + if limit: + self._rollover() + self.turn_base = self.turn_no + 1 + self.final = text or "_(无文本输出)_" # _rollover 清了 final,重设后再推新卡 + ok, _ = self._push() + if not ok: self._fallback_text(_display_text(text), final=True) def fail(self, msg): self.status = f"❌ {msg}" - if not self._push(): + ok, limit = self._push() + if limit: + self._rollover() + self.turn_base = self.turn_no + 1 + ok, _ = self._push() + if not ok: self._fallback_text(f"❌ {msg}", final=True) diff --git a/frontends/tests/test_fsapp_card_rollover.py b/frontends/tests/test_fsapp_card_rollover.py new file mode 100644 index 000000000..883bd0d5b --- /dev/null +++ b/frontends/tests/test_fsapp_card_rollover.py @@ -0,0 +1,197 @@ +"""Tests for Feishu task-card rollover (#814). + +fsapp.py imports lark_oapi at module load, so _TaskCard and the limit-error +helper are extracted from source via ast/exec (same pattern as +test_bridge_utils.py) and exercised against a fake transport. +Run: pytest frontends/tests/test_fsapp_card_rollover.py -v +""" +import ast +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +_FSAPP_SRC = (ROOT / "frontends" / "fsapp.py").read_text(encoding="utf-8") + + +class FakeTransport: + """Scriptable stand-in for the lark-oapi transport layer.""" + + def __init__(self): + self.patched = [] # [(msg_id, elements)] + self.created = [] # [elements] + self.patch_limit_after = None # Nth patch onward fails with a card-limit error + self.create_fail = False + self.fallback_texts = [] + + def card_raw(self, elements): + return json.dumps({"body": {"elements": elements}}, ensure_ascii=False) + + def send_raw(self, rid, payload, msg_type, rtype): + elements = json.loads(payload)["body"]["elements"] + self.created.append(elements) + return None if self.create_fail else f"msg-{len(self.created)}" + + def patch_result(self, message_id, card_json): + elements = json.loads(card_json)["body"]["elements"] + self.patched.append((message_id, elements)) + if self.patch_limit_after is not None and len(self.patched) > self.patch_limit_after: + return False, True # 230099-style platform card limit + return True, False + + def send_message(self, rid, text, receive_id_type="open_id"): + self.fallback_texts.append(text) + + +def _load(transport): + tree = ast.parse(_FSAPP_SRC) + wanted = {"_TaskCard", "_is_card_limit_error", "_CARD_LIMIT_MARKERS"} + nodes = [ + n for n in tree.body + if (isinstance(n, (ast.ClassDef, ast.FunctionDef)) and n.name in wanted) + or (isinstance(n, ast.Assign) + and {t.id for t in n.targets if isinstance(t, ast.Name)} & wanted) + ] + ns = { + "_card_raw": transport.card_raw, + "_send_raw": transport.send_raw, + "_patch_card_result": transport.patch_result, + "send_message": transport.send_message, + "_display_text": lambda t: t or "", + } + exec(compile(ast.Module(body=nodes, type_ignores=[]), "fsapp.py", "exec"), ns) + return ns["_TaskCard"], ns["_is_card_limit_error"] + + +def _panel_titles(elements): + return [e["header"]["title"]["content"] for e in elements if e.get("tag") == "collapsible_panel"] + + +def _header(elements): + return elements[0]["content"] + + +def _markdown_texts(elements): + return [e["content"] for e in elements if e.get("tag") == "markdown"] + + +class TestNormalFlow: + def test_single_card_patches_in_place(self): + t = FakeTransport() + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + card.step("读文件", "detail-1") + card.step("写文件", "detail-2") + assert card.msg_id == "msg-1" + assert [m for m, _ in t.patched] == ["msg-1", "msg-1"] + assert _panel_titles(t.patched[-1][1]) == ["Turn 1 · 读文件", "Turn 2 · 写文件"] + assert card.page_no == 1 + assert t.fallback_texts == [] + + def test_create_failure_at_start_falls_back_to_text(self): + t = FakeTransport() + t.create_fail = True + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + assert t.fallback_texts == ["🤔 思考中..."] + + +class TestReactiveRollover: + def test_patch_limit_rolls_over_to_smaller_card(self): + t = FakeTransport() + t.patch_limit_after = 1 # 2nd patch hits the platform limit + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + card.step("s1", "d1") # patch #1 ok + card.step("s2", "d2") # patch #2 -> limit -> rollover + + assert card.page_no == 2 + assert card.msg_id == "msg-2" # new card created + new_elements = t.created[-1] + assert _panel_titles(new_elements) == ["Turn 2 · s2"] # steps cleared: new card is small + assert "📄 工作卡片 2" in _header(new_elements) + assert any("上一张工作卡片达到飞书限制" in c for c in _markdown_texts(new_elements)) + assert card.turn_base == 2 + + def test_turn_numbering_stays_contiguous_across_cards(self): + t = FakeTransport() + t.patch_limit_after = 1 + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + card.step("s1", "d1") + card.step("s2", "d2") # rollover happens here + card.step("s3", "d3") # lands on the new card + assert _panel_titles(t.patched[-1][1]) == ["Turn 2 · s2", "Turn 3 · s3"] + + def test_create_failure_is_treated_as_limit_and_retries(self): + t = FakeTransport() + t.create_fail = True + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() # create #1 fails -> text fallback + card.step("s1", "d1") # create #2 fails -> limit -> rollover -> create #3 + assert len(t.created) == 3 + assert card.page_no == 2 + assert t.fallback_texts == ["🤔 思考中..."] + + def test_done_rolls_over_and_keeps_final_text(self): + t = FakeTransport() + t.patch_limit_after = 1 + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + card.step("s1", "d1") + card.done("最终答案") + assert t.fallback_texts == [] # recovered via rollover + assert any("最终答案" in c for c in _markdown_texts(t.created[-1])) + + def test_fail_rolls_over_and_keeps_error_status(self): + t = FakeTransport() + t.patch_limit_after = 1 + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + card.step("s1", "d1") + card.fail("boom") + assert t.fallback_texts == [] + assert "❌ boom" in _header(t.created[-1]) + + def test_persistent_failure_falls_back_once_per_boundary(self): + t = FakeTransport() + t.create_fail = True + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + card.fail("boom") + assert t.fallback_texts == ["🤔 思考中...", "❌ boom"] + + +class TestProactiveRollover: + def test_threshold_flips_page_before_api_rejects(self): + t = FakeTransport() + TaskCard, _ = _load(t) + card = TaskCard("rid", "open_id") + card.start() + for i in range(1, 51): + card.step(f"s{i}", f"d{i}") + assert card.page_no == 1 # 50 steps fit under the threshold + card.step("s51", "d51") # 51st trips the proactive rollover + assert card.page_no == 2 + assert card.turn_base == 51 + assert _panel_titles(t.created[-1]) == ["Turn 51 · s51"] # rollover pushed a fresh small card + + +class TestLimitErrorMarkers: + def test_platform_limit_codes_detected(self): + _, is_limit = _load(FakeTransport()) + assert is_limit(230099, "Failed to create card content") + assert is_limit(11310, "element exceeds the limit") + assert is_limit("", "Element Exceeds The Limit") # case-insensitive + + def test_other_errors_are_not_limit(self): + _, is_limit = _load(FakeTransport()) + assert not is_limit(99991672, "token expired") + assert not is_limit("", "")