// #507 — send_reply attachments regression + parity with send_task. // // Before this PR: send_reply had no `attachments` field in its Zod schema. // MCP's default schema-validation strips unknown fields, so a caller // passing `attachments` would see `ok:true` and never learn the field // was dropped. Nothing landed in `tasks.meta_json`. Silent failure. // // After this PR: schema declares `attachments` (top-level, parity with // REST /api/task L506) and `meta` (parity with send_task L837). Handler // validates via `validateAttachments` (uploads.ts:359 — the same helper // the REST path uses), persists into inbox.meta_json + tasks.meta_json // (parity with send_task L952/957), and **echoes attachments READ BACK // FROM DB** in the response (lead 2b5f6634 catch: echoing the in-memory // variable would still succeed if the UPDATE actually landed 0 rows — // the echo needs to prove the DB write, not the caller's intent). // // Coverage: // Positive P1-P5 — attachments actually persist + echoed correctly // Reverse (e) — no-attachments call is byte-identical to today // Negative N1-N6 — malformed attachments explicitly rejected // No-in_reply_to — attachments still persisted onto inbox.meta_json // Cross-network — same permission rules as bare send_reply import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { db } from "./db.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerTools } from "./tools.js"; const NET_ID = "net_507_att"; const USER_ID = "u_507_att"; const NODE_ID = "node_507_att"; const AGENT_ALIAS = "peer-507"; const AGENT_TOK_ID = "tok_507_att"; interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; } interface Reply { ok?: boolean; message_id?: string; session_status?: string; warning?: string; error?: string; message?: string; attachments_saved?: Array<{ type: string; file_id: string; name?: string; mime?: string; size?: number }>; [k: string]: unknown; } function cleanup() { try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {} try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {} } beforeEach(cleanup); afterAll(cleanup); function seed(): { taskId: string } { db.run( `INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID], ); db.run( `INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID], ); db.run( `INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID], ); db.run( `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`, [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`], ); db.run( `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`, [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID], ); // Also seed hub session so alias="hub" is a valid dashboard-style target. db.run( `INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at) VALUES ('res_hub_507', 'hub', 'idle', ?1, datetime('now'), datetime('now')) ON CONFLICT DO NOTHING`, [NET_ID], ); // Seed a task the reply will target. Dashboard-style: from_name='hub', // to_name=agent, status='delivered'. const taskId = `task_507_${Date.now()}${Math.floor(Math.random() * 1000)}`; db.run( `INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority) VALUES (?1, 'hub', ?2, 'delivered', 'test task from dashboard', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?3, 'normal')`, [taskId, AGENT_ALIAS, NET_ID], ); return { taskId }; } async function getSendReplyHandler(): Promise { const server = new McpServer({ name: "test", version: "0" }); const tools: Record = {}; const originalTool = (server as any).tool.bind(server); (server as any).tool = (name: string, ...rest: any[]) => { // send_reply is registered with (name, desc, schema, handler) const handler = rest[rest.length - 1]; if (typeof handler === "function") tools[name] = handler; return originalTool(name, ...rest); }; registerTools(server, { enforceNetworkId: NET_ID, enforceUserId: USER_ID, callerAlias: AGENT_ALIAS, callerTokenIsNetwork: true, callerTokenId: AGENT_TOK_ID, } as any); const handler = tools.send_reply; if (!handler) throw new Error("send_reply handler not registered"); return handler; } async function callReply(handler: ToolHandler, args: any): Promise { const result = await handler(args); return JSON.parse(result.content[0].text) as Reply; } // A valid file_id shape (matches FILE_ID_REGEX /^[A-Za-z0-9_-]{8,64}$/). // Note: these attachments do NOT need to point at real uploaded files // for the persistence/echo tests — validateAttachments only checks shape. // The dashboard proxy resolves file_id to disk at download time separately. function att(file_id: string, extra: Record = {}) { return { type: "file", file_id, name: "test.bin", size: 1024, mime: "application/octet-stream", ...extra }; } function readTaskMeta(taskId: string): any { const row = db.get<{ meta_json: string | null }>( "SELECT meta_json FROM tasks WHERE task_id = ?1", [taskId], ); return row?.meta_json ? JSON.parse(row.meta_json) : null; } function readInboxMeta(msgId: string): any { const row = db.get<{ meta_json: string | null }>( "SELECT meta_json FROM inbox WHERE id = ?1", [msgId], ); return row?.meta_json ? JSON.parse(row.meta_json) : null; } describe("#507 — send_reply attachments (positive)", () => { test("P1: two attachments persist to tasks.meta_json AND inbox.meta_json AND echo READ BACK FROM DB", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const a1 = att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "one.pdf" }); const a2 = att("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { name: "two.png" }); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "here you go", in_reply_to: taskId, status: "replied" as const, attachments: [a1, a2], }); expect(reply.ok).toBe(true); expect(reply.attachments_saved).toBeDefined(); expect(reply.attachments_saved!.length).toBe(2); expect(reply.attachments_saved!.map((a) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort()); // Verify persistence — the echo must come from what actually landed on disk. const taskMeta = readTaskMeta(taskId); expect(taskMeta?.attachments?.length).toBe(2); expect(taskMeta.attachments.map((a: any) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort()); const inboxMeta = readInboxMeta(reply.message_id!); expect(inboxMeta?.attachments?.length).toBe(2); }); test("P2: meta.attachments (nested form) also accepted", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const a1 = att("cccccccccccccccccccccccccccccccc"); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "nested-form", in_reply_to: taskId, status: "replied" as const, meta: { attachments: [a1], other_field: "preserved" }, }); expect(reply.ok).toBe(true); expect(reply.attachments_saved!.length).toBe(1); const persisted = readTaskMeta(taskId); expect(persisted?.attachments?.[0]?.file_id).toBe(a1.file_id); expect(persisted?.other_field).toBe("preserved"); // meta merge preserves siblings }); test("P3: top-level attachments WIN over meta.attachments when both supplied (parity with REST /api/task L2101)", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const topLevel = att("dddddddddddddddddddddddddddddddd", { name: "wins.pdf" }); const nested = att("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", { name: "loses.pdf" }); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "conflict resolution", in_reply_to: taskId, status: "replied" as const, attachments: [topLevel], meta: { attachments: [nested], other: "kept" }, }); expect(reply.ok).toBe(true); expect(reply.attachments_saved!.length).toBe(1); expect(reply.attachments_saved![0].file_id).toBe(topLevel.file_id); const persisted = readTaskMeta(taskId); expect(persisted?.attachments?.length).toBe(1); expect(persisted?.attachments?.[0]?.file_id).toBe(topLevel.file_id); expect(persisted?.other).toBe("kept"); }); }); describe("#507 — reverse (e): no-attachments call is byte-identical to pre-#507", () => { test("R-e1: reply with no attachments field returns response without attachments_saved key at all", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "no attachments here", in_reply_to: taskId, status: "replied" as const, }); expect(reply.ok).toBe(true); expect(reply.message_id).toBeDefined(); // Reverse-(e) core: key must not appear at all (undefined, not empty). // If a caller had a strict equality assertion on the response shape, // adding an empty attachments_saved field would break it. expect("attachments_saved" in reply).toBe(false); }); test("R-e2: reply with attachments=undefined returns response without attachments_saved key", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "explicit undefined", in_reply_to: taskId, status: "replied" as const, attachments: undefined, }); expect(reply.ok).toBe(true); expect("attachments_saved" in reply).toBe(false); }); test("R-e3: reply with attachments=[] (empty array) returns response without attachments_saved key", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "explicit empty array", in_reply_to: taskId, status: "replied" as const, attachments: [], }); expect(reply.ok).toBe(true); // Empty array is a valid input — validator returns ok+empty. // Response shape stays clean (no attachments_saved field). expect("attachments_saved" in reply).toBe(false); }); test("R-e4: reply with no attachments does not touch pre-existing tasks.meta_json (COALESCE preserves)", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); // Pre-set a meta_json on the task row so we can prove COALESCE preserves it. db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [ JSON.stringify({ pre_existing: true, some_field: "must survive" }), taskId, ]); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "no attachments — must not clobber existing meta", in_reply_to: taskId, status: "replied" as const, }); expect(reply.ok).toBe(true); const meta = readTaskMeta(taskId); expect(meta?.pre_existing).toBe(true); expect(meta?.some_field).toBe("must survive"); }); }); describe("#507 — negative: malformed attachments explicitly rejected (bad_attachments)", () => { test("N1: attachments not an array → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "malformed", in_reply_to: taskId, status: "replied" as const, attachments: "not an array" as any, }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); expect(typeof reply.message).toBe("string"); // Reply never landed on disk (validation happened before DB write). const meta = readTaskMeta(taskId); expect(meta).toBeNull(); }); test("N2: too many attachments (>20) → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); // 21 valid-shape attachments const many = Array.from({ length: 21 }, (_, i) => att("a".repeat(32).slice(0, 30) + (i < 10 ? "0" + i : "" + i))); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "too many", in_reply_to: taskId, status: "replied" as const, attachments: many, }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); expect(reply.message).toMatch(/too many/i); }); test("N3: bad file_id (contains path separator) → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "bad file_id", in_reply_to: taskId, status: "replied" as const, attachments: [{ type: "file", file_id: "../etc/passwd", name: "x", size: 1 }], }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); expect(reply.message).toMatch(/file_id/i); }); test("N4: wrong type field (must be 'file') → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "bad type", in_reply_to: taskId, status: "replied" as const, attachments: [{ type: "url", file_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }], }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); expect(reply.message).toMatch(/type/i); }); test("N5: attachment size > MAX_UPLOAD_BYTES → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "too big", in_reply_to: taskId, status: "replied" as const, attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { size: 999_999_999_999 })], }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); expect(reply.message).toMatch(/size/i); }); test("N6: attachment name too long (>255) → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "long name", in_reply_to: taskId, status: "replied" as const, attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "x".repeat(256) })], }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); expect(reply.message).toMatch(/name/i); }); test("N7: non-object attachment item → 400 bad_attachments", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "junk item", in_reply_to: taskId, status: "replied" as const, attachments: ["string-not-object"] as any, }); expect(reply.ok).toBe(false); expect(reply.error).toBe("bad_attachments"); }); }); describe("#507 — echo integrity (read-back-from-DB, not in-memory)", () => { test("echo values are exactly what the DB round-trip produced (proves read-back not passthrough)", async () => { const { taskId } = seed(); const handler = await getSendReplyHandler(); const a = att("ffffffffffffffffffffffffffffffff", { name: "roundtrip.pdf", mime: "application/pdf", size: 4096, }); const reply = await callReply(handler, { alias: AGENT_ALIAS, text: "roundtrip check", in_reply_to: taskId, status: "replied" as const, attachments: [a], }); expect(reply.ok).toBe(true); // The echoed attachment must equal what DB round-trip gives back — // fetch independently and compare. const inboxMeta = readInboxMeta(reply.message_id!); const dbAttachment = inboxMeta?.attachments?.[0]; expect(reply.attachments_saved![0]).toEqual(dbAttachment); // Also the echo must contain the full shape as re-hydrated from JSON // (proves it went through normalize/serialize/read/parse — not just // an in-memory pass-through). expect(reply.attachments_saved![0].file_id).toBe(a.file_id); expect(reply.attachments_saved![0].name).toBe(a.name); expect(reply.attachments_saved![0].mime).toBe(a.mime); expect(reply.attachments_saved![0].size).toBe(a.size); expect(reply.attachments_saved![0].type).toBe("file"); }); });