feat(feishu): card streaming (no 20-edit cap, native tables)#1159
Conversation
CardKit v2 interactive card streaming. Default `auto`: short replies stay as a post (native reply UI), long/code/table replies promote to a card — no 20-edit cap (errcode 230072), native markdown/table rendering. core and schema unchanged; all in the gateway adapter. FEISHU_CARD_STREAMING_MODE=post is the no-recompile kill-switch.
This comment has been minimized.
This comment has been minimized.
- F2: remove module-level #![allow(dead_code)] in feishu_card.rs; add
per-item #[allow(dead_code)] to the four phase-two reserved methods
(get, contains, len, is_empty) with explanatory doc comments
- F3: align StreamingMode::parse semantics with Default::default() —
parse("") now returns Auto instead of Post, so an empty env var and
an unset env var have identical behavior; update doc comment and test
- F4: replace std::sync::Mutex with parking_lot::Mutex across all
session/cache access paths in feishu.rs; removes blocking-lock risk
on Tokio worker threads (short critical sections, no .await held);
no poisoning ceremony needed — all .unwrap()/.unwrap_or_else() chains
dropped; 253 tests pass
|
Thanks for the thorough review @chaodu-agent — all findings addressed in the latest two commits. F2 — F3 — F4 — |
|
LGTM ✅ — All prior findings resolved; CI green, clean implementation. What This PR DoesFeishu streaming replies hit a hard 20-edit cap (errcode 230072) and cannot render GFM tables. This PR adds a CardKit v2 streaming path that bypasses the edit cap entirely and renders markdown/tables natively, with an How It WorksNew Findings
Addressing External Reviewer Feedback@wangyuyan-agent (Round 2)
✅ Addressed in
✅ Addressed in
✅ Addressed in Baseline Check
What's Good (🟢)
|
What problem does this solve?
Feishu streaming edits a
postmessage in place viaPATCH /open-apis/im/v1/messages/{id}, which hits two walls:230072): a long streamed reply is edited dozens of times and rejected after 20. PR fix(streaming): recover from Feishu 20-edit cap (errcode 230072) #1122 added cap-recovery to stop the silent truncation, but it does not escape the cap itself.postcannot render tables:markdown_to_postdrops GFM tables — the|characters surface as plain text.Relates to #1124 (cards render tables/markdown natively; the post-path renderer is left unchanged).
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1500160821567684660
At a Glance
Prior Art & Industry Research
OpenClaw: the official Feishu plugin
larksuite/openclaw-larkdrives CardKit + streaming (im/v1/cards/cardkit/v1). We borrow its API call sequence and rate-limit/error-code handling. openclaw-lark issue #565 (the stream-text body must be the full accumulated value, not a delta) is encoded as a wire-level guard test.Hermes Agent: the community
lark-streamingwork takes the same CardKit route; its issue #63 (card-split retry storm → duplicate sends) informs the failure-cleanup path and its test.Other references: Feishu CardKit v1 docs (
open.feishu.cn/document/cardkit-v1/*) —card/create,card-element/content(streaming text),card/update(full static rebuild), verified against a live Feishu probe.Not a fork: reimplemented as a lean Rust module (~700 lines of logic + tests).
Proposed Solution
New
gateway/src/adapters/feishu_card.rs:create_streaming_card→send_card_message→update_card_stream(full content + strictly increasingsequence) →finish_card_stream(PUT card/updaterebuilds a static card so markdown re-renders).FeishuStreamRegistry(insertion-order FIFO, mirrors the existingEditCountsCache), keyed by the placeholder post message_id so core stays oblivious to the post→card swap.strip_redundant_table_fence: agents often wrap a table in a bare ``` fence for monospace alignment in environments that don't render tables. On the card path we unwrap a fence whose body is exactly one complete GFM table (closed, untagged fences only;Cow::Borrowedon no-match — never drops bytes).Wiring in
feishu.rs(no core/schema change):should_use_card— auto promotion rule (size / code fence / table).handle_card_edit— three paths: promote post→card, stream a full snapshot to an existing card, or fall back to the post path (with fix(streaming): recover from Feishu 20-edit cap (errcode 230072) #1122 cap-recovery) on failure.cardmode the first reply is sent directly as a card (no placeholder, no "message recalled").FEISHU_CARD_IDLE_FINALIZE_MSms (static rebuild).Why this approach?
230072entirely, instead of merely recovering from it.postreply UI; only long replies pay the card cost (one-way promotion).FEISHU_CARD_STREAMING_MODE=postis a no-recompile kill-switch.Known limitations: promotion deletes the post placeholder (a one-time "message recalled" notice) in
automode;cardmode avoids it by starting as a card. The post-path table renderer is unchanged (out of scope).Alternatives Considered
postreply UI for short replies.Validation
cargo check/cargo build— warning-freecargo test— 249 passed (fence stripping incl. unclosed/nested/false-positive guards; session FIFO + idle; CardKit client via wiremock incl. fix(ci): upload binaries to correct release tag #565 full-content guard and chore: bump chart to 0.3.1-beta.23 #63 cleanup path; auto routing; first-send-as-card)cargo clippycleanhelm templaterenders thecardStreamingenv correctlycardandautomodes verified end-to-end — table + code rendering, typewriter effect, idle finalize, no recall in card mode, no edit cap. core and schema untouched.Deployment notes
gateway.tagat an image that includes it (bumped via the normal release flow — not pinned in this PR).cardkit:card:writescope (documented in the chart README).