You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
v0.9.0 introduced UnifiedGatewayAdapter, replacing the standalone GatewayAdapter for Feishu. This broke every Feishu streaming path, not just card mode. The original issue #1367 only reported the card-mode symptom; this issue documents the full scope confirmed by comparing v0.9.0-pre, v0.9.0, and PR #1458 code.
Root cause
The standalone GatewayAdapter (crates/openab-core/src/gateway.rs) ran a request/response protocol over WebSocket: send_message carried a request_id and waited for the real om_ message ID; edit_message carried a request_id and waited for the ack (so it could surface the 20-edit cap); delete_message was overridden to issue a real DELETE /im/v1/messages/{id}.
UnifiedGatewayAdapter (src/unified_adapter.rs) replaced all of this with fire-and-forget dispatch_reply:
send_message returns a synthetic unified_<uuid> ID, never the real om_.
edit_message is fire-and-forget with request_id: None.
delete_message has no override — it inherits the trait default edit_message(msg, "\u{200b}"), which in card mode routes back into handle_card_edit and deletes nothing.
Hardcoded false → core uses the "draft" sentinel as the MessageRef
Edit loop (every ~1.5s)
edit_message(reply_to="draft") → Feishu dispatch seam is_valid_feishu_message_id("draft") rejects it → silently skipped (debug log), PATCH never sent
edit_message return
Ok(()) — core thinks the edit landed
Turn end
msg.message_id == "draft" → core sends the full turn buffer as a new message via send_message
keep_full_text
streaming(true) || narration_display → true → send-once includes inter-tool narration
Net effect: streaming is an illusion. No incremental updates reach the client. The user sees the full reply (including narration) appear once at turn end. The edit loop runs uselessly every frame.
card (opt-in via FEISHU_CARD_STREAMING_MODE=card)
Same as above through the edit loop (all edits silently skipped). At turn end, try_send_initial_card fires with the full accumulated text — no CardKit typewriter effect. This is the symptom #1367 reported.
delete_message recovery (all modes)
The standalone adapter overrode delete_message specifically because:
Critical for the streaming-edit-cap recovery path: when Feishu's 20-edits-per-message cap is hit and we send full content as a fresh message, we need to remove the half-edited placeholder to avoid duplicated content. The default zero-width-edit fallback would itself fail on a cap-reached message, leaving the placeholder visible.
UnifiedGatewayAdapter never gained this override. Any code path in core that calls adapter.delete_message(&msg) (adapter.rs:1297, :1327, :1354) will, in unified Feishu card mode, route back into handle_card_edit with a zero-width-space payload — either overwriting the card content or no-op'ing on a finalized session. The placeholder is never actually deleted, so recovery paths produce visible duplication (stale card + fresh reply).
Idle reaper interaction (pre-existing, amplified by #1458)
FeishuStreamRegistry::idle_keys selects sessions with sequence > 0 && is_idle(idle_ms); card_idle_finalize_ms defaults to 3000ms. Core's cosmetic edit loop only sends an edit when the rendered text actually changes. During a tool call that produces no display change (e.g. sleep 15), no edit is sent, the reaper sees 3s of idleness, and finalizes the card mid-turn.
After finalize, every later edit hits Existing::Finalized (feishu.rs:2842), which reports success: true. Core sees Ok and does not fall back to send-once. The entire reply after the tool call is silently dropped — the user sees only the pre-tool placeholder.
This is pre-existing #1159 design (not introduced by v0.9.0), but:
In standalone mode it only triggers when [gateway] streaming = true (default false).
Prompt the agent to run a tool taking >3s (e.g. sleep 15), then produce a long answer
Observe: card shows pre-tool placeholder text, then nothing — the post-tool reply is lost
Logs: feishu card stream finalized (idle) fires during the tool call; subsequent Existing::Finalized returns success: true
delete recovery
Card mode, trigger the 20-edit cap (long response with many incremental updates)
Observe: stale card remains visible alongside the fresh full-text resend
Proposed fix scope
delete_message override on UnifiedGatewayAdapter — mirror the standalone override (gateway.rs:707), issue a fire-and-forget command: "delete_message" reply so Feishu's DELETE /im/v1/messages/{id} path (feishu.rs:2587) actually runs.
Idle reaper vs open turn — Existing::Finalized should report success: false (not true) so core's delete-and-resend fallback can trigger. Must land together with (1) or the fallback leaves a stale card. Root cause is that the reaper infers turn boundaries from idleness while the fact of a turn ending is known only to core; the stream_finish trait seam (adapter.rs:429) is a candidate for an explicit finish signal so the reaper does not finalize mid-turn.
post/auto keep_full_text regression — v0.9.0 accidentally preserved narration in send-once because streaming==true (even though streaming did not work). Any fix that correctly sets streaming=false for post/auto (as fix(gateway/feishu): enable CardKit typewriter streaming in card mode #1458 does) changes send-once content from full-buffer to final-answer-only. Either keep Draft strategy for post/auto to preserve v0.9.0 behavior, or decouple keep_full_text from the streaming flag.
@## Description
v0.9.0 introduced
UnifiedGatewayAdapter, replacing the standaloneGatewayAdapterfor Feishu. This broke every Feishu streaming path, not just card mode. The original issue #1367 only reported the card-mode symptom; this issue documents the full scope confirmed by comparing v0.9.0-pre, v0.9.0, and PR #1458 code.Root cause
The standalone
GatewayAdapter(crates/openab-core/src/gateway.rs) ran a request/response protocol over WebSocket:send_messagecarried arequest_idand waited for the realom_message ID;edit_messagecarried arequest_idand waited for the ack (so it could surface the 20-edit cap);delete_messagewas overridden to issue a realDELETE /im/v1/messages/{id}.UnifiedGatewayAdapter(src/unified_adapter.rs) replaced all of this with fire-and-forgetdispatch_reply:send_messagereturns a syntheticunified_<uuid>ID, never the realom_.edit_messageis fire-and-forget withrequest_id: None.delete_messagehas no override — it inherits the trait defaultedit_message(msg, "\u{200b}"), which in card mode routes back intohandle_card_editand deletes nothing.Impact by mode (v0.9.0 unified)
post/auto(default)use_streaming()true(unified always compilestelegramfeature;TELEGRAM_RICH_MESSAGESdefaultstrue)show_streaming_placeholder()false→ core uses the"draft"sentinel as the MessageRefedit_message(reply_to="draft")→ Feishu dispatch seamis_valid_feishu_message_id("draft")rejects it → silently skipped (debug log), PATCH never sentedit_messagereturnOk(())— core thinks the edit landedmsg.message_id == "draft"→ core sends the full turn buffer as a new message viasend_messagekeep_full_textstreaming(true) || narration_display→true→ send-once includes inter-tool narrationNet effect: streaming is an illusion. No incremental updates reach the client. The user sees the full reply (including narration) appear once at turn end. The edit loop runs uselessly every frame.
card(opt-in viaFEISHU_CARD_STREAMING_MODE=card)Same as above through the edit loop (all edits silently skipped). At turn end,
try_send_initial_cardfires with the full accumulated text — no CardKit typewriter effect. This is the symptom #1367 reported.delete_messagerecovery (all modes)The standalone adapter overrode
delete_messagespecifically because:UnifiedGatewayAdapternever gained this override. Any code path in core that callsadapter.delete_message(&msg)(adapter.rs:1297,:1327,:1354) will, in unified Feishu card mode, route back intohandle_card_editwith a zero-width-space payload — either overwriting the card content or no-op'ing on a finalized session. The placeholder is never actually deleted, so recovery paths produce visible duplication (stale card + fresh reply).Idle reaper interaction (pre-existing, amplified by #1458)
FeishuStreamRegistry::idle_keysselects sessions withsequence > 0 && is_idle(idle_ms);card_idle_finalize_msdefaults to 3000ms. Core's cosmetic edit loop only sends an edit when the rendered text actually changes. During a tool call that produces no display change (e.g.sleep 15), no edit is sent, the reaper sees 3s of idleness, and finalizes the card mid-turn.After finalize, every later edit hits
Existing::Finalized(feishu.rs:2842), which reportssuccess: true. Core seesOkand does not fall back to send-once. The entire reply after the tool call is silently dropped — the user sees only the pre-tool placeholder.This is pre-existing #1159 design (not introduced by v0.9.0), but:
[gateway] streaming = true(defaultfalse).use_streaming()defaulttrue, so every unified Feishu deployment is now exposed once streaming actually works (e.g. after fix(gateway/feishu): enable CardKit typewriter streaming in card mode #1458 enables card mode).Three-version comparison
streaming=true)keep_full_text=truekeep_full_text=falseunlessnarration_displaydelete_messagerecoveryDELETE /im/v1/messages/{id}handle_card_edit, deletes nothing)streaming=trueReproduction
post/auto fake streaming
FEISHU_CARD_STREAMING_MODEunsetfeishu: skipping command - draft placeholder has no real message_idat debug levelcard mode (with #1458 applied)
FEISHU_CARD_STREAMING_MODE=cardsleep 15), then produce a long answerfeishu card stream finalized (idle)fires during the tool call; subsequentExisting::Finalizedreturnssuccess: truedelete recovery
Proposed fix scope
delete_messageoverride onUnifiedGatewayAdapter— mirror the standalone override (gateway.rs:707), issue a fire-and-forgetcommand: "delete_message"reply so Feishu'sDELETE /im/v1/messages/{id}path (feishu.rs:2587) actually runs.Existing::Finalizedshould reportsuccess: false(nottrue) so core's delete-and-resend fallback can trigger. Must land together with (1) or the fallback leaves a stale card. Root cause is that the reaper infers turn boundaries from idleness while the fact of a turn ending is known only to core; thestream_finishtrait seam (adapter.rs:429) is a candidate for an explicit finish signal so the reaper does not finalize mid-turn.keep_full_textregression — v0.9.0 accidentally preserved narration in send-once becausestreaming==true(even though streaming did not work). Any fix that correctly setsstreaming=falsefor post/auto (as fix(gateway/feishu): enable CardKit typewriter streaming in card mode #1458 does) changes send-once content from full-buffer to final-answer-only. Either keepDraftstrategy for post/auto to preserve v0.9.0 behavior, or decouplekeep_full_textfrom thestreamingflag.Related
delete_messageoverride)