fix(gateway/feishu): enable CardKit typewriter streaming in card mode - #1458
fix(gateway/feishu): enable CardKit typewriter streaming in card mode#1458SunnyYYLin wants to merge 1 commit into
Conversation
08bb37a to
c4eb580
Compare
Add FEISHU_CARD_STREAMING_MODE=card opt-in for Feishu CardKit editable-message streaming. When enabled, the unified adapter creates a streaming card placeholder and updates it in real-time as the agent generates content, providing a typewriter-like experience in Feishu. - StreamingStrategy enum (Disabled/Draft/EditablePlaceholder) as additive API; legacy trait methods retained for compatibility - unified_adapter selects EditablePlaceholder for Feishu only when FEISHU_CARD_STREAMING_MODE=card - main.rs starts Card mode idle reaper and restores Feishu WebSocket lifecycle (startup + graceful shutdown) - CardOutcome::Updated observability log (card_id, msg_id, seq) Default behavior unchanged: without the env var, all existing deployments continue using the post-edit path. Closes openabdev#1367
c4eb580 to
55df8b6
Compare
|
Note LGTM ✅ - The opt-in CardKit path preserves native Feishu message IDs and isolates gateway responses from unified user-event dispatch; no blocking findings. What This PR DoesThis PR restores visible CardKit typewriter streaming for unified Feishu deployments when How It Works
Findings
Finding Details🟢 F1: Backward-compatible strategy APIThe new trait methods have defaults, and the default strategy maps the pre-existing 🟢 F2: Correct native-ID response pathThe unified adapter subscribes before dispatch, filters by the exact request ID, waits only for Feishu responses, and returns the native 🟢 F3: Response/event separationThe embedded unified bridge recognizes 🟢 F4: Validation evidenceThe reviewed head has successful repository check runs, including Addressing External Reviewer FeedbackNo external GitHub review comments or threads were present at review time, so there are no unresolved external concerns. Baseline Check
5. Three Reasons We Might Not Need This PR
These are tradeoffs and follow-up considerations, not blocking findings for this change. What's Good (🟢)
|
There was a problem hiding this comment.
Note
LGTM ✅ - No blocking findings; the opt-in Feishu CardKit streaming path is backward-compatible and correctly correlates native message IDs.
Consolidated review: #1458 (comment)
wangyuyan-agent
left a comment
There was a problem hiding this comment.
Reviewed at head 55df8b6d. Verdict: request changes — not because the approach is wrong, but because three statements in the PR description do not match the code, and the recovery path this change depends on is not wired in unified mode.
Direction first, because it is sound. Issue #1367 laid out three options and recommended A (add platform: &str to the streaming trait methods, accepting a breaking change). What ships here is more accurate to describe as A's contract shell plus B's message-ID passthrough: streaming_strategy() gives per-channel strategy selection without the breaking parameter, while send_streaming_placeholder() + dispatch_with_response() — core waiting for a GatewayResponse before entering the edit loop — is precisely what the issue defined as Option B's mechanism, minus B's per-platform inner adapter. That combination is a good trade, and the default impls mean no existing ChatAdapter implementation changes behavior. dispatch_with_response subscribes before dispatching, so there is no lost-response race. Every CardOutcome arm in handle_card_edit emits a response, so the 5s wait cannot stall the loop on a rate-limited frame. try_send_initial_card falling back to the post path still yields a real om_ id.
Two things need to change before merge, plus two findings that need an answer.
Blocking 1 — three claims in the PR description are not true of the code
Each is independently checkable without a Feishu tenant:
-
"Late edits after finalize fall back to delete-and-resend (existing #1122 recovery path)."
handle_card_edit'sExisting::Finalizedarm callsemit_response(..., true, Some(om_post), None)—success: true. Core'sedit_messagetherefore returnsOk, and the finalization branch inAdapterRouteronly deletes-and-resends insideif let Err(e) = adapter.edit_message(...). For the reaper's own finalize, that recovery never runs. (It does run forCardOutcome::Failed, so the sentence is right for hard API failures and wrong for idle finalize — which is the case the paragraph is actually about.) -
"
main.rs: Passesstreaming_strategyinto the ACP turn loop."streaming_strategyhas zero occurrences insrc/main.rsat this head. The file's only changes are a comment relocation and theGatewayResponsefilter in the event bridge. The threading happens entirely insideAdapterRouter; nomain.rschange was needed. Please drop or correct the claim. -
"Only
send_streaming_placeholderwaits for the platform response."edit_messagealso waits, on every frame, wheneveruses_feishu_card_streaming(&msg.channel)is true — that is the point of the change and it is correct behavior, but it makes the "Accepted Residual Risks" statement aboutsend_messageremaining fire-and-forget read as broader than it is. Worth restating as "ordinarysend_messageremains fire-and-forget; the streaming placeholder and card-mode edits both wait."
Blocking 2 — unified never overrides delete_message, so the recovery this PR depends on cannot complete
UnifiedGatewayAdapter has no delete_message implementation, so it inherits the trait default at adapter.rs:396: self.edit_message(msg, "\u{200b}"). In card mode that edit is routed straight back into handle_card_edit — it does not delete anything. Depending on session state it either overwrites the card with a zero-width space or reports success and does nothing.
The standalone adapter already solved exactly this, and its own comment states why:
Override default
delete_message(which falls back to edit-to-zero-width) so platforms with native delete APIs (e.g. FeishuDELETE /im/v1/messages/{id}) can perform real deletions. Critical for the streaming-edit-cap recovery path … The default zero-width-edit fallback would itself fail on a cap-reached message, leaving the placeholder visible.
Since this PR is what makes core's edit errors reachable in unified mode at all, it is also what makes the missing override matter. Without it, every Err path that ends in delete-then-resend leaves the original card in place and appends a second copy of the reply. Mirroring the standalone override on UnifiedGatewayAdapter closes it.
This also constrains the fix for F1 below: reporting failure from a finalized session without a working delete would produce a stale card sitting above a fresh full reply. The two have to land together.
F1 — the idle reaper finalizes mid-turn and the reply is silently dropped
Mechanism, all three links checkable in-tree:
- Core's cosmetic edit loop issues an edit only when the rendered text actually changes (
buf_rx.has_changed()thencontent != last). Nothing is sent while a tool runs and produces no display change. FeishuStreamRegistry::idle_keysselects sessions withsequence > 0 && is_idle(idle_ms), andcard_idle_finalize_msdefaults to 3000.run_idle_reaperthen rebuilds the card as static and callsmark_finalized.- Every later edit lands on
Existing::Finalized, which reports success (Blocking 1, item 1). Core seesOk, so the end-of-turn delivery does not fall back either.
So one tool call longer than 3s closes the stream mid-turn, and the answer that follows it is discarded with no signal in the client or the logs.
Reproduced on PR head 55df8b6d merged with main at 53061d69 — the four files carrying this change (adapter.rs, unified_adapter.rs, feishu.rs, feishu_card.rs) are byte-identical to the PR head in that tree — with FEISHU_CARD_STREAMING_MODE=card on a real Feishu tenant over WebSocket. Prompt: run sleep 15 via shell, then write ~400 words plus a markdown table.
01:57:30 feishu first reply sent directly as card card_msg_id=om_x100…c0a
01:57:33 feishu card stream updated card_id=7668505399948233666 seq=1
01:57:37 feishu card stream finalized (idle) <- reaper; sleep 15 still running
01:57:37–01:58:10 agent emits 315 agent_message_chunk updates
01:58:10 turn ends
No further card stream updated, no warning, no error, no delete, no fresh send. The agent produced 892 characters including the complete table; the card, read back from the API, contains exactly 🔧 Running: sleep 15.... Precisely: what is lost is the first delivery chunk, i.e. the whole reply whenever it fits in one message. Content beyond message_limit still reaches the user, because the finalization path sends chunks.iter().skip(1) via send_message.
Scope. The Existing::Finalized arm and the reaper are pre-existing #1159 design, not introduced by this PR. Standalone gateway card mode can reach the same state, though only when [gateway] streaming = true — which is not the default (GatewayConfig.streaming defaults to false). What belongs to this PR is turning the path on for unified mode, which is the point of the change, and describing the risk backwards.
On the fix, and the order matters. Reporting success: false from Existing::Finalized is a safety net, not the fix — and on its own it converts a truncated card into a stale card plus a duplicate plain-text reply (see Blocking 2). The root cause is that the reaper infers turn boundaries from idleness, while the fact of a turn ending is known only to core, and the core→gateway command vocabulary has no terminal signal for the card lifecycle. This PR extends that lifecycle to start and update; the missing third state is finish. Note the trait already has a stream_finish seam (adapter.rs:429, currently used only on the native-streaming path) — an explicit finish command, or simply not finalizing while a turn is still open, addresses the cause. Then success: false plus the delete_message override from Blocking 2 becomes a correct backstop rather than a new failure mode.
F2 — post / auto defaults do change, contrary to the description
The body states default behavior is unchanged and that card mode is the only opt-in. For post and auto in unified mode that does not hold:
unified = ["telegram", "line", "feishu", …], so a unified binary always carries thetelegramfeature, andTELEGRAM_RICH_MESSAGESresolves viaunwrap_or(true). With neither that variable norTELEGRAM_STREAMINGset,UnifiedGatewayAdapter::use_streamingreturnstrue.- Before this PR that made Feishu take the streaming path with
show_streaming_placeholder() == false, i.e.StreamingStrategy::Draft. After it,streaming_strategyreturnsDisabledfor any mode other thancard. streamingdoes not only gate the placeholder:keep_full_text = streaming || narration_display. So the delivered message changes from the full turn buffer to the final answer block only — inter-tool narration disappears forpostandautousers who never opted into anything.
I think the new behavior is the better one — send-once should not replay narration the user never saw stream. The issue is that it is a user-visible change presented as a no-op, and the new tests cannot catch it because AppState::test_default sets telegram_rich_messages: false, which is the opposite of the from_env default. Suggest saying so in the body and adding a case with telegram_rich_messages: true asserting the intended post/auto strategy.
What's good
- Additive trait extension with defaults;
streaming_strategy_preserves_legacy_placeholder_contractpins the legacy mapping down. - Subscribe-before-dispatch, correlation by exact
request_id, ordinarysend_messageuntouched. - Filtering
openab.gateway.response.v1out of the unified inbound bridge is the right seam, and it is a necessary companion to introducingrequest_idon this path — before this change unified produced no responses at all.GatewayResponsecannot swallow inbound events either, sincerequest_idandsuccessare required fields absent from the event schema. - Deriving
streamingfrom a single strategy value instead of two independent predicates removes a real footgun for the next multiplexed adapter. - The
CardOutcome::Updatedlog is what made the timeline above legible.
| reply.request_id = Some(next_request_id()); | ||
| let message_id = self | ||
| .dispatch_with_response(&reply) | ||
| .await? |
There was a problem hiding this comment.
This path inverts three deliberate choices in the standalone equivalent. GatewayAdapter::edit_message uses EDIT_RESPONSE_TIMEOUT_MS = 800, returns Ok(()) on timeout — with the comment "Treat as success to avoid false-positive ❌; the cap-reached path already short-circuits much faster" — and returns Ok(()) when the response channel closes. dispatch_with_response uses 5000ms and turns both timeout and closed-channel into Err.
Consequences, in order of likelihood:
- A merely slow tenant (not a failing one) now produces an error at end-of-turn, which drives the delete-and-resend path: the card is dropped and the reply is re-sent as plain text. That is the false positive the standalone comment was written to avoid, and it is worse here because it discards a rendered card.
- On the placeholder specifically,
?lets a cosmetic element abort the whole turn: the caller surfaces an error to the user instead of degrading.send_streaming_placeholderfailing is a good reason to fall back toStreamingStrategy::Disabled(send-once), not to fail the turn. - A timeout on the placeholder also leaves an orphan: the card exists with
…and its session sits atsequence == 0, whichidle_keysdeliberately skips, so the reaper will never finalize it. It stays until FIFO eviction. RecvError::Lagged(_) => continuedrops frames silently; if the matching response is in a dropped frame the wait necessarily runs the full 5s and then reports failure. Worth aligning the timeout with the standalone value, or at least documenting why card mode needs 5s where post mode needs 800ms.
Measured for margin: creating and sending the initial card took ~1.7s of the 5s budget in a healthy local run (two REST round-trips).
| .duration_since(std::time::UNIX_EPOCH) | ||
| .unwrap_or_default() | ||
| .as_nanos() | ||
| .saturating_mul(1_000_000) |
There was a problem hiding this comment.
Two small things.
The name covers only one of the two uses — next_request_id() also produces the synthetic message_id returned by send_message and send_message_with_reply.
And saturating_mul is unreachable defense here: nanos-since-epoch is ~1.8e18, so the product is ~1.8e24 against a u128 ceiling of ~3.4e38. If it ever did saturate, every id would collapse to one value — worse than overflowing. Uniqueness, incidentally, does not come from the 1e6 slot: REQUEST_SEQUENCE never resets, so it will eventually exceed 1e6 and spill into the next timestamp's band. It holds because the timestamp is non-decreasing while the counter strictly increases, so t*1e6 + s is strictly increasing overall. A comment recording that reasoning, or checked_mul with an explicit branch, would be clearer than a saturating op that cannot fire.
What problem does this solve?
When
FEISHU_CARD_STREAMING_MODE=card, messages are delivered as CardKit cards but with no visible typewriter effect 鈥?the full reply appears all at once after the agent finishes. Root cause: the unified adapter'sshow_streaming_placeholder()returns false, so the core creates a draft placeholder (message_id="draft"), and all incrementaledit_messagecommands are skipped by theis_valid_feishu_message_idseam.This bug first appeared in v0.9.0, caused by the unified adapter replacing the legacy Feishu adapter's streaming path.
Closes #1367
Discord Discussion URL: https://discordapp.com/channels/1491295327620169908/1530153804333449226
Review Contract
Goal
Restore CardKit typewriter streaming for Feishu in unified mode by giving the streaming placeholder a real
om_message ID, so incremental edits flow throughhandle_card_edit鈫?update_card_streaminstead of being dropped at the draft seam.Non-goals
post).Accepted Residual Risks
FEISHU_CARD_IDLE_FINALIZE_MS, the idle reaper finalizes the card. Late edits after finalize fall back to delete-and-resend (existing fix(streaming): recover from Feishu 20-edit cap (errcode 230072) #1122 recovery path).send_messageremains fire-and-forget for non-streaming paths. Onlysend_streaming_placeholderwaits for the platform response (via a dedicated response channel). This keeps the change isolated to the streaming path.Acceptance Criteria
FEISHU_CARD_STREAMING_MODE=card, behavior is identical to before (post-edit path).FEISHU_CARD_STREAMING_MODE=card, real CardKit updates stream during generation.cargo test --features unifiedpasses.cargo clippy --workspace --features unified -- -D warningsclean.Follow-ups
At a Glance
Prior Art & Industry Research
OpenClaw:
Uses draft-message editing for Telegram streaming (edit_message every N tokens). Feishu path uses post-edit with a 20-edit cap before promoting to card.
Hermes Agent:
N/A 鈥?Hermes does not support Feishu.
Other references:
Proposed Solution
The fix restores the pre-v0.9.0 streaming path through the unified adapter architecture:
adapter.rs: AdditiveStreamingStrategyenum (Disabled / Draft / EditablePlaceholder) +streaming_strategy()method with default impl. Legacyuse_streaming()/show_streaming_placeholder()retained 鈥?no trait break.unified_adapter.rs: SelectsEditablePlaceholderfor Feishu whenFEISHU_CARD_STREAMING_MODE=card. Addsdispatch_with_response()鈥?a request-response channel that waits for the Feishu adapter to return the realom_message ID. Only the streaming placeholder path uses this; normalsend_messageremains fire-and-forget.main.rs: Passesstreaming_strategyinto the ACP turn loop .feishu.rs: +1 line observability log onCardOutcome::Updated(card_id, msg_id, seq).Why a response channel instead of modifying
send_message?The unified adapter's
send_messageis fire-and-forget by design. Changing it to always wait for a platform response would:The response channel isolates the wait to
send_streaming_placeholderonly.Alternatives Considered
send_messageto return real IDs: Fewer lines (~50 vs ~140) but changes behavior for all platforms and all message paths. Rejected 鈥?too broad for an opt-in feature.show_streaming_placeholder() 鈫?true): Works but leaks Feishu-specific semantics into the generic adapter trait. No mechanism for adapters to customize placeholder behavior.Validation
cargo test --features unified鈥?all passcargo clippy --workspace --features unified -- -D warnings鈥?cleancargo build --release --features unified鈥?success