Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 66 additions & 17 deletions frontends/fsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand All @@ -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 "_(无输出)_"
Expand All @@ -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}]
Expand All @@ -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"
Expand All @@ -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)


Expand Down
197 changes: 197 additions & 0 deletions frontends/tests/test_fsapp_card_rollover.py
Original file line number Diff line number Diff line change
@@ -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("", "")