* feat(sync): keep live data scoped and current Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates. * fix(sync): preserve selective delivery boundaries Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries. * test(app): align browser fixtures with runtime contracts * fix(sync): preserve mixed-client delivery guarantees * fix(sync): finish paged history before advancing * fix(sync): replay live deltas after refresh failures * test(server): model socket capability lookup * test(app): use platform shortcut for split-pane coverage * fix(app): keep hidden timelines dormant * test(app): normalize split-pane shortcut setup * fix(app): preserve sync state across retries * fix(app): preserve background sync state across races * fix directory bootstrap reconciliation edge cases * fix selective timeline compatibility races * fix superseded directory sync races * fix(sync): centralize directory replica ordering Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources. * fix(app): keep timeline requests runtime-scoped * fix(app): close directory bootstrap races
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
|
import { test } from "./fixtures";
|
|
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
|
import { getServerId } from "./helpers/server-id";
|
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
|
import {
|
|
expectReconnectingToastGone,
|
|
expectReconnectingToastVisible,
|
|
} from "./helpers/workspace-ui";
|
|
|
|
interface ViewedTimelineScenario {
|
|
client: SeedDaemonClient;
|
|
workspaceId: string;
|
|
firstAgentId: string;
|
|
secondAgentId: string;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
async function seedViewedTimelineScenario(): Promise<ViewedTimelineScenario> {
|
|
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
|
const createAgent = (title: string) =>
|
|
workspace.client.createAgent({
|
|
provider: "mock",
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title,
|
|
modeId: "load-test",
|
|
model: "ten-second-stream",
|
|
});
|
|
const [firstAgent, secondAgent] = await Promise.all([
|
|
createAgent("First viewed chat"),
|
|
createAgent("Second viewed chat"),
|
|
]);
|
|
return {
|
|
client: workspace.client,
|
|
workspaceId: workspace.workspaceId,
|
|
firstAgentId: firstAgent.id,
|
|
secondAgentId: secondAgent.id,
|
|
cleanup: workspace.cleanup,
|
|
};
|
|
}
|
|
|
|
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
|
|
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
|
|
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 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");
|
|
}
|
|
|
|
test.describe("Viewed agent timelines", () => {
|
|
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
|
const scenario = await seedViewedTimelineScenario();
|
|
try {
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await selectAgent(page, "Second viewed chat");
|
|
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);
|
|
await selectAgent(page, "First viewed chat");
|
|
await expect(
|
|
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
|
).toBeVisible();
|
|
} 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 page.getByRole("button", { name: "Split pane right" }).click();
|
|
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 catches up after reconnecting", async ({ page }) => {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const scenario = await seedViewedTimelineScenario();
|
|
try {
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
await gate.drop();
|
|
await expectReconnectingToastVisible(page);
|
|
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
|
await expect(
|
|
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
|
).toHaveCount(0);
|
|
gate.restore();
|
|
await expectReconnectingToastGone(page);
|
|
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();
|
|
}
|
|
});
|
|
});
|