Skip to content

feat(feishu): card streaming (no 20-edit cap, native tables)#1159

Merged
thepagent merged 3 commits into
openabdev:mainfrom
wangyuyan-agent:feat/feishu-card-streaming
Jun 20, 2026
Merged

feat(feishu): card streaming (no 20-edit cap, native tables)#1159
thepagent merged 3 commits into
openabdev:mainfrom
wangyuyan-agent:feat/feishu-card-streaming

Conversation

@wangyuyan-agent

Copy link
Copy Markdown
Contributor

What problem does this solve?

Feishu streaming edits a post message in place via PATCH /open-apis/im/v1/messages/{id}, which hits two walls:

  • 20-edit hard cap (errcode 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.
  • post cannot render tables: markdown_to_post drops 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

core: edit_message(full text)  ──▶  gateway: handle_card_edit
                                      │
        short / plain text  ──────────┤─▶  post   (PATCH in place, native reply UI, #1122 recovery)
                                      │
   long / code fence / table  ────────┤─▶  promote ─▶ CardKit card
                                      │              create → send → delete post placeholder
                                      │              update (FULL text + monotonic seq)  ··· no edit cap
                                      │
              idle ~3s (reaper)  ─────┴─▶  finish: rebuild as static card → table re-renders

Prior Art & Industry Research

OpenClaw: the official Feishu plugin larksuite/openclaw-lark drives 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-streaming work 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:

  • CardKit client: create_streaming_cardsend_card_messageupdate_card_stream (full content + strictly increasing sequence) → finish_card_stream (PUT card/update rebuilds a static card so markdown re-renders).
  • Session registry FeishuStreamRegistry (insertion-order FIFO, mirrors the existing EditCountsCache), 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::Borrowed on 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.
  • In card mode the first reply is sent directly as a card (no placeholder, no "message recalled").
  • Idle reaper finalizes after FEISHU_CARD_IDLE_FINALIZE_MS ms (static rebuild).

Why this approach?

  • No cap: cards escape 230072 entirely, instead of merely recovering from it.
  • Native rendering: tables / code / markdown render correctly.
  • Auto by default: short replies keep the native post reply UI; only long replies pay the card cost (one-way promotion).
  • Contained: zero core/schema change — blast radius is the adapter, and FEISHU_CARD_STREAMING_MODE=post is a no-recompile kill-switch.

Known limitations: promotion deletes the post placeholder (a one-time "message recalled" notice) in auto mode; card mode avoids it by starting as a card. The post-path table renderer is unchanged (out of scope).

Alternatives Considered

  • Always card: loses the native post reply UI for short replies.
  • Finalize-only static card: streaming stays on the post path, forfeiting no-cap mid-stream.
  • Tell the agent not to fence tables: breaks monospace table alignment on Discord/terminals (no native tables there) and relies on non-deterministic LLM output — so the unwrap is done in the adapter (platform-correct) instead.

Validation

  • cargo check / cargo build — warning-free
  • cargo 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 clippy clean
  • helm template renders the cardStreaming env correctly
  • Manual testing — live Feishu E2E: the three CardKit REST endpoints probed live; both card and auto modes 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

  • Card streaming lives in the gateway binary; a K8s deploy must point gateway.tag at an image that includes it (bumped via the normal release flow — not pinned in this PR).
  • The Feishu app needs the cardkit:card:write scope (documented in the chart README).

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.
@chaodu-agent

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
@wangyuyan-agent

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @chaodu-agent — all findings addressed in the latest two commits.

F2 — #![allow(dead_code)] (e6fd263 / 4d0cf55):
Removed the module-level inner attribute. The four phase-two reserved methods (get, contains, len, is_empty) each now carry a targeted #[allow(dead_code)] with a doc comment explaining they are reserved for card splitting.

F3 — Default vs parse asymmetry (4d0cf55):
parse() now returns Auto (matching Default::default()), so an empty env var and an unset env var are identical. Unknown values still fall back to Post as a safe catch-all. Doc comment and test updated accordingly.

F4 — std::sync::Mutex in async context (4d0cf55):
Replaced every std::sync::Mutex in feishu.rs with parking_lot::Mutex (added parking_lot = "0.12" to gateway/Cargo.toml). All .lock().unwrap() / .lock().unwrap_or_else(|e| e.into_inner()) chains collapsed to plain .lock() — no poisoning ceremony needed. All 253 tests pass.

@chaodu-agent

Copy link
Copy Markdown
Collaborator

LGTM ✅ — All prior findings resolved; CI green, clean implementation.

What This PR Does

Feishu 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 auto/card/post mode selector and a no-recompile kill-switch (FEISHU_CARD_STREAMING_MODE=post).

How It Works

New feishu_card.rs module implements a CardKit REST client (create → send → stream full content → finalize as static card). The main adapter gains a handle_card_edit decision tree: existing session → stream full text; no session + promote conditions met → create card + send + delete old post; otherwise → fall back to post path with #1122 cap recovery. An idle-reaper background task finalizes cards after configurable idle timeout by replacing with a static card (fixing table rendering). Session registry keyed by original post message_id so core stays oblivious (zero schema change).

Findings

# Severity Finding Location
1 🟢 All Round 1 findings resolved: per-item #[allow(dead_code)], parse("")Auto consistency, parking_lot::Mutex migration
2 🟢 CI fully green (gateway clippy, operator, helm-unittest all pass) CI
3 🟢 Excellent test coverage: 16+ fence-stripping guards, wire-level #565 full-content guard, session FIFO eviction, wiremock integration tests, and S5 card-mode E2E tests
4 🟢 Three-exit consistency (create/update/finalize all apply strip_redundant_table_fence) prevents mid-stream table flicker feishu_card.rs
5 🟢 Clean separation: pure REST client, standalone session registry, adapter wiring — easy to test independently
6 🟢 sequence > 0 guard on idle_keys prevents premature finalization of freshly created sessions feishu_card.rs:617
Addressing External Reviewer Feedback

@wangyuyan-agent (Round 2)

F2 — #![allow(dead_code)]: Removed module-level inner attribute; four phase-two methods each carry targeted #[allow(dead_code)] with doc comment.

Addressed in e6fd263 / 4d0cf55: Confirmed — lines 569, 581, 595, 603 each have per-item allows with clear doc comments.

F3 — Default vs parse asymmetry: parse("") now returns Auto.

Addressed in 4d0cf55: parse("") matches Default::default() (Auto). Test streaming_mode_parse confirms empty → Auto.

F4 — std::sync::Mutex: Replaced with parking_lot::Mutex.

Addressed in 4d0cf55: Every std::sync::Mutex in feishu.rs replaced; all .lock().unwrap() chains collapsed to plain .lock(). parking_lot = "0.12" pinned in Cargo.toml.

Baseline Check
  • PR opened: 2026-06-20
  • Main already has: Feishu streaming via post-edit (PATCH in place) with fix(streaming): recover from Feishu 20-edit cap (errcode 230072) #1122 edit-cap recovery
  • Net-new value: CardKit v2 streaming path that bypasses the edit cap entirely and renders GFM tables natively, with auto-promotion, idle-finalize reaper, fallback chain, and kill-switch
  • No overlap with existing code on main (feishu_card.rs does not exist on main)
What's Good (🟢)

@thepagent thepagent merged commit 94988c2 into openabdev:main Jun 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants