Skip to content

Commit 94c60cc

Browse files
committed
fix(message-tool): allow buffer-only attachments without media path or url (#90768)
1 parent 3a2f54e commit 94c60cc

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

src/infra/outbound/message-action-params.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,35 @@ describe("message action media helpers", () => {
578578
expect(args.contentType).toBeUndefined();
579579
});
580580

581+
it("promotes buffer-only structured attachments to top-level args (#90768)", async () => {
582+
// Buffer-only attachments (no media/path/url) used to be invisible to the
583+
// structured-attachment resolver, so message.send/reply with just a
584+
// base64 buffer + filename + contentType silently dropped the file.
585+
const base64 = Buffer.from("hello-bytes").toString("base64");
586+
const args: Record<string, unknown> = {
587+
attachments: [
588+
{
589+
buffer: base64,
590+
filename: "memo.txt",
591+
contentType: "text/plain",
592+
},
593+
],
594+
};
595+
596+
await hydrateAttachmentParamsForAction({
597+
cfg,
598+
channel: "imessage",
599+
args,
600+
action: "sendAttachment",
601+
dryRun: true,
602+
mediaPolicy: { mode: "host" },
603+
});
604+
605+
expect(args.buffer).toBe(base64);
606+
expect(args.filename).toBe("memo.txt");
607+
expect(args.contentType).toBe("text/plain");
608+
});
609+
581610
it("does not fall back caption->message on reply (reply has its own text field)", async () => {
582611
// sendAttachment uses caption as the body text and falls back from
583612
// message -> caption when the agent only supplied `message`. Reply has

src/infra/outbound/message-action-params.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ type StructuredAttachmentSource = {
4949
attachment: Record<string, unknown>;
5050
key: string;
5151
value: string;
52-
kind: "media" | "file";
52+
kind: "media" | "file" | "buffer";
5353
contentType?: string;
5454
filename?: string;
5555
};
@@ -107,6 +107,7 @@ function collectStructuredAttachmentSources(
107107
if (!isRecord(attachment)) {
108108
continue;
109109
}
110+
let matched = false;
110111
for (const key of STRUCTURED_ATTACHMENT_MEDIA_SOURCE_PARAM_KEYS) {
111112
const entry = resolveMediaParamEntry(attachment, key);
112113
if (!entry || !normalizeOptionalString(entry.value)) {
@@ -121,8 +122,31 @@ function collectStructuredAttachmentSources(
121122
readStringParam(attachment, "contentType") ?? readStringParam(attachment, "mimeType"),
122123
filename: readStringParam(attachment, "filename") ?? readStringParam(attachment, "name"),
123124
});
125+
matched = true;
124126
break;
125127
}
128+
if (matched) {
129+
continue;
130+
}
131+
// Buffer-only attachments carry no media path/URL; surface them so the
132+
// runner can hydrate the buffer onto top-level args before channel dispatch.
133+
const bufferKey = resolveSnakeCaseParamKey(attachment, "buffer");
134+
if (!bufferKey) {
135+
continue;
136+
}
137+
const bufferValue = readStringParam(attachment, "buffer", { trim: false });
138+
if (!bufferValue) {
139+
continue;
140+
}
141+
sources.push({
142+
attachment,
143+
key: bufferKey,
144+
value: bufferValue,
145+
kind: "buffer",
146+
contentType:
147+
readStringParam(attachment, "contentType") ?? readStringParam(attachment, "mimeType"),
148+
filename: readStringParam(attachment, "filename") ?? readStringParam(attachment, "name"),
149+
});
126150
}
127151
return sources;
128152
}
@@ -502,6 +526,15 @@ async function hydrateAttachmentActionPayload(params: {
502526
if (attachmentSource?.contentType && !readStringParam(params.args, "contentType")) {
503527
params.args.contentType = attachmentSource.contentType;
504528
}
529+
// Buffer-only structured attachments carry no media path/URL. Promote the
530+
// raw base64 to top-level args so downstream hydration treats them like a
531+
// direct buffer send instead of dropping the attachment.
532+
if (
533+
attachmentSource?.kind === "buffer" &&
534+
!readStringParam(params.args, "buffer", { trim: false })
535+
) {
536+
params.args.buffer = attachmentSource.value;
537+
}
505538

506539
if (params.allowMessageCaptionFallback) {
507540
const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim();

0 commit comments

Comments
 (0)