Merge pull request #2135 from Molecule-AI/fix/chat-user-attachments-hydration

fix(chat): hydrate user-side file attachments on chat reload
This commit is contained in:
hongmingwang-moleculeai 2026-04-26 21:43:09 -07:00 committed by GitHub
commit 9a75c0fcbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 98 additions and 2 deletions

View File

@ -112,6 +112,84 @@ describe("activityRowToMessages", () => {
const msgs = activityRowToMessages(row, NEVER_INTERNAL);
expect(msgs.find((m) => m.role === "user")).toBeUndefined();
});
// Reviewer follow-up: the pre-fix loader didn't extract user-side
// file parts, so a chat reload after a session where the user
// dragged in a file showed the text bubble but lost the chip.
// Symmetric to the agent attachment hydration below.
it("hydrates user-side file attachments from request_body.params.message.parts", () => {
const row = makeRow({
request_body: {
params: {
message: {
parts: [
{ kind: "text", text: "here's the screenshot" },
{
kind: "file",
file: {
name: "shot.png",
mimeType: "image/png",
uri: "workspace:/uploads/shot.png",
size: 4096,
},
},
],
},
},
},
});
const msgs = activityRowToMessages(row, NEVER_INTERNAL);
const user = msgs.find((m) => m.role === "user")!;
expect(user.content).toBe("here's the screenshot");
expect(user.attachments).toEqual([
{ name: "shot.png", mimeType: "image/png", uri: "workspace:/uploads/shot.png", size: 4096 },
]);
});
it("emits an attachments-only user bubble when text is empty (drag-drop without caption)", () => {
// Some users drop a file with no message — the bubble should
// still render so the file appears in history. Pre-fix the
// empty userText short-circuited and the row was dropped.
const row = makeRow({
request_body: {
params: {
message: {
parts: [
{ kind: "file", file: { name: "report.pdf", uri: "workspace:/uploads/report.pdf" } },
],
},
},
},
});
const msgs = activityRowToMessages(row, NEVER_INTERNAL);
expect(msgs).toHaveLength(1);
expect(msgs[0].role).toBe("user");
expect(msgs[0].content).toBe("");
expect(msgs[0].attachments).toHaveLength(1);
expect(msgs[0].attachments![0].name).toBe("report.pdf");
});
it("internal-self predicate suppresses the row even if it carries attachments", () => {
// Defence-in-depth: heartbeat self-trigger never produces
// attachments, but if a future internal trigger DID, we still
// want to suppress (otherwise it'd render as the user attaching
// something they never touched).
const row = makeRow({
request_body: {
params: {
message: {
parts: [
{ kind: "text", text: "Delegation results are ready..." },
{ kind: "file", file: { name: "x.zip", uri: "workspace:/x.zip" } },
],
},
},
},
});
const msgs = activityRowToMessages(row, (t) => t.startsWith("Delegation results are ready"));
expect(msgs.find((m) => m.role === "user")).toBeUndefined();
});
});
describe("agent-message extraction", () => {

View File

@ -35,8 +35,26 @@ export function activityRowToMessages(
const out: ChatMessage[] = [];
const userText = extractRequestText(row.request_body);
if (userText && !isInternalSelfMessage(userText)) {
out.push({ ...createMessage("user", userText), timestamp: row.created_at });
// Hydrate user-side file attachments out of the same A2A envelope.
// Without this, a chat reload after a session where the user dragged
// in a file shows the text bubble but loses the download chip — the
// pre-fix loader only walked text via extractRequestText. Mirrors
// the agent branch below. Wire shape from ChatTab's outbound POST:
// request_body = {params: {message: {parts: [{kind:"text"}, {kind:"file", file:{...}}]}}}
// extractFilesFromTask walks `task.parts`, so we feed it `params.message`.
const userMsg = (row.request_body?.params as Record<string, unknown> | undefined)
?.message as Record<string, unknown> | undefined;
const userAttachments = userMsg ? extractFilesFromTask(userMsg) : [];
// Internal-self messages (e.g. heartbeat self-trigger) take precedence
// — drop the row even if it carries attachments, since the heartbeat
// path doesn't produce attachments anyway and keeping the bubble would
// misattribute it to the user.
const isInternal = !!userText && isInternalSelfMessage(userText);
if (!isInternal && (userText || userAttachments.length > 0)) {
out.push({
...createMessage("user", userText, userAttachments),
timestamp: row.created_at,
});
}
if (row.response_body) {