From 401ff02d7cc471c9fe42d09661f04c70374994ba Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 03:44:32 +0000 Subject: [PATCH 1/4] fix(e2e): make echo-runtime fixture explicitly push-mode (#2786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RCA #2786 / comment #100702: the shared external echo-runtime fixture created workspaces without an explicit delivery_mode. Registry/proxy behavior then allowed poll-mode defaulting, which short-circuits to {status:'queued'} and never dispatches to the echo URL — so desktop tests waiting for inline 'Echo: ...' text timed out. - Pass delivery_mode: 'push' in the workspace creation payload in chat-seed.ts so the echo fixture is unambiguously push-mode from creation. - Keep the existing DB update that also sets delivery_mode = 'push' as a defensive fallback for the SSRF-bypass path. - Export parseDbUrl/runPsql from chat-seed.ts and add a regression test in chat-desktop.spec.ts asserting the seeded workspace is push-mode. Classification: TEST-FIXTURE mismatch (poll vs push), not a #2759 product regression. The product HTTP/WS A2A_RESPONSE path was verified correct. Refs #2786 / #2782 / #2759 Co-Authored-By: Claude --- canvas/e2e/chat-desktop.spec.ts | 13 ++++++++++++- canvas/e2e/fixtures/chat-seed.ts | 8 +++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/canvas/e2e/chat-desktop.spec.ts b/canvas/e2e/chat-desktop.spec.ts index 05f02d392..02f4c7404 100644 --- a/canvas/e2e/chat-desktop.spec.ts +++ b/canvas/e2e/chat-desktop.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; import { startEchoRuntime } from "./fixtures/echo-runtime"; -import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed"; +import { seedWorkspace, startHeartbeat, cleanupWorkspace, runPsql } from "./fixtures/chat-seed"; /** Enter the Org-map view so the Canvas (React Flow graph) mounts. */ async function enterMapView(page: Page): Promise { @@ -67,6 +67,17 @@ test.describe("Desktop ChatTab", () => { expect(hasEmptyState || hasHistory).toBeTruthy(); }); + test("echo fixture workspace is configured for push delivery", async () => { + // Regression for #2786: external echo-runtime workspaces must be push-mode + // so the proxy dispatches synchronously to the echo URL. Poll-mode defaults + // short-circuit to {status:'queued'} and the inline echo never renders. + const out = runPsql( + `SELECT delivery_mode FROM workspaces WHERE id = '${workspaceId}';`, + 10_000, + ); + expect(out).toContain("push"); + }); + test("send text message and receive echo response", async ({ page }) => { const chat = page.locator("#panel-chat [data-testid='chat-panel']:visible"); const textarea = page.locator("#panel-chat textarea").first(); diff --git a/canvas/e2e/fixtures/chat-seed.ts b/canvas/e2e/fixtures/chat-seed.ts index 3e966edcf..c8c0f3ffe 100644 --- a/canvas/e2e/fixtures/chat-seed.ts +++ b/canvas/e2e/fixtures/chat-seed.ts @@ -21,7 +21,7 @@ interface PgCredentials { db: string; } -function parseDbUrl(): PgCredentials | null { +export function parseDbUrl(): PgCredentials | null { const dbUrl = process.env.E2E_DATABASE_URL; if (!dbUrl) return null; const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/; @@ -31,13 +31,13 @@ function parseDbUrl(): PgCredentials | null { return { user, pass, host, port, db }; } -function runPsql(sql: string, timeoutMs = 30_000): void { +export function runPsql(sql: string, timeoutMs = 30_000): string { 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( + const out = execFileSync( "psql", ["-h", host, "-p", port, "-U", user, "-d", db, "-c", sql], { @@ -46,6 +46,7 @@ function runPsql(sql: string, timeoutMs = 30_000): void { timeout: timeoutMs, }, ); + return out.toString(); } export interface SeededWorkspace { @@ -75,6 +76,7 @@ export async function seedWorkspace(echoURL: string): Promise { external: true, runtime: "external", url: echoURL, + delivery_mode: "push", }), }); if (!createRes.ok) { -- 2.52.0 From 5ba99a911818aca2aaf67f8c1c0cd4824ee0ee3f Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 04:01:40 +0000 Subject: [PATCH 2/4] fix(chat,e2e): push-mode echo race + visible-panel selectors for #2786 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 362748 showed the fixture-only fix (delivery_mode='push', already on main via #2788) was insufficient. The remaining failures were: 1. Desktop echo tests still timed out waiting for 'Echo: ...' — the #2759 token-per-send guard dropped push-mode HTTP replies when a WebSocket completion event finished the token before the HTTP 200 landed. 2. chat-separation sub-tab tests matched the hidden ConciergeShell ChatTab copy because selectors were scoped only to #panel-chat. Changes: - canvas/src/components/tabs/chat/hooks/useChatSend.ts: process push-mode replies unconditionally; only gate the poll-mode {status:'queued'} short-circuit on token membership. - canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.concurrentReplies.test.tsx: add regression test for push-mode reply surviving a racing WS completion. - canvas/e2e/chat-separation.spec.ts: scope sub-tab/textarea lookups to the visible chat panel. Refs #2786 / #2782 / #2759 Co-Authored-By: Claude --- canvas/e2e/chat-separation.spec.ts | 20 +++++---- .../useChatSend.concurrentReplies.test.tsx | 43 ++++++++++++++++++ .../components/tabs/chat/hooks/useChatSend.ts | 44 ++++++++----------- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/canvas/e2e/chat-separation.spec.ts b/canvas/e2e/chat-separation.spec.ts index 1235cf738..534ba8e78 100644 --- a/canvas/e2e/chat-separation.spec.ts +++ b/canvas/e2e/chat-separation.spec.ts @@ -18,7 +18,7 @@ async function enterMapView(page: Page): Promise { await btn.click(); } -/** Open the seeded workspace's Chat side panel. */ +/** Open the seeded workspace's Chat side panel (scoped to the visible panel). */ async function openChatPanel(page: Page, workspaceName: string): Promise { await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/"); @@ -38,11 +38,13 @@ async function openChatPanel(page: Page, workspaceName: string): Promise { await page.waitForSelector("#panel-chat [data-testid='chat-panel']:visible", { timeout: 5_000, }); - await expect(page.locator("#panel-chat textarea").first()).toBeEnabled({ + await expect(page.locator("#panel-chat [data-testid='chat-panel']:visible textarea").first()).toBeEnabled({ timeout: 15_000, }); } +const panelLocator = (page: Page) => + page.locator("#panel-chat [data-testid='chat-panel']:visible"); /** Post a message to the workspace via the A2A proxy so activity rows exist. * `source` determines the auth shape, which in turn determines * activity_logs.source_id: @@ -128,7 +130,7 @@ test.describe("Chat Sub-Tabs", () => { }); test("chat tab shows My Chat and Agent Comms sub-tabs", async ({ page }) => { - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); await expect(panel.getByRole("button", { name: "My Chat" })).toBeVisible(); await expect(panel.getByRole("button", { name: "Agent Comms" })).toBeVisible(); }); @@ -141,7 +143,7 @@ test.describe("Chat Sub-Tabs", () => { }); test("switching to Agent Comms shows different content", async ({ page }) => { - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); await panel.getByRole("button", { name: "Agent Comms" }).click(); // Agent Comms should be selected and My Chat's textarea should not be visible. @@ -152,7 +154,7 @@ test.describe("Chat Sub-Tabs", () => { }); test("My Chat has input box, Agent Comms does not", async ({ page }) => { - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); // My Chat has the textarea. await expect(panel.locator("textarea").first()).toBeVisible(); @@ -163,7 +165,7 @@ test.describe("Chat Sub-Tabs", () => { }); test("switching back to My Chat preserves messages", async ({ page }) => { - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); // Send a message so there is content to preserve. const textarea = panel.locator("textarea").first(); @@ -343,13 +345,13 @@ test.describe("Data Flow — Initial Prompt in Chat", () => { }); test("seeded chat history appears in My Chat", async ({ page }) => { - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); 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 }) => { - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); await expect(panel.getByText("No messages yet")).not.toBeVisible(); }); }); @@ -384,7 +386,7 @@ test.describe("No JS Errors", () => { await openChatPanel(page, workspaceName); // Switch between tabs. - const panel = page.locator("#panel-chat"); + const panel = panelLocator(page); await panel.getByRole("button", { name: "Agent Comms" }).click(); await panel.getByRole("button", { name: "My Chat" }).click(); diff --git a/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.concurrentReplies.test.tsx b/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.concurrentReplies.test.tsx index 35c8b3ff4..0033b1f0b 100644 --- a/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.concurrentReplies.test.tsx +++ b/canvas/src/components/tabs/chat/hooks/__tests__/useChatSend.concurrentReplies.test.tsx @@ -609,3 +609,46 @@ describe("useChatSend — legacy no-messageId fallback is exact-one-token conser expect(result.current.error).toBeNull(); }); }); + +describe("useChatSend — push-mode reply is not dropped by a racing WS completion (core#2786)", () => { + it("renders the push-mode echo reply even when onSendComplete finishes the token first", async () => { + const send = deferred(); + apiPostMock.mockImplementationOnce(() => send.promise); + + const onAgentMessage = vi.fn(); + const { result } = renderHook(() => + useChatSend("push-echo-ws-race", { + getHistoryMessages: () => [], + onAgentMessage, + }), + ); + + await act(async () => { + await result.current.sendMessage("echo me"); + await Promise.resolve(); + }); + expect(result.current.sending).toBe(true); + + const messageId = (apiPostMock.mock.calls[0][1] as { params: { message: { messageId: string } } }).params.message.messageId; + + // A WebSocket completion event (ACTIVITY_LOGGED status=ok with messageId, + // or AGENT_MESSAGE) can arrive before the HTTP 200 on a fast echo/reply. + // It finishes the token — the spinner must drop. + act(() => { + result.current.finishSendByMessageId?.(messageId); + }); + expect(result.current.sending).toBe(false); + + // The late HTTP push-mode reply still carries the actual content and MUST + // be rendered; otherwise the echo bubble never appears (core#2786). + await act(async () => { + send.resolve({ result: { parts: [{ kind: "text", text: "Echo: echo me" }] } }); + await Promise.resolve(); + }); + + expect(onAgentMessage).toHaveBeenCalledTimes(1); + expect((onAgentMessage.mock.calls[0][0] as { content: string }).content).toBe( + "Echo: echo me", + ); + }); +}); diff --git a/canvas/src/components/tabs/chat/hooks/useChatSend.ts b/canvas/src/components/tabs/chat/hooks/useChatSend.ts index e06c29151..d1cc851fd 100644 --- a/canvas/src/components/tabs/chat/hooks/useChatSend.ts +++ b/canvas/src/components/tabs/chat/hooks/useChatSend.ts @@ -366,33 +366,17 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) { { timeoutMs: 30 * 60 * 1000 }, ) .then((resp) => { - // core#2725: only process the reply that belongs to this token. - // If the token is neither in-flight nor pending-WS, it has already - // been finished or superseded — drop it to avoid misrouted replies - // / duplicates. - if ( - !inFlightTokensRef.current.has(myToken) && - !pendingWSTokensRef.current.has(myToken) - ) return; - - // Task #227 — poll-mode (external/MCP workspace) queued-200 - // short-circuit. ws-server's `proxyA2ARequest` returns - // `{status:"queued", delivery_mode:"poll", ...}` immediately - // when the target has no URL (delivery_mode=poll), BEFORE the - // agent has produced any reply. There is no `result.parts` - // payload here — the actual reply will arrive separately via - // the AGENT_MESSAGE WebSocket event after the agent's next - // `wait_for_message` poll. - // - // Keep the spinner up by moving the token to the WS-pending set; - // releaseSendGuards will prune it when the AGENT_MESSAGE lands - // (handled by useChatSocket `onAgentMessage`/`onSendComplete`) - // or an explicit error fires (`onSendError` from an - // ACTIVITY_LOGGED status="error"). Don't synthesise an empty - // agent bubble. + // Poll-mode queued short-circuit (external/MCP workspace): the + // server returns `{status:"queued"}` immediately and the real reply + // arrives later via the AGENT_MESSAGE WebSocket event. if (resp?.status === "queued") { - // If WS already completed for this token via the legacy fallback - // path, finish immediately instead of re-pending forever. + if ( + !inFlightTokensRef.current.has(myToken) && + !pendingWSTokensRef.current.has(myToken) + ) { + // Already finished by a WS event (legacy fallback raced ahead). + return; + } if (wsCompletedTokensRef.current.has(myToken)) { finishSendToken(myToken); } else { @@ -401,6 +385,14 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) { return; } + // Push-mode synchronous reply: process the agent message even if a + // WebSocket completion event (ACTIVITY_LOGGED or AGENT_MESSAGE) + // already finished this token. Without this, a fast echo/reply that + // triggers a WS completion before the HTTP 200 lands would have its + // token removed here and the reply bubble would never render + // (core#2786 / #2759). Token cleanup is idempotent; ChatTab's + // message dedup handles the rare case where both paths carry the + // same content. const replyText = extractReplyText(resp); const replyFiles = extractFilesFromTask( (resp?.result ?? {}) as Record, -- 2.52.0 From af9454fae08dd7ba15dd21520168d8ebc66c85c7 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 04:08:08 +0000 Subject: [PATCH 3/4] fix(e2e): chat-separation sub-tab selectors use role=tab (#2786) Run 362805: desktop echo tests all passed after the push-mode race fix, but chat-separation sub-tab tests still failed. The ChatTab sub-tab buttons render with role='tab' (not 'button'), so the tests could not find them. Update all sub-tab selectors to use getByRole('tab') and scope the 'My Chat selected' assertion to the visible panel locator. Refs #2786 --- canvas/e2e/chat-separation.spec.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/canvas/e2e/chat-separation.spec.ts b/canvas/e2e/chat-separation.spec.ts index 534ba8e78..65b2b97ee 100644 --- a/canvas/e2e/chat-separation.spec.ts +++ b/canvas/e2e/chat-separation.spec.ts @@ -131,24 +131,22 @@ test.describe("Chat Sub-Tabs", () => { test("chat tab shows My Chat and Agent Comms sub-tabs", async ({ page }) => { const panel = panelLocator(page); - await expect(panel.getByRole("button", { name: "My Chat" })).toBeVisible(); - await expect(panel.getByRole("button", { name: "Agent Comms" })).toBeVisible(); + await expect(panel.getByRole("tab", { name: "My Chat" })).toBeVisible(); + await expect(panel.getByRole("tab", { name: "Agent Comms" })).toBeVisible(); }); test("My Chat is selected by default", async ({ page }) => { - const myChatBtn = page - .locator("#panel-chat") - .getByRole("button", { name: "My Chat" }); + const myChatBtn = panelLocator(page).getByRole("tab", { name: "My Chat" }); await expect(myChatBtn).toHaveAttribute("aria-selected", "true"); }); test("switching to Agent Comms shows different content", async ({ page }) => { const panel = panelLocator(page); - await panel.getByRole("button", { name: "Agent Comms" }).click(); + await panel.getByRole("tab", { name: "Agent Comms" }).click(); // Agent Comms should be selected and My Chat's textarea should not be visible. await expect( - panel.getByRole("button", { name: "Agent Comms" }), + panel.getByRole("tab", { name: "Agent Comms" }), ).toHaveAttribute("aria-selected", "true"); await expect(panel.locator("textarea").first()).not.toBeVisible(); }); @@ -160,7 +158,7 @@ test.describe("Chat Sub-Tabs", () => { await expect(panel.locator("textarea").first()).toBeVisible(); // Switch to Agent Comms. - await panel.getByRole("button", { name: "Agent Comms" }).click(); + await panel.getByRole("tab", { name: "Agent Comms" }).click(); await expect(panel.locator("textarea").first()).not.toBeVisible(); }); @@ -176,8 +174,8 @@ test.describe("Chat Sub-Tabs", () => { ).toBeVisible({ timeout: 15_000 }); // Switch to Agent Comms and back. - await panel.getByRole("button", { name: "Agent Comms" }).click(); - await panel.getByRole("button", { name: "My Chat" }).click(); + await panel.getByRole("tab", { name: "Agent Comms" }).click(); + await panel.getByRole("tab", { name: "My Chat" }).click(); // Message should still be there. await expect(panel.getByText("Persistence check", { exact: true })).toBeVisible(); @@ -387,8 +385,8 @@ test.describe("No JS Errors", () => { // Switch between tabs. const panel = panelLocator(page); - await panel.getByRole("button", { name: "Agent Comms" }).click(); - await panel.getByRole("button", { name: "My Chat" }).click(); + await panel.getByRole("tab", { name: "Agent Comms" }).click(); + await panel.getByRole("tab", { name: "My Chat" }).click(); const critical = errors.filter( (e) => !e.includes("WebSocket") && !e.includes("favicon") && !e.includes("hydration"), -- 2.52.0 From 28e2bf185a5bf9898c5963ea1d02d4e2ab39752f Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 14 Jun 2026 04:12:18 +0000 Subject: [PATCH 4/4] fix(e2e): delay echo reply for 'Trigger activity' to stabilize activity-log test (#2786) Run 362837: only the 'activity log appears during send' test failed. The echo runtime replies instantly, so the thinking state (and therefore the inline activity-log element) clears before Playwright can observe it. Add a short fixture delay when the message text is exactly 'Trigger activity' so the activity-log assertion has time to pass, then the echo still renders. Refs #2786 --- canvas/e2e/fixtures/echo-runtime.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/canvas/e2e/fixtures/echo-runtime.ts b/canvas/e2e/fixtures/echo-runtime.ts index 69be2eeda..55b5e4b3f 100644 --- a/canvas/e2e/fixtures/echo-runtime.ts +++ b/canvas/e2e/fixtures/echo-runtime.ts @@ -145,16 +145,21 @@ export async function startEchoRuntime(): Promise { ? "Echo: received your file(s)." : "Echo: hello"; - const response = { - jsonrpc: "2.0", - id: rpc.id ?? null, - result: { - parts: [{ kind: "text", text: replyText }], - }, - }; + // Allow the activity-log assertion in chat-desktop.spec.ts to observe + // the thinking state before the instant echo reply clears it. + const delayMs = text === "Trigger activity" ? 800 : 0; + setTimeout(() => { + const response = { + jsonrpc: "2.0", + id: rpc.id ?? null, + result: { + parts: [{ kind: "text", text: replyText }], + }, + }; - res.writeHead(200); - res.end(JSON.stringify(response)); + res.writeHead(200); + res.end(JSON.stringify(response)); + }, delayMs); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid json" })); -- 2.52.0