Skip to content

Feishu image/file replies lose media on withdrawn/recalled reply targets that text replies survive #98311

Description

@yetval

Summary

On Feishu, an agent-generated image or file reply is silently lost when the reply target has been withdrawn/recalled/deleted, because sendImageFeishu and sendFileFeishu do a bare client.im.message.reply(...) with none of the withdrawn/not-found top-level fallback that text and card replies get. A text reply to the same withdrawn target recovers by falling back to a top-level client.im.message.create, but the media reply throws and no message is ever delivered.

Environment

  • Commit: 518937b (origin/main at time of filing)
  • Surface: Feishu plugin outbound media delivery (extensions/feishu/src/media.ts)

Steps to reproduce

  1. Agent replies into a Feishu chat quoting a specific message (replyToMessageId set), non-thread reply (or a reply where a top-level fallback is otherwise allowed).
  2. The quoted message is withdrawn/recalled/deleted before the reply is sent, so Feishu returns code 230011 (The message was withdrawn.) or 231003 (The message is not found), or throws the equivalent SDK/Axios error.
  3. If the reply is text or a card, delivery survives: the send falls back to a top-level im.message.create.
  4. If the reply carries an image or file (sendMediaFeishu -> sendImageFeishu / sendFileFeishu), the send throws and the media is never delivered.

Expected

The media reply inherits the same withdrawn/not-found handling as text/card: when the reply target is unavailable and a top-level fallback is allowed, fall back to a top-level im.message.create so the image/file still reaches the chat.

Actual

sendImageFeishu / sendFileFeishu throw (Feishu image reply failed: ... / Feishu file reply failed: ...) and im.message.create is never called. The agent-generated media is dropped with no delivery.

Root cause

Text and card replies route through sendReplyOrFallbackDirect (extensions/feishu/src/send.ts:146), which both catches a withdrawn/not-found thrown error and inspects the response body:

  } catch (err) {
    if (!isWithdrawnReplyError(err)) {
      throw err;
    }
    if (replyTargetFallbackError) {
      throw replyTargetFallbackError;
    }
    return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
  }
  if (shouldFallbackFromReplyTarget(response)) {
    if (replyTargetFallbackError) {
      throw replyTargetFallbackError;
    }
    return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
  }

sendMessageFeishu (send.ts) and sendCardFeishu (send.ts) both call it. The media path does not. sendImageFeishu (extensions/feishu/src/media.ts:517) and sendFileFeishu (extensions/feishu/src/media.ts:571) run a bare reply plus assertFeishuMessageApiSuccess with neither guard:

  if (replyToMessageId) {
    const response = await requestFeishuApi(
      () =>
        client.im.message.reply({
          path: { message_id: replyToMessageId },
          data: { content, msg_type: "image", ...(replyInThread ? { reply_in_thread: true } : {}) },
        }),
      "Feishu image reply failed",
      { includeNestedErrorLogId: true },
    );
    assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
    return toFeishuSendResult(response, receiveId, "media");
  }

sendMediaFeishu forwards replyToMessageId straight into these unguarded paths, so a withdrawn/not-found target throws and the media is dropped.

Scope

  • Fires on non-thread replies, or when a top-level fallback is otherwise allowed. This is the same class the text/card fallback was built to recover, added in the merged PR fix(feishu): fall back from missing thread replies #80306 (which touched send.ts and reply-dispatcher.ts but not media.ts).
  • For a native reply_in_thread reply WITHOUT allowTopLevelReplyFallback, the text path also deliberately throws (replyTargetFallbackError, send.ts) rather than cross a thread boundary. So the fair claim is that plain group/DM media replies lose media where text recovers, not that text always recovers.
  • Feishu-only, one media message per event, and requires a withdrawn/deleted/not-found reply target, so this is a P2 data-loss bug rather than a broad outage.

Sibling surfaces

Real behavior proof

Behavior addressed: Feishu image/file replies to a withdrawn/not-found reply target are dropped where a text reply to the same target survives via top-level fallback.
Real environment tested: drove the real sendMessageFeishu, sendImageFeishu, and sendFileFeishu at commit 518937b, with only the Feishu network client stubbed at its narrowest seam (resolveFeishuSendTarget), so reply() returns/throws Feishu withdrawn code 230011 and create() succeeds; every send function, the fallback decision, and sendMediaFeishu routing stayed real.
Exact steps or command run after this patch: drive text, image, and file replies to the same withdrawn (code 230011) reply target and record whether the top-level create() was reached.
Evidence after fix:

# OBSERVED (buggy, origin/main 518937be4e)
TEXT reply withdrawn(230011): create called = 1, messageId = om_text_fallback
IMAGE reply withdrawn(230011): threw = "Feishu image reply failed: The message was withdrawn.", create called = 0
FILE reply withdrawn-throw(230011): threw = "Feishu file reply failed: {\"message\":\"The message was withdrawn.\"}", create called = 0

# EXPECTED (identical inputs, media path routed through the same withdrawn/not-found fallback)
IMAGE reply withdrawn(230011): create called = 1, messageId = om_image_fallback

Observed result after fix: on origin/main the text reply survives (top-level create reached) while the image and file replies throw and never reach create, so the media is lost; routing the media reply through the same fallback makes the image survive on identical inputs.
What was not tested: not exercised against the live Feishu Open Platform API (the withdrawn code and error shapes are modeled from the existing WITHDRAWN_REPLY_ERROR_CODES set and send.reply-fallback fixtures); native reply_in_thread without allowTopLevelReplyFallback is intentionally out of scope since the text path also throws there by design.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.no-staleExclude from stale automation

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions