diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index e37229a11ed..a5e956d3b89 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -176,6 +176,9 @@ fails the case instead of silently testing another phase.
Restart continuity requires the agent to recall a phrase after the server
restarts. The final prompt does not reveal that phrase. A generic successful
reply after restart cannot pass this check.
+The browser leaves the old development client before the server stops, then
+opens the canonical chat route and waits for the composer. This avoids racing
+Vite's automatic reconnect navigation against the test's explicit navigation.
The blocker query requests a JSON status snapshot. It must name the current
recorded blocker and report zero active runs independently of the task's blocked
diff --git a/tests/runner-e2e/chat-restart.spec.ts b/tests/runner-e2e/chat-restart.spec.ts
new file mode 100644
index 00000000000..d74c19d6ec1
--- /dev/null
+++ b/tests/runner-e2e/chat-restart.spec.ts
@@ -0,0 +1,31 @@
+import { createServer } from "node:http";
+import { expect, test } from "@playwright/test";
+import { restartChatServer } from "./chat-restart.js";
+
+test("unloads the old browser client before server restart and opens the new document", async ({ page }) => {
+ let generation = 1;
+ const server = createServer((_req, res) => {
+ res.writeHead(200, { "content-type": "text/html" });
+ res.end(``);
+ });
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Missing listener");
+ const url = `http://127.0.0.1:${address.port}`;
+ try {
+ await page.goto(url);
+ await expect(page.getByTestId("composer")).toHaveValue("server generation 1");
+ await restartChatServer(page, async () => {
+ expect(page.url(), "The old client must be gone before it can auto-reload").toBe("about:blank");
+ server.closeAllConnections();
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
+ generation += 1;
+ await new Promise(resolve => server.listen(address.port, "127.0.0.1", resolve));
+ });
+ await page.goto(url, { waitUntil: "commit" });
+ await expect(page.getByTestId("composer")).toHaveValue("server generation 2");
+ } finally {
+ server.closeAllConnections();
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
+ }
+});
diff --git a/tests/runner-e2e/chat-restart.ts b/tests/runner-e2e/chat-restart.ts
new file mode 100644
index 00000000000..ef488ab3bd6
--- /dev/null
+++ b/tests/runner-e2e/chat-restart.ts
@@ -0,0 +1,7 @@
+import type { Page } from "@playwright/test";
+
+/** Restart the test server without letting the old Vite client race navigation. */
+export async function restartChatServer(page: Page, restart: () => Promise) {
+ await page.goto("about:blank", { waitUntil: "commit", timeout: 30_000 });
+ await restart();
+}
diff --git a/tests/runner-e2e/playwright-support.config.ts b/tests/runner-e2e/playwright-support.config.ts
index 517dcfa332c..ad609f75bc6 100644
--- a/tests/runner-e2e/playwright-support.config.ts
+++ b/tests/runner-e2e/playwright-support.config.ts
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
/** Browser-only harness regressions: no Paperclip instance or provider credentials. */
export default defineConfig({
testDir: ".",
- testMatch: ["screenshot-readiness.spec.ts", "lost-send.spec.ts"],
+ testMatch: ["screenshot-readiness.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts"],
workers: 1,
retries: 0,
timeout: 10_000,
diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts
index 73ce46984c0..135f1036259 100644
--- a/tests/runner-e2e/runner.spec.ts
+++ b/tests/runner-e2e/runner.spec.ts
@@ -4,6 +4,7 @@ import { createTaskThroughUi, submitTaskReply } from "./user-actions.js";
import { runFirstTaskFlow, setupFirstTaskFixtures } from "./first-task-flow.js";
import { runChatFlow } from "./chat-flow.js";
+import { restartChatServer } from "./chat-restart.js";
import { createHash, randomBytes } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -832,7 +833,7 @@ for (const execution of executions) {
} else if (execution.task.flow === "agent_chat") {
const chat = await runChatFlow({
page, api, fixtures, execution, nonce, workspacePath,
- restart: () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs }),
+ restart: () => restartChatServer(page, () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs })),
observe: (chatIssue, chatRuns) => { issue = chatIssue; selectedRuns = chatRuns; },
capture: captureScreenshot,
evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),