Skip to content

bug(gateway/feishu): v0.9.0 unified mode broke all Feishu streaming modes + delete recovery #1464

Description

@SunnyYYLin

@## Description

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.

Impact by mode (v0.9.0 unified)

post / auto (default)

Stage What happens
use_streaming() Returns true (unified always compiles telegram feature; TELEGRAM_RICH_MESSAGES defaults true)
show_streaming_placeholder() 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_displaytrue → 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:

Three-version comparison

Dimension v0.9.0-pre (standalone) v0.9.0 (unified) PR #1458 (card mode)
post/auto streaming real PATCH updates (needs streaming=true) fake streaming (edits silently rejected, send-once fallback) explicitly Disabled
post/auto send-once content N/A (streaming) full turn buffer (incl. narration) — keep_full_text=true final answer only — keep_full_text=false unless narration_display
card mode streaming N/A fake streaming (same draft rejection) real CardKit typewriter
delete_message recovery real DELETE /im/v1/messages/{id} ZWSP edit (routed into handle_card_edit, deletes nothing) still missing override
idle reaper exposure only if streaming=true exposed (streaming defaults true) but edits never land so reaper never fires exposed and edits do land → 3s tool call drops the reply

Reproduction

post/auto fake streaming

  1. v0.9.0 unified binary, Feishu enabled, FEISHU_CARD_STREAMING_MODE unset
  2. Send a prompt that takes >3s to generate
  3. Observe: no incremental updates; full reply appears at turn end
  4. Logs show repeated feishu: skipping command - draft placeholder has no real message_id at debug level

card mode (with #1458 applied)

  1. Set FEISHU_CARD_STREAMING_MODE=card
  2. Prompt the agent to run a tool taking >3s (e.g. sleep 15), then produce a long answer
  3. Observe: card shows pre-tool placeholder text, then nothing — the post-tool reply is lost
  4. Logs: feishu card stream finalized (idle) fires during the tool call; subsequent Existing::Finalized returns success: true

delete recovery

  1. Card mode, trigger the 20-edit cap (long response with many incremental updates)
  2. Observe: stale card remains visible alongside the fresh full-text resend

Proposed fix scope

  1. 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.
  2. Idle reaper vs open turnExisting::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.
  3. 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.

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions