Files
paseo/packages/app/e2e/browser/viewed-agent-timelines.spec.ts
Mohamed Boudra 9696f42264 Subscribe only the chats this session opened (#5040)
* fix(app): subscribe only the chats this session opened

Open-chat membership was sourced from the persisted workspace layout, which is
restored from disk at launch and carries a tab for every chat the user has ever
opened on the host. Every member got an authoritative timeline catch-up at
session start, and each of those fetches resumed its agent on the daemon: one
provider session per historical tab, every launch.

Two things followed from each resume. Registration stamped `updatedAt` over the
restored timestamp, and the resumed agent started with no attention because the
resume path never read the stored flag. Both were persisted, so restarting Paseo
rewrote every workspace's last-used time in the sidebar and cleared Ready to
review on chats the user never looked at. The timestamp loss was permanent:
workspace `statusEnteredAt` is re-derived from `updatedAt` on every daemon start.

Keep the open set on the session instead. A chat joins it the first time the user
opens it and stays until its tab closes or the session ends, so hiding, switching
workspaces, evicting a retained view, backgrounding and reconnect all preserve the
subscription, with no recent-agent limit. Layout keeps its release role and loses
its source role.

Harden the producer as well, so a resume from any caller stays inert: registration
adopts restored timestamps rather than stamping now, and resume carries the stored
attention through.

* fix(agents): keep a resumed agent's updatedAt from moving backwards

Dropping the registration stamp exposed a skew the stamp had been erasing.
`extractTimestamps` read `lastActivityAt ?? updatedAt`, but the two diverge:
renaming, labelling, restoring from archive, marking unread, and clearing
attention all move `updatedAt` on an unloaded agent without touching
`lastActivityAt`. Eight of 854 agent records on one dev machine carry that skew,
up to eight days of it.

A resume then handed clients a timestamp older than one they already held, and
`acceptAgentDirectoryUpdate` drops any state update that goes backwards. Marking
a chat unread without opening it, then opening it, left the unread dot stuck in
History, the agent list, Command Center and the tab: the daemon cleared attention
and the client refused the update.

Read both fields and take the newest, which is already the rule
`resolveStoredAgentPayloadUpdatedAt` applies to the list payload. Move that
function beside the other stored-record readers as `resolveStoredAgentUpdatedAt`
so one rule covers both paths.

Also replace the `!options?.updatedAt` inference in `registerSession` with an
explicit `restoring` flag. The inference was correct for all four call sites, but
it made a second meaning out of a field, and the next caller to seed a timestamp
would have opted out of activity stamping silently.

* test(app): assert a reloaded layout subscribes only the chat it restores into

No e2e reloaded the page with a populated workspace layout and asserted which
agents the daemon acknowledged, so the regression this branch fixes had no
end-to-end guard. Opening four chats across two workspaces leaves a tab per chat
in persisted layout; after a reload only the restored chat may be subscribed,
because subscribing resumes that agent on the daemon.

Verified it fails when session-context's layout callback is pointed back at
replaceVisibleAgentIds: the three restored tabs nobody opened join the
acknowledged set.
2026-09-18 09:12:43 +00:00

457 lines
18 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "../support/fixtures";
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
} from "../support/helpers/agent-stream";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
interface ViewedTimelineScenario {
client: SeedDaemonClient;
workspaceId: string;
repoPath: string;
firstAgentId: string;
secondAgentId: string;
cleanup(): Promise<void>;
}
async function seedViewedTimelineScenario(
options: { firstAgentModel?: string } = {},
): Promise<ViewedTimelineScenario> {
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
const createAgent = (title: string, model = "ten-second-stream") =>
workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model,
});
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat", options.firstAgentModel),
createAgent("Second viewed chat"),
]);
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
repoPath: workspace.repoPath,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
}
interface RestoredLayoutChat {
workspaceId: string;
agentId: string;
title: string;
}
interface RestoredLayoutScenario {
chats: RestoredLayoutChat[];
cleanup(): Promise<void>;
}
/**
* Two workspaces with two chats each. Opening all four leaves the persisted workspace layout
* holding a tab per chat, which is the state a relaunch restores into.
*/
async function seedRestoredLayoutScenario(): Promise<RestoredLayoutScenario> {
const workspaces = await Promise.all([
seedWorkspace({ repoPrefix: "restored-layout-first-" }),
seedWorkspace({ repoPrefix: "restored-layout-second-" }),
]);
try {
const chats = await Promise.all(
workspaces.flatMap((workspace, workspaceIndex) =>
[1, 2].map(async (chatIndex): Promise<RestoredLayoutChat> => {
const title = `Restored chat ${workspaceIndex + 1}-${chatIndex}`;
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
});
return { workspaceId: workspace.workspaceId, agentId: agent.id, title };
}),
),
);
return {
chats,
cleanup: async () => {
for (const workspace of workspaces) await workspace.cleanup();
},
};
} catch (error) {
for (const workspace of workspaces) await workspace.cleanup().catch(() => undefined);
throw error;
}
}
async function openAgent(
page: Page,
scenario: { workspaceId: string },
agentId: string,
options: { recordRenders?: boolean } = {},
) {
const route = buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId);
await page.goto(options.recordRenders ? `${route}&renderProfile=1` : route);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
);
await waitForWorkspaceTabsVisible(page);
}
async function selectAgent(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function expectForkFailureWithoutOverlappingStatus(page: Page) {
const expectContinuousToast = await observeToastReplacement(page);
await page.getByRole("button", { name: "Fork chat from here" }).last().click();
await page.getByRole("menuitem", { name: "Fork in a new tab", exact: true }).click();
await expect(
page.getByRole("alert").filter({ hasText: "Transport not connected" }),
).toBeVisible();
await expectReconnectingToastGone(page, { timeout: 100 });
await expectReconnectingToastVisible(page);
await expectContinuousToast();
}
async function observeToastReplacement(page: Page) {
const observation = await page
.getByRole("alert")
.filter({ hasText: "Reconnecting to host" })
.evaluateHandle((toast) => {
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
let frame = 0;
const sample = () => {
const style = getComputedStyle(toast);
frames.push({
connected: toast.isConnected,
opacity: Number(style.opacity),
transform: style.transform,
});
frame = requestAnimationFrame(sample);
};
sample();
return {
stop() {
cancelAnimationFrame(frame);
return frames;
},
};
});
return async () => {
const frames = await observation.evaluate((recorder) => recorder.stop());
await observation.dispose();
expect(frames.length).toBeGreaterThan(1);
expect(frames.every((frame) => frame.connected)).toBe(true);
expect(Math.min(...frames.map((frame) => frame.opacity))).toBe(1);
expect(new Set(frames.map((frame) => frame.transform)).size).toBe(1);
};
}
async function countChatCommits(page: Page, agentId: string) {
return page.evaluate(
(id) =>
(globalThis.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === `AgentStreamSection:${id}`,
).length,
agentId,
);
}
async function enableMoveTabShortcut(page: Page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
});
}
async function moveActiveTabRight(page: Page) {
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
}
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
await scenario.client.sendAgentMessage(agentId, prompt);
const finish = await scenario.client.waitForFinish(agentId, 30_000);
expect(finish.status).toBe("idle");
}
async function startVisibleTurn(
page: Page,
scenario: ViewedTimelineScenario,
prompt: string,
): Promise<void> {
await scenario.client.sendAgentMessage(scenario.firstAgentId, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
await expectInlineWorkingIndicator(page);
}
async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<void> {
const tab = page.getByRole("button", { name: title, exact: true });
await expect(tab.locator('[data-status-bucket="running"]')).toHaveCount(0);
await expectAgentIdle(page);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expectTurnCopyButton(page);
}
async function withViewedTimelineScenario(
run: (scenario: ViewedTimelineScenario) => Promise<void>,
) {
const scenario = await seedViewedTimelineScenario();
try {
await run(scenario);
} finally {
await scenario.cleanup();
}
}
async function openSevenChats(page: Page, scenario: ViewedTimelineScenario) {
const subscriptions = observeTimelineSubscriptions(page);
const additional = [];
for (let index = 0; index < 5; index += 1) {
additional.push(
await scenario.client.createAgent({
provider: "mock",
cwd: scenario.repoPath,
workspaceId: scenario.workspaceId,
title: `Additional chat ${index + 1}`,
modeId: "load-test",
model: "ten-second-stream",
}),
);
}
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
for (let index = 0; index < additional.length; index += 1) {
await selectAgent(page, `Additional chat ${index + 1}`);
}
await subscriptions.waitForSubscribedAgents([
scenario.firstAgentId,
scenario.secondAgentId,
...additional.map((agent) => agent.id),
]);
}
async function expectCurrentChatWithoutCatchUp(page: Page, message: string) {
await expect(page.getByText(message, { exact: true })).toBeVisible();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
test.describe("Viewed agent timelines", () => {
test("a reloaded layout subscribes only the chat it restores into", async ({ page }) => {
test.setTimeout(120_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedRestoredLayoutScenario();
try {
// One document load, then sidebar and tab clicks — the way a user reaches these chats.
// A `page.goto` per chat would restart the app and empty the session's set each time.
const [first, second, third, fourth] = scenario.chats;
await openAgent(page, first!, first!.agentId);
await selectAgent(page, second!.title);
await selectWorkspaceInSidebar(page, third!.workspaceId);
await selectAgent(page, third!.title);
await selectAgent(page, fourth!.title);
await subscriptions.waitForSubscribedAgents(scenario.chats.map((chat) => chat.agentId));
// Relaunch. Layout comes back from disk carrying a tab for every chat above; only the
// one on screen may be subscribed, because subscribing resumes the agent on the daemon.
const restored = scenario.chats.at(-1)!;
const sibling = scenario.chats.at(-2)!;
subscriptions.reset();
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: restored.title, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: sibling.title, exact: true })).toBeVisible();
await subscriptions.waitForSubscribedAgents([restored.agentId]);
// Opening the restored sibling is what adds it, and it adds only itself.
await selectAgent(page, sibling.title);
await subscriptions.waitForSubscribedAgents([restored.agentId, sibling.agentId]);
} finally {
await scenario.cleanup();
}
});
test("open chats stay current after switching beyond five agents", async ({ page }) => {
await withViewedTimelineScenario(async (scenario) => {
await openSevenChats(page, scenario);
await commitMessage(scenario, scenario.firstAgentId, "Current after seven open chats.");
await selectAgent(page, "First viewed chat");
await expectCurrentChatWithoutCatchUp(page, "Current after seven open chats.");
});
});
test("a turn that finishes while hidden reopens with consistently idle chrome", async ({
page,
}) => {
test.setTimeout(150_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
try {
await openAgent(page, scenario, scenario.firstAgentId);
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
expect(finish.status).toBe("idle");
await selectAgent(page, "First viewed chat");
await expectAgentConsistentlyIdle(page, "First viewed chat");
} finally {
await scenario.cleanup();
}
});
test("a hidden retained chat stays current without rendering", async ({ page }) => {
test.setTimeout(60_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId, { recordRenders: true });
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Unsent draft survives hidden streaming");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
const hiddenCommits = await countChatCommits(page, scenario.firstAgentId);
expect(hiddenCommits).toBeGreaterThan(0);
await commitMessage(
scenario,
scenario.firstAgentId,
"Committed while the first chat is hidden.",
);
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toHaveCount(0);
expect(await countChatCommits(page, scenario.firstAgentId)).toBe(hiddenCommits);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(composer).toHaveValue("Unsent draft survives hidden streaming");
await composer.fill("Draft edited after returning");
await expect(composer).toHaveValue("Draft edited after returning");
} finally {
await scenario.cleanup();
}
});
test("two visible split chats both stay current", async ({ page }) => {
const scenario = await seedViewedTimelineScenario();
try {
await enableMoveTabShortcut(page);
await openAgent(page, scenario, scenario.firstAgentId);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await selectAgent(page, "Second viewed chat");
await moveActiveTabRight(page);
await expect(
page.getByRole("button", { name: "First viewed chat", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("a visible chat reports updating until reconnect catch-up completes", async ({
page,
}, testInfo) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await commitMessage(scenario, scenario.firstAgentId, "Visible before the connection drops.");
await openAgent(page, scenario, scenario.firstAgentId);
const previousMessage = page.getByText("Visible before the connection drops.", {
exact: true,
});
await expect(previousMessage).toBeVisible();
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
"aria-selected",
"true",
);
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCSS("opacity", "1");
await page.screenshot({ path: testInfo.outputPath("reconnecting-chat.png") });
await expectForkFailureWithoutOverlappingStatus(page);
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
page.getByText("Committed while the chat reconnects.", { exact: true }),
).toHaveCount(0);
// Hold the visible response to observe the connected-but-updating state.
gate.holdTimelineResponses(scenario.firstAgentId);
const expectContinuousToast = await observeToastReplacement(page);
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectReconnectingToastGone(page);
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
await expect(
page.getByTestId("app-toast-message").filter({ hasText: "Updating messages" }),
).toBeVisible();
await expect(previousMessage).toBeVisible();
await expect(page.getByTestId("agent-updating-toast")).toHaveCSS("opacity", "1");
await expectContinuousToast();
await page.screenshot({ path: testInfo.outputPath("updating-chat.png") });
gate.releaseHeldTimelineResponses();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
exact: true,
});
await expect(recoveredMessage).toHaveCount(1);
await expect(recoveredMessage).toBeVisible();
} finally {
gate.restore();
await scenario.cleanup();
}
});
test("preserves reconnecting toast through retained tab switches", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await selectAgent(page, "First viewed chat");
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await selectAgent(page, "Second viewed chat");
await expectReconnectingToastVisible(page, { timeout: 500 });
} finally {
gate.restore();
await scenario.cleanup();
}
});
});