diff --git a/canvas/e2e/chat-separation.spec.ts b/canvas/e2e/chat-separation.spec.ts index 2492fda84..81d7279a2 100644 --- a/canvas/e2e/chat-separation.spec.ts +++ b/canvas/e2e/chat-separation.spec.ts @@ -8,8 +8,8 @@ import { seedChatHistory, } from "./fixtures/chat-seed"; -const API = process.env.E2E_API_URL ?? "http://localhost:8080"; const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080"; +const API = process.env.E2E_API_URL ?? PLATFORM_URL; const ADMIN_TOKEN = process.env.E2E_ADMIN_TOKEN ?? process.env.ADMIN_TOKEN; /** Enter the Org-map view so the Canvas (React Flow graph) mounts. */ @@ -255,9 +255,11 @@ test.describe("Data Flow — Initial Prompt in Chat", () => { const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); // Pre-seed chat history so the My Chat panel shows deterministic content. + // Include double quotes to regression-test shell-safe JSON quoting in + // seedChatHistory (CR2 #11517). await seedChatHistory(workspaceId, [ - { role: "user", content: "Hello from seed" }, - { role: "agent", content: "Hello back from seed" }, + { role: "user", content: 'Hello from seed with "quotes"' }, + { role: "agent", content: 'Hello back from seed with "quotes"' }, ]); cleanup = async () => { @@ -277,8 +279,8 @@ test.describe("Data Flow — Initial Prompt in Chat", () => { test("seeded chat history appears in My Chat", async ({ page }) => { const panel = page.locator("#panel-chat"); - await expect(panel.getByText("Hello from seed")).toBeVisible({ timeout: 5_000 }); - await expect(panel.getByText("Hello back from seed")).toBeVisible({ timeout: 5_000 }); + await expect(panel.getByText('Hello from seed with "quotes"')).toBeVisible({ timeout: 5_000 }); + await expect(panel.getByText('Hello back from seed with "quotes"')).toBeVisible({ timeout: 5_000 }); }); test("My Chat empty state is not shown when history exists", async ({ page }) => { diff --git a/canvas/e2e/fixtures/chat-seed.ts b/canvas/e2e/fixtures/chat-seed.ts index 755e7ae48..7e7637fac 100644 --- a/canvas/e2e/fixtures/chat-seed.ts +++ b/canvas/e2e/fixtures/chat-seed.ts @@ -9,10 +9,45 @@ */ import { randomUUID } from "node:crypto"; -import { execFileSync, execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080"; +interface PgCredentials { + user: string; + pass: string; + host: string; + port: string; + db: string; +} + +function parseDbUrl(): PgCredentials | null { + const dbUrl = process.env.E2E_DATABASE_URL; + if (!dbUrl) return null; + const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/; + const m = dbUrl.match(pgRegex); + if (!m) return null; + const [, user, pass, host, port, db] = m; + return { user, pass, host, port, db }; +} + +function runPsql(sql: string, timeoutMs = 30_000): void { + const creds = parseDbUrl(); + if (!creds) { + throw new Error("E2E_DATABASE_URL must be set for DB seeding"); + } + const { user, pass, host, port, db } = creds; + execFileSync( + "psql", + ["-h", host, "-p", port, "-U", user, "-d", db, "-c", sql], + { + env: { ...process.env, PGPASSWORD: pass }, + stdio: "pipe", + timeout: timeoutMs, + }, + ); +} + export interface SeededWorkspace { id: string; name: string; @@ -62,16 +97,9 @@ export async function seedWorkspace(echoURL: string): Promise { // 2. Direct DB update: mark online + point url at echo runtime. // The platform blocks loopback URLs at the API layer (SSRF guard), // so we bypass via psql for local E2E. - const dbUrl = process.env.E2E_DATABASE_URL; - if (!dbUrl) { + if (!process.env.E2E_DATABASE_URL) { throw new Error("E2E_DATABASE_URL must be set for DB seeding"); } - const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/; - const m = dbUrl.match(pgRegex); - if (!m) { - throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`); - } - const [, user, pass, host, port, db] = m; // Pre-seed a platform_inbound_secret so chat file uploads don't trigger // the lazy-heal 503 "retry in 30 s" path on first use. @@ -81,17 +109,9 @@ export async function seedWorkspace(echoURL: string): Promise { ], ).join(""); - const psql = [ - `PGPASSWORD=${pass} psql`, - `-h ${host} -p ${port} -U ${user} -d ${db}`, - `-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`, - ].join(" "); - - try { - execSync(psql, { stdio: "pipe", timeout: 30_000 }); - } catch (err) { - throw new Error(`DB update failed: ${err}`); - } + runPsql( + `UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}', delivery_mode = 'push' WHERE id = '${ws.id}'`, + ); cacheWorkspaceURL(ws.id, echoURL); @@ -152,30 +172,49 @@ export function startHeartbeat( /** * Seed chat-history rows for a workspace. + * + * Chat history is read from activity_logs via messagestore.MessageStore + * (workspace-server/internal/messagestore/postgres_store.go). We insert + * a2a_receive rows with source_id NULL (canvas-origin) so the + * /chat-history hydrator picks them up. Each message becomes its own row + * so arbitrary user/agent sequences can be seeded. */ export async function seedChatHistory( workspaceId: string, messages: Array<{ role: "user" | "agent"; content: string }>, ): Promise { - const dbUrl = process.env.E2E_DATABASE_URL; - if (!dbUrl) return; + if (!process.env.E2E_DATABASE_URL) return; - const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/; - const m = dbUrl.match(pgRegex); - if (!m) return; - const [, user, pass, host, port, db] = m; + const escape = (s: string) => s.replace(/'/g, "''").replace(/\\/g, "\\\\"); - const values = messages - .map( - (msg, i) => - `('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`, - ) + const rows = messages + .map((msg, i) => { + const offsetSec = messages.length - i; + const requestBody = + msg.role === "user" + ? JSON.stringify({ + params: { + message: { + parts: [{ kind: "text", text: msg.content }], + }, + }, + }) + : "{}"; + const responseBody = + msg.role === "agent" + ? JSON.stringify({ + result: { + parts: [{ kind: "text", text: msg.content }], + }, + }) + : "{}"; + return `('${randomUUID()}', '${workspaceId}', 'a2a_receive', NULL, NULL, 'message/send', NULL, '${escape(requestBody)}'::jsonb, '${escape(responseBody)}'::jsonb, 0, 'ok', NOW() - INTERVAL '${offsetSec} seconds')`; + }) .join(","); - const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`; + const sql = `INSERT INTO activity_logs (id, workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, duration_ms, status, created_at) VALUES ${rows};`; - const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`; - execSync(psql, { stdio: "pipe", timeout: 10_000 }); + runPsql(sql, 10_000); } /** @@ -185,18 +224,10 @@ export async function seedChatHistory( * that can race or 500 on external workspaces. */ export async function cleanupWorkspace(workspaceId: string): Promise { - const dbUrl = process.env.E2E_DATABASE_URL; - if (!dbUrl) return; - - const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/; - const m = dbUrl.match(pgRegex); - if (!m) return; - const [, user, pass, host, port, db] = m; - - const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`; + if (!process.env.E2E_DATABASE_URL) return; try { - execSync(psql, { stdio: "pipe", timeout: 30_000 }); + runPsql(`DELETE FROM workspaces WHERE id = '${workspaceId}'`); } catch { // Best-effort cleanup; don't fail the test suite if the row is already gone. }