diff --git a/src/auto-reply/reply/followup-media.ts b/src/auto-reply/reply/followup-media.ts
index dca7b4b414b..19b2c061c8c 100644
--- a/src/auto-reply/reply/followup-media.ts
+++ b/src/auto-reply/reply/followup-media.ts
@@ -292,27 +292,21 @@ export async function applyDeferredMediaUnderstandingToQueuedRun(
return;
}
- const referenceBody = mediaContext.RawBody ?? mediaContext.Body;
- // Prefer RawBody-vs-Body comparison when RawBody exists. If RawBody is
- // missing, any real ... block plus file-like attachments means
- // extraction already ran, even if the stored name came from Content-Disposition
- // instead of the attachment path/url basename.
- if (!mediaContext.DeferredFileBlocksExtracted && hasAnyFileAttachments(mediaContext)) {
- const rawBodyMissing = typeof mediaContext.RawBody !== "string";
- if (mediaContext.Body !== referenceBody) {
- mediaContext.DeferredFileBlocksExtracted = true;
- } else if (
- rawBodyMissing &&
- (Boolean(mediaContext.MediaUnderstanding?.length) ||
- bodyContainsExtractedFileBlock(mediaContext.Body))
- ) {
- mediaContext.DeferredFileBlocksExtracted = true;
- }
- }
if (mediaContext.MediaUnderstanding?.length) {
mediaContext.DeferredMediaApplied = true;
return;
}
+ // Treat followup file extraction as already applied only when we have explicit
+ // evidence: the queue snapshot already flagged it or Body already contains a
+ // real extracted ... block. Body/RawBody mismatches are not
+ // reliable because some channels wrap Body with envelope metadata.
+ if (
+ !mediaContext.DeferredFileBlocksExtracted &&
+ hasAnyFileAttachments(mediaContext) &&
+ bodyContainsExtractedFileBlock(mediaContext.Body)
+ ) {
+ mediaContext.DeferredFileBlocksExtracted = true;
+ }
if (mediaContext.DeferredFileBlocksExtracted && hasOnlyFileLikeAttachments(mediaContext)) {
mediaContext.DeferredMediaApplied = true;
diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts
index 0c8ac63d4d7..f766aa57321 100644
--- a/src/auto-reply/reply/followup-runner.test.ts
+++ b/src/auto-reply/reply/followup-runner.test.ts
@@ -1231,6 +1231,54 @@ describe("createFollowupRunner media understanding", () => {
expect(agentCall?.prompt?.match(/ {
+ const fileBlock = '\nreport content\n';
+ applyMediaUnderstandingMock.mockImplementationOnce(
+ async (params: { ctx: Record }) => {
+ params.ctx.Body = `summarize this\n\n${fileBlock}`;
+ return {
+ outputs: [],
+ decisions: [],
+ appliedImage: false,
+ appliedAudio: false,
+ appliedVideo: false,
+ appliedFile: true,
+ };
+ },
+ );
+ runEmbeddedPiAgentMock.mockResolvedValueOnce({
+ payloads: [{ text: "file processed" }],
+ meta: {},
+ });
+
+ const runner = createFollowupRunner({
+ opts: { onBlockReply: vi.fn(async () => {}) },
+ typing: createMockTypingController(),
+ typingMode: "instant",
+ defaultModel: "anthropic/claude-opus-4-5",
+ });
+
+ await runner(
+ createQueuedRun({
+ prompt: "[media attached: /tmp/report.pdf]\nLine: Alice\nsummarize this",
+ mediaContext: {
+ Body: "Line: Alice\nsummarize this",
+ RawBody: "summarize this",
+ MediaPaths: ["/tmp/report.pdf"],
+ MediaTypes: ["application/pdf"],
+ },
+ }),
+ );
+
+ expect(applyMediaUnderstandingMock).toHaveBeenCalledTimes(1);
+ const agentCall = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0] as {
+ prompt?: string;
+ };
+ expect(agentCall?.prompt).toContain("Line: Alice");
+ expect(agentCall?.prompt).toContain(fileBlock);
+ expect(agentCall?.prompt?.match(/ {
applyMediaUnderstandingMock.mockImplementationOnce(
async (params: { ctx: Record }) => {
diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts
index e1e9e20e5c8..1c2f5e0551a 100644
--- a/src/auto-reply/reply/queue/types.ts
+++ b/src/auto-reply/reply/queue/types.ts
@@ -52,9 +52,9 @@ export type FollowupMediaContext = {
DeferredMediaApplied?: boolean;
/**
* Set when file extraction has already been applied to Body (either in the
- * primary path or by a previous deferred-media run). Checked instead of
- * scanning body text for `...`
+ * blocks.
*/
DeferredFileBlocksExtracted?: boolean;
};