Improve reconnect status feedback (#2931)

* fix(app): debounce reconnect status toast

Keep reconnect transport immediate while delaying the visible status toast so brief outages stay quiet. Scope the toast to the active retained panel, keep its copy and status dot consistent across locales, and add a manual real relay deployment journey that verifies streaming recovery without user action.

* fix(app): isolate manual relay deployment check

Keep the deployment journey out of the provider-wide real test project so it runs only when its local relay prerequisites are explicitly requested. Also keep the relay teardown active when packaged daemon startup or later cleanup fails.

* fix(app): preserve reconnect toast across tabs

Keep reconnect presentation timing at the host level so switching between retained panels does not restart the debounce or flash the toast. Add a browser regression covering a visible outage across retained tab switches.

* fix(app): anchor reconnect debounce to host status

Record host connection status transitions independently of mounted agent panels and preserve the outage start across reconnecting states. Use that host-level timestamp when a visible agent panel appears, with runtime coverage for the transition lifecycle.

* fix(app): reset reconnect toast state per outage

* test(app): build relay journey prerequisites
This commit is contained in:
Mohamed Boudra
2026-08-06 13:45:34 +02:00
committed by GitHub
parent e99eb7de3d
commit c1f7ed3198
24 changed files with 725 additions and 25 deletions
@@ -0,0 +1,129 @@
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import net from "node:net";
import path from "node:path";
export interface LocalElixirRelay {
endpoint: string;
port: number;
start(): Promise<void>;
stop(): Promise<void>;
close(): Promise<void>;
}
async function availablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Could not allocate a relay port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}, 5_000);
try {
await once(child, "exit");
} finally {
clearTimeout(force);
}
}
async function waitUntilReady(
port: number,
child: ChildProcess,
recentOutput: () => string,
): Promise<void> {
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
);
}
try {
const response = await fetch(`http://127.0.0.1:${port}/ready`, {
signal: AbortSignal.timeout(1_000),
});
if (response.ok) return;
lastError = new Error(`Relay readiness returned HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(
`Elixir relay did not become ready on port ${port}: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${recentOutput()}`,
);
}
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
const relayRoot =
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
throw new Error(
`Expected the Elixir relay checkout at ${relayRoot}. Set PASEO_RELAY_CHECKOUT to override it.`,
);
}
const port = await availablePort();
let child: ChildProcess | null = null;
let output: string[] = [];
const start = async () => {
if (child && child.exitCode === null && child.signalCode === null) {
throw new Error("Elixir relay is already running");
}
output = [];
child = spawn("asdf", ["exec", "mix", "run", "--no-halt"], {
cwd: relayRoot,
env: {
...process.env,
MIX_ENV: "prod",
PASEO_RELAY_HOST: "127.0.0.1",
PASEO_RELAY_PORT: String(port),
PASEO_RELAY_MIN_CLUSTER_SIZE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
const capture = (chunk: Buffer) => {
output.push(chunk.toString("utf8"));
output = output.join("").split("\n").slice(-80);
};
child.stdout?.on("data", capture);
child.stderr?.on("data", capture);
try {
await waitUntilReady(port, child, () => output.join("\n"));
} catch (error) {
await stopProcess(child);
throw error;
}
};
const stop = async () => {
if (!child) return;
await stopProcess(child);
child = null;
};
await start();
return {
endpoint: `127.0.0.1:${port}`,
port,
start,
stop,
close: stop,
};
}
@@ -14,6 +14,7 @@ export interface MockAgentWorkspace {
export interface MockAgentOptions {
repoPrefix: string;
title: string;
port?: number;
initialPrompt?: string;
model?: string;
modeId?: string;
@@ -28,7 +29,7 @@ export interface MockAgentOptions {
export async function seedMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port });
try {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -0,0 +1,111 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface PackagedWebDaemon {
home: string;
origin: string;
port: number;
serverId: string;
pairingOfferUrl(): Promise<string>;
close(): Promise<void>;
}
async function availablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Could not allocate a daemon port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function waitForWebUi(origin: string, home: string): Promise<void> {
const deadline = Date.now() + 30_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const response = await fetch(origin, { signal: AbortSignal.timeout(1_000) });
const body = await response.text();
if (response.ok && body.includes("<html")) return;
lastError = new Error(`Daemon web UI returned HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const log = await readFile(path.join(home, "daemon.log"), "utf8").catch(() => "");
throw new Error(
`Daemon web UI did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${log.split("\n").slice(-80).join("\n")}`,
);
}
export async function startPackagedWebDaemon(input: {
relayEndpoint: string;
}): Promise<PackagedWebDaemon> {
const port = await availablePort();
const home = await mkdtemp(path.join(tmpdir(), "paseo-relay-deployment-e2e-"));
const serverId = `relay-deployment-${Date.now().toString(36)}`;
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
const env: NodeJS.ProcessEnv = {
...process.env,
CI: "true",
NODE_ENV: "development",
PASEO_NODE_ENV: "development",
PASEO_SERVER_ID: serverId,
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_USE_TLS: "false",
PASEO_RELAY_PUBLIC_USE_TLS: "false",
};
try {
await execFileAsync(
paseo,
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
{ env },
);
const origin = `http://127.0.0.1:${port}`;
await waitForWebUi(origin, home);
return {
home,
origin,
port,
serverId,
pairingOfferUrl: async () => {
const { stdout } = await execFileAsync(
paseo,
["daemon", "pair", "--home", home, "--relay", "--json"],
{ env },
);
const result = JSON.parse(stdout) as { url?: unknown };
if (typeof result.url !== "string") {
throw new Error(`Pairing command returned no URL: ${stdout}`);
}
return result.url;
},
close: async () => {
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(
() => undefined,
);
await rm(home, { recursive: true, force: true });
},
};
} catch (error) {
await execFileAsync(paseo, ["daemon", "stop", "--home", home], { env }).catch(() => undefined);
await rm(home, { recursive: true, force: true });
throw error;
}
}
@@ -0,0 +1,131 @@
import { expect, type Page } from "@playwright/test";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import type { LocalElixirRelay } from "./local-elixir-relay";
import type { PackagedWebDaemon } from "./packaged-web-daemon";
import { expectRunningAgentChrome } from "./agent-stream";
import { buildCreateAgentPreferences } from "./daemon-registry";
export interface RelayDeploymentMeasurements {
reconnectToastDelayMs: number;
reconnectToastVisibleMs: number;
totalReconnectMs: number;
relayStartupMs: number;
streamPausedWhileDisconnected: boolean;
runningStatePreserved: boolean;
streamResumedWithoutUserAction: boolean;
}
export async function connectDaemonWebAppOnlyThroughRelay(
page: Page,
daemon: PackagedWebDaemon,
): Promise<void> {
const offerUrl = await daemon.pairingOfferUrl();
const offer = parseConnectionOfferFromUrl(offerUrl);
if (!offer) throw new Error("Daemon pairing command returned an invalid offer URL");
const relayConnection = {
id: `relay:${offer.relay.endpoint}`,
type: "relay" as const,
relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls ?? false,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
};
const now = new Date().toISOString();
const host = {
serverId: offer.serverId,
label: "Relay deployment host",
connections: [relayConnection],
preferredConnectionId: relayConnection.id,
createdAt: now,
updatedAt: now,
};
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (socket) => {
await socket.close({ code: 1008, reason: "Blocked developer daemon during relay E2E" });
});
await page.route("**/*", async (route) => {
if (route.request().resourceType() !== "document") {
await route.continue();
return;
}
const response = await route.fetch();
const html = await response.text();
await route.fulfill({
response,
body: html.replace(/<script>window\.__PASEO_INITIAL_DAEMON_CONNECTION__=.*?<\/script>/, ""),
});
});
await page.addInitScript(
({ storedHost, preferences }) => {
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
);
const relaySocketOpened = new Promise<void>((resolve) => {
page.on("websocket", (socket) => {
if (socket.url().includes(offer.relay.endpoint)) resolve();
});
});
await page.goto(daemon.origin);
await relaySocketOpened;
await expect(page.getByTestId("sidebar-settings")).toBeVisible({ timeout: 30_000 });
}
async function latestAssistantText(page: Page): Promise<string> {
return (await page.getByTestId("assistant-message").last().innerText()).trim();
}
async function waitForAssistantTextToGrow(page: Page, previous: string): Promise<void> {
await expect.poll(() => latestAssistantText(page), { timeout: 30_000 }).not.toBe(previous);
}
async function expectRunningStatusPreserved(page: Page, agentTitle: string): Promise<void> {
const tab = page.getByRole("button", { name: agentTitle, exact: true });
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
}
export async function measureRelayRestartDuringStream(input: {
page: Page;
relay: LocalElixirRelay;
agentTitle: string;
}): Promise<RelayDeploymentMeasurements> {
const { page, relay, agentTitle } = input;
const toast = page.getByTestId("agent-reconnecting-toast");
const beforeOutage = await latestAssistantText(page);
await waitForAssistantTextToGrow(page, beforeOutage);
const outageStartedAt = performance.now();
const toastVisibleAtPromise = toast
.waitFor({ state: "visible", timeout: 30_000 })
.then(() => performance.now());
await relay.stop();
const toastVisibleAt = await toastVisibleAtPromise;
await expectRunningStatusPreserved(page, agentTitle);
await page.waitForTimeout(750);
const disconnectedText = await latestAssistantText(page);
await page.waitForTimeout(1_000);
const streamPausedWhileDisconnected = (await latestAssistantText(page)) === disconnectedText;
expect(streamPausedWhileDisconnected).toBe(true);
const relayStartAt = performance.now();
await relay.start();
const relayReadyAt = performance.now();
await toast.waitFor({ state: "hidden", timeout: 30_000 });
const reconnectedAt = performance.now();
await expectRunningAgentChrome(page, agentTitle);
await waitForAssistantTextToGrow(page, disconnectedText);
return {
reconnectToastDelayMs: Math.round(toastVisibleAt - outageStartedAt),
reconnectToastVisibleMs: Math.round(reconnectedAt - toastVisibleAt),
totalReconnectMs: Math.round(reconnectedAt - outageStartedAt),
relayStartupMs: Math.round(relayReadyAt - relayStartAt),
streamPausedWhileDisconnected,
runningStatePreserved: true,
streamResumedWithoutUserAction: true,
};
}
@@ -95,9 +95,12 @@ export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
const toast = page.getByTestId("agent-reconnecting-toast");
await expect(toast).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
await expect(toast).toHaveText("Reconnecting");
await expect(toast.getByTestId("agent-reconnecting-status-dot")).toBeVisible();
}
export async function expectReconnectingToastGone(
@@ -109,6 +112,14 @@ export async function expectReconnectingToastGone(
});
}
export async function expectReconnectingToastDebounced(
page: Page,
options?: { durationMs?: number },
): Promise<void> {
await page.waitForTimeout(options?.durationMs ?? 750);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
}
export async function expectHostConnectingOrOffline(
page: Page,
options?: { timeout?: number },