/** * Minimal A2A echo runtime for E2E tests. * * Listens on an ephemeral port, receives A2A JSON-RPC `message/send` * requests, and returns a response with the original text echoed back. * Also implements the workspace-side chat upload ingest endpoint so * file-attachment E2E can exercise the full upload → send → echo * round-trip. * * Usage (inside test fixture): * const echo = await startEchoRuntime(); * // ... seed workspace with agent_url pointing to echo.baseURL ... * echo.stop(); */ import { createServer, type Server } from "node:http"; export interface EchoRuntime { baseURL: string; stop: () => Promise; lastRequest: { method: string; text: string; files: unknown[] } | null; } /** Parse a minimal multipart body and extract the first file's name + content. */ function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null { // Find the boundary line (first line starting with "--"). const str = body.toString("binary"); const firstDash = str.indexOf("--"); if (firstDash === -1) return null; const eol = str.indexOf("\r\n", firstDash); if (eol === -1) return null; const boundary = str.slice(firstDash + 2, eol); const boundaryMarker = "\r\n--" + boundary; // Find the first part that has a filename in Content-Disposition. let pos = eol + 2; while (pos < str.length) { const nextBoundary = str.indexOf(boundaryMarker, pos); if (nextBoundary === -1) break; const part = str.slice(pos, nextBoundary); const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i); if (cdMatch) { const name = cdMatch[1]; const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i); const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream"; // Body starts after the first double-CRLF in the part. const bodyStart = part.indexOf("\r\n\r\n"); if (bodyStart !== -1) { // Extract the raw bytes (not the string) so binary is safe. const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary"); const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary"); const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary"); const content = body.subarray(partStartInBody, partEndInBody); return { name, mimeType, content }; } } pos = nextBoundary + boundaryMarker.length; // Skip trailing "--" (end marker) or CRLF. if (str.slice(pos, pos + 2) === "--") break; if (str.slice(pos, pos + 2) === "\r\n") pos += 2; } return null; } export async function startEchoRuntime(): Promise { let lastRequest: EchoRuntime["lastRequest"] = null; const server = createServer((req, res) => { // CORS: allow the canvas origin (localhost:3000) to call us. res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } const url = req.url ?? "/"; // Workspace-side chat upload ingest (RFC #2312). if (url === "/internal/chat/uploads/ingest" && req.method === "POST") { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { const body = Buffer.concat(chunks); const file = parseMultipart(body); if (!file) { res.writeHead(400); res.end(JSON.stringify({ error: "no files field" })); return; } const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_"); const prefix = Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16), ).join(""); const response = { files: [ { uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`, name: sanitized, mimeType: file.mimeType, size: file.content.length, }, ], }; res.setHeader("Content-Type", "application/json"); res.writeHead(200); res.end(JSON.stringify(response)); }); return; } // Default: A2A JSON-RPC handler. let body = ""; req.setEncoding("utf8"); req.on("data", (chunk: string) => { body += chunk; }); req.on("end", () => { res.setHeader("Content-Type", "application/json"); try { const rpc = JSON.parse(body); const msg = rpc.params?.message; const textParts = msg?.parts ?.filter((p: { kind?: string; text?: string }) => p.kind === "text") .map((p: { text?: string }) => p.text) .filter(Boolean) ?? []; const fileParts = msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? []; const text = textParts.join("\n"); lastRequest = { method: rpc.method ?? "unknown", text, files: fileParts, }; const replyText = text ? `Echo: ${text}` : fileParts.length > 0 ? "Echo: received your file(s)." : "Echo: hello"; const response = { jsonrpc: "2.0", id: rpc.id ?? null, result: { parts: [{ kind: "text", text: replyText }], }, }; res.writeHead(200); res.end(JSON.stringify(response)); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid json" })); } }); }); await new Promise((resolve) => server.listen(0, resolve)); const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; const baseURL = `http://localhost:${port}`; return { baseURL, stop: () => new Promise((resolve) => { server.close(() => resolve(undefined)); }), get lastRequest() { return lastRequest; }, }; }