fix(e2e): make echo-runtime fixture explicitly push-mode (#2786) #2819

Merged
devops-engineer merged 4 commits from fix/2786-desktop-echo-fixture into main 2026-06-14 04:17:59 +00:00
6 changed files with 113 additions and 60 deletions
+12 -1
View File
@@ -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<void> {
@@ -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();
+21 -21
View File
@@ -18,7 +18,7 @@ async function enterMapView(page: Page): Promise<void> {
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<void> {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
@@ -38,11 +38,13 @@ async function openChatPanel(page: Page, workspaceName: string): Promise<void> {
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,42 +130,40 @@ test.describe("Chat Sub-Tabs", () => {
});
test("chat tab shows My Chat and Agent Comms sub-tabs", async ({ page }) => {
const panel = page.locator("#panel-chat");
await expect(panel.getByRole("button", { name: "My Chat" })).toBeVisible();
await expect(panel.getByRole("button", { name: "Agent Comms" })).toBeVisible();
const panel = panelLocator(page);
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 = page.locator("#panel-chat");
await panel.getByRole("button", { name: "Agent Comms" }).click();
const panel = panelLocator(page);
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();
});
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();
// 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();
});
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();
@@ -174,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();
@@ -343,13 +343,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,9 +384,9 @@ test.describe("No JS Errors", () => {
await openChatPanel(page, workspaceName);
// Switch between tabs.
const panel = page.locator("#panel-chat");
await panel.getByRole("button", { name: "Agent Comms" }).click();
await panel.getByRole("button", { name: "My Chat" }).click();
const panel = panelLocator(page);
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"),
+5 -3
View File
@@ -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<SeededWorkspace> {
external: true,
runtime: "external",
url: echoURL,
delivery_mode: "push",
}),
});
if (!createRes.ok) {
+14 -9
View File
@@ -145,16 +145,21 @@ export async function startEchoRuntime(): Promise<EchoRuntime> {
? "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" }));
@@ -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",
);
});
});
@@ -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<string, unknown>,