diff --git a/canvas/e2e/chat-separation.spec.ts b/canvas/e2e/chat-separation.spec.ts index 65b2b97ee..ef4a1ebea 100644 --- a/canvas/e2e/chat-separation.spec.ts +++ b/canvas/e2e/chat-separation.spec.ts @@ -6,6 +6,7 @@ import { startHeartbeat, cleanupWorkspace, seedChatHistory, + queryPsql, } from "./fixtures/chat-seed"; const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080"; @@ -354,6 +355,101 @@ test.describe("Data Flow — Initial Prompt in Chat", () => { }); }); +const describeWithDb = process.env.E2E_DATABASE_URL + ? test.describe + : test.describe.skip; + +describeWithDb("Chat seed DB round-trip", () => { + let cleanup: () => Promise = async () => {}; + let workspaceId = ""; + + test.beforeAll(async () => { + const echo = await startEchoRuntime(); + const ws = await seedWorkspace(echo.baseURL); + workspaceId = ws.id; + const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); + + // Seed tricky payloads: double quotes, backslashes, apostrophes, and a + // newline. If the JSON is mangled by shell/SQL quoting, the round-trip + // assertion below will fail instead of silently passing. + await seedChatHistory(workspaceId, [ + { + role: "user", + content: 'User said "hello" and \\backslash\\ plus an apostrophe\'s test', + }, + { + role: "agent", + content: 'Agent replied "ok"\nwith a newline', + }, + ]); + + cleanup = async () => { + stopHeartbeat(); + await echo.stop(); + }; + }); + + test.afterAll(async () => { + await cleanupWorkspace(workspaceId); + await cleanup(); + }); + + test("seeded jsonb round-trips exactly through psql", async () => { + interface SeededActivityRow { + id: string; + workspace_id: string; + activity_type: string; + source_id: string | null; + method: string; + request_body: unknown; + response_body: unknown; + status: string; + duration_ms: number; + created_at: string; + } + + const rows = queryPsql< + SeededActivityRow[] + >(`SELECT jsonb_agg(row_to_json(t) ORDER BY t.created_at) FROM (SELECT id, workspace_id, activity_type, source_id, method, request_body, response_body, status, duration_ms, created_at FROM activity_logs WHERE workspace_id = '${workspaceId}' ORDER BY created_at) t`)[0]; + + expect(rows).toHaveLength(2); + + const [userRow, agentRow] = rows; + + expect(userRow.activity_type).toBe("a2a_receive"); + expect(userRow.source_id).toBeNull(); + expect(userRow.method).toBe("message/send"); + expect(userRow.request_body).toEqual({ + params: { + message: { + parts: [ + { + kind: "text", + text: 'User said "hello" and \\backslash\\ plus an apostrophe\'s test', + }, + ], + }, + }, + }); + expect(userRow.response_body).toEqual({}); + + expect(agentRow.activity_type).toBe("a2a_receive"); + expect(agentRow.source_id).toBeNull(); + expect(agentRow.method).toBe("message/send"); + expect(agentRow.request_body).toEqual({}); + expect(agentRow.response_body).toEqual({ + result: { + parts: [ + { + kind: "text", + text: 'Agent replied "ok"\nwith a newline', + }, + ], + }, + }); + }); +}); + test.describe("No JS Errors", () => { let cleanup: () => Promise = async () => {}; let workspaceId = ""; diff --git a/canvas/e2e/fixtures/chat-seed.ts b/canvas/e2e/fixtures/chat-seed.ts index c8c0f3ffe..f5531201c 100644 --- a/canvas/e2e/fixtures/chat-seed.ts +++ b/canvas/e2e/fixtures/chat-seed.ts @@ -44,11 +44,40 @@ export function runPsql(sql: string, timeoutMs = 30_000): string { env: { ...process.env, PGPASSWORD: pass }, stdio: "pipe", timeout: timeoutMs, + encoding: "utf8", }, ); return out.toString(); } +/** + * Execute a read-only psql query and return each row parsed as JSON. + * The caller is responsible for making the query return exactly one JSON + * value per output line (e.g. with `row_to_json` or `jsonb_agg`). + */ +export function queryPsql(sql: string, timeoutMs = 30_000): T[] { + const creds = parseDbUrl(); + if (!creds) { + throw new Error("E2E_DATABASE_URL must be set for DB seeding"); + } + const { user, pass, host, port, db } = creds; + const out = execFileSync( + "psql", + ["-h", host, "-p", port, "-U", user, "-d", db, "-tA", "-c", sql], + { + env: { ...process.env, PGPASSWORD: pass }, + stdio: "pipe", + timeout: timeoutMs, + encoding: "utf8", + }, + ); + return out + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as T); +} + export interface SeededWorkspace { id: string; name: string;