Skip to content

Commit 62b90eb

Browse files
fix(think): preserve attachment fetchMetadata through messenger serialization (#1833) (#1839)
serializableMessengerEvent stripped everything except id/mediaType/name/ size/text/url from attachments before handing events to sub-agent DOs, discarding fetch, raw, and the platform identifier. For adapters like @chat-adapter/telegram the file id lives only in fetchMetadata.fileId and the top-level id is never set, so attachments were irretrievable inside a sub-agent. Add a serialization-safe fetchMetadata field to MessengerAttachment, preserve it through serializableMessengerEvent, and carry it through toMessengerAttachment while backfilling the top-level id from a known metadata key (id/fileId/mediaId/fileUniqueId). Co-authored-by: Sunil Pai <18808+threepointone@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d8db5e0 commit 62b90eb

4 files changed

Lines changed: 82 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@cloudflare/think": patch
3+
---
4+
5+
Preserve attachment `fetchMetadata` through messenger event serialization so sub-agents can re-fetch files.
6+
7+
When a conversation resolver routes a thread to a sub-agent Durable Object, the messenger event is run through `serializableMessengerEvent()` before crossing the DO boundary. That serialization previously dropped everything except `id`, `mediaType`, `name`, `size`, `text`, and `url` from each attachment — discarding `fetch`, `raw`, and (for adapters that store their platform identifier there) the only remaining handle on the file.
8+
9+
For adapters like `@chat-adapter/telegram`, the file identifier lives exclusively in `fetchMetadata.fileId` and the top-level `id` is never populated, so photos became irretrievable inside a sub-agent (`attachment.id` and `attachment.fetch` were both missing).
10+
11+
`MessengerAttachment` now carries a serialization-safe `fetchMetadata?: Record<string, string>` field that survives the sub-agent hop. `toMessengerAttachment()` copies `fetchMetadata` from the underlying Chat SDK attachment and backfills the top-level `id` from a known metadata key (`id`, `fileId`, `mediaId`, `fileUniqueId`) when the adapter doesn't set one. A downstream agent can use `fetchMetadata` together with the adapter's `rehydrateAttachment()` to reconstruct the download closure.

packages/think/src/messengers/chat-sdk.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ export function toMessengerAuthor(author: ChatAuthor): MessengerAuthor {
740740
export function toMessengerAttachment(
741741
attachment: ChatAttachment
742742
): MessengerAttachment {
743+
const fetchMetadata = attachment.fetchMetadata;
743744
return {
744745
fetch: attachment.fetchData
745746
? async () => {
@@ -754,6 +755,8 @@ export function toMessengerAttachment(
754755
return copy instanceof ArrayBuffer ? copy : new ArrayBuffer(0);
755756
}
756757
: undefined,
758+
fetchMetadata: fetchMetadata ? { ...fetchMetadata } : undefined,
759+
id: identifierFromFetchMetadata(fetchMetadata),
757760
mediaType: attachment.mimeType,
758761
name: attachment.name,
759762
raw: attachment,
@@ -762,6 +765,29 @@ export function toMessengerAttachment(
762765
};
763766
}
764767

768+
const FETCH_METADATA_ID_KEYS = ["id", "fileId", "mediaId", "fileUniqueId"];
769+
770+
/**
771+
* Best-effort top-level id for an attachment whose adapter only records its
772+
* identifier in `fetchMetadata` (e.g. Telegram `fileId`). This keeps id-based
773+
* consumers working without per-adapter knowledge, while the full
774+
* `fetchMetadata` is preserved verbatim for precise re-fetching.
775+
*/
776+
function identifierFromFetchMetadata(
777+
fetchMetadata: Record<string, string> | undefined
778+
): string | undefined {
779+
if (!fetchMetadata) {
780+
return undefined;
781+
}
782+
for (const key of FETCH_METADATA_ID_KEYS) {
783+
const value = fetchMetadata[key];
784+
if (typeof value === "string" && value.length > 0) {
785+
return value;
786+
}
787+
}
788+
return undefined;
789+
}
790+
765791
function stableNamePart(value: string): string {
766792
const safe = value.replace(/[^a-zA-Z0-9:_-]/g, "_");
767793
if (safe.length <= 80) {

packages/think/src/messengers/events.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export interface MessengerAuthor {
1818
export interface MessengerAttachment {
1919
data?: ArrayBuffer;
2020
fetch?: () => Promise<ArrayBuffer>;
21+
/**
22+
* Platform-specific metadata needed to re-fetch the attachment after the
23+
* event has been serialized (e.g. across a sub-agent Durable Object hop).
24+
* Adapters store identifiers here — Telegram `fileId`, WhatsApp `mediaId`,
25+
* etc. — that survive serialization even when `fetch`, `data`, and `raw`
26+
* cannot, so a downstream agent can reconstruct the download closure.
27+
*/
28+
fetchMetadata?: Record<string, string>;
2129
id?: string;
2230
mediaType?: string;
2331
name?: string;
@@ -114,6 +122,9 @@ export function serializableMessengerEvent(
114122
message: event.message
115123
? {
116124
attachments: event.message.attachments.map((attachment) => ({
125+
fetchMetadata: attachment.fetchMetadata
126+
? { ...attachment.fetchMetadata }
127+
: undefined,
117128
id: attachment.id,
118129
mediaType: attachment.mediaType,
119130
name: attachment.name,

packages/think/src/tests/messengers.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
TextStreamCallback,
2727
textDeltaFromStreamChunk,
2828
ThinkMessengerRuntime,
29+
toMessengerAttachment,
2930
toMessengerUserMessage,
3031
type MessengerEvent,
3132
type MessengerThinkHost
@@ -349,6 +350,7 @@ describe("think messengers core", () => {
349350
{
350351
data: new ArrayBuffer(1),
351352
fetch: () => Promise.resolve(new ArrayBuffer(1)),
353+
fetchMetadata: { fileId: "AgACAgIfileid" },
352354
mediaType: "text/plain",
353355
name: "notes.txt",
354356
raw: { providerFile: true },
@@ -372,9 +374,41 @@ describe("think messengers core", () => {
372374
expect(cloned.event.message.attachments[0].raw).toBeUndefined();
373375
expect(cloned.event.message.attachments[0].data).toBeUndefined();
374376
expect(cloned.event.message.attachments[0].fetch).toBeUndefined();
377+
expect(cloned.event.message.attachments[0].fetchMetadata).toEqual({
378+
fileId: "AgACAgIfileid"
379+
});
375380
expect(cloned.thread._type).toBe("chat:Thread");
376381
});
377382

383+
it("preserves attachment fetchMetadata and backfills id when converting", () => {
384+
const attachment = toMessengerAttachment({
385+
fetchData: () => Promise.resolve(Buffer.from("hello")),
386+
fetchMetadata: { fileId: "AgACAgItelegram" },
387+
mimeType: "image/jpeg",
388+
name: "photo.jpg",
389+
size: 1024,
390+
type: "image",
391+
url: "https://example.com/photo.jpg"
392+
});
393+
394+
expect(attachment.fetchMetadata).toEqual({ fileId: "AgACAgItelegram" });
395+
expect(attachment.id).toBe("AgACAgItelegram");
396+
expect(attachment.raw).toBeDefined();
397+
});
398+
399+
it("leaves attachment id undefined when fetchMetadata has no known id key", () => {
400+
const attachment = toMessengerAttachment({
401+
fetchMetadata: { region: "us-east" },
402+
mimeType: "image/jpeg",
403+
name: "photo.jpg",
404+
type: "image",
405+
url: "https://example.com/photo.jpg"
406+
});
407+
408+
expect(attachment.fetchMetadata).toEqual({ region: "us-east" });
409+
expect(attachment.id).toBeUndefined();
410+
});
411+
378412
it("parses and classifies messenger recovery snapshots", () => {
379413
const accepted = messengerReplySnapshot("accepted", baseEvent, {
380414
_type: "chat:Thread",

0 commit comments

Comments
 (0)