Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure * Revert "Keep older chat history and image previews stable (#2490)" The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework. * Revert "Stop completed turns from appearing stuck (#2484)" See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
This commit is contained in:
@@ -1,685 +0,0 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "./fixtures";
|
||||
import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream";
|
||||
import { gateNextAgentMessage } from "./helpers/agent-message-gate";
|
||||
import {
|
||||
attachImageFromMenu,
|
||||
expectComposerDraft,
|
||||
expectComposerEditable,
|
||||
expectAttachmentPill,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
sendDraftToQueue,
|
||||
startRunningMockAgent,
|
||||
} from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { readScrollMetrics } from "./helpers/agent-bottom-anchor";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
|
||||
const IMAGE = {
|
||||
name: "message-submission.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
|
||||
interface MessageGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface SubmissionScenario {
|
||||
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
|
||||
}
|
||||
|
||||
interface DraftCreateScenario {
|
||||
workspaceId: string;
|
||||
agentCreatedDelay: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}
|
||||
|
||||
interface RejectionScenario {
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
interface UnrelatedRunningScenario {
|
||||
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{
|
||||
submissionScenario: SubmissionScenario;
|
||||
draftCreateScenario: DraftCreateScenario;
|
||||
rejectionScenario: RejectionScenario;
|
||||
unrelatedRunningScenario: UnrelatedRunningScenario;
|
||||
}>({
|
||||
submissionScenario: async ({ page }, provide, testInfo) => {
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `message-submission-${testInfo.workerIndex}-`,
|
||||
title: "Message submission regression",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
await provide({ gate });
|
||||
await agent.cleanup();
|
||||
},
|
||||
draftCreateScenario: async ({ page }, provide, testInfo) => {
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`,
|
||||
});
|
||||
await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay });
|
||||
agentCreatedDelay.release();
|
||||
await workspace.cleanup();
|
||||
},
|
||||
rejectionScenario: async ({ page }, provide, testInfo) => {
|
||||
const errorMessage = "Requested mock prompt rejection";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `message-rejection-${testInfo.workerIndex}-`,
|
||||
title: "Message rejection regression",
|
||||
model: "ten-second-stream",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
await provide({ errorMessage });
|
||||
await agent.cleanup();
|
||||
},
|
||||
unrelatedRunningScenario: async ({ page }, provide, testInfo) => {
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `unrelated-running-${testInfo.workerIndex}-`,
|
||||
title: "Unrelated running transition",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
await provide({ gate, agent });
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function submitMessageWithImage(page: Page, prompt: string): Promise<Locator> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await composer.press("Enter");
|
||||
const nextFrame = await composer.evaluate(
|
||||
(composerElement, submittedPrompt) =>
|
||||
new Promise<{
|
||||
rowPresent: boolean;
|
||||
workingPresent: boolean;
|
||||
composerValue: string | null;
|
||||
attachmentPresent: boolean;
|
||||
}>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]'));
|
||||
const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement;
|
||||
resolve({
|
||||
rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)),
|
||||
workingPresent: Boolean(
|
||||
document.querySelector('[data-testid="turn-working-indicator"]'),
|
||||
),
|
||||
composerValue: composerInput.value,
|
||||
attachmentPresent: Boolean(
|
||||
document.querySelector('[data-testid="composer-image-attachment-pill"]'),
|
||||
),
|
||||
});
|
||||
});
|
||||
}),
|
||||
prompt,
|
||||
);
|
||||
expect(nextFrame).toEqual({
|
||||
rowPresent: true,
|
||||
workingPresent: true,
|
||||
composerValue: "",
|
||||
attachmentPresent: false,
|
||||
});
|
||||
return page.getByTestId("user-message").filter({ hasText: prompt }).last();
|
||||
}
|
||||
|
||||
async function submitImageOnlyMessage(page: Page): Promise<Locator> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").last();
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
return userMessage;
|
||||
}
|
||||
|
||||
async function expectPendingSubmission(page: Page, userMessage: Locator): Promise<void> {
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached();
|
||||
await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0");
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "true");
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function readMessageGeometry(page: Page, userMessage: Locator): Promise<MessageGeometry> {
|
||||
const box = await userMessage.boundingBox();
|
||||
if (!box) throw new Error("Submitted user message has no browser geometry");
|
||||
const { offsetY } = await readScrollMetrics(page);
|
||||
return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height };
|
||||
}
|
||||
|
||||
async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise<void>> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const state = { active: true, sawMissing: false };
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionFooterContinuity = state;
|
||||
const checkFrame = () => {
|
||||
if (!state.active) return;
|
||||
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
|
||||
state.sawMissing = true;
|
||||
}
|
||||
requestAnimationFrame(checkFrame);
|
||||
};
|
||||
requestAnimationFrame(checkFrame);
|
||||
});
|
||||
|
||||
return async () => {
|
||||
const sawMissing = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionFooterContinuity as
|
||||
| { active: boolean; sawMissing: boolean }
|
||||
| undefined;
|
||||
if (!state) throw new Error("Working-footer continuity check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionFooterContinuity;
|
||||
return state.sawMissing;
|
||||
});
|
||||
expect(sawMissing).toBe(false);
|
||||
};
|
||||
}
|
||||
|
||||
async function expectAcceptedSubmission(
|
||||
page: Page,
|
||||
userMessage: Locator,
|
||||
submittedGeometry: MessageGeometry,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry);
|
||||
}
|
||||
|
||||
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await composer.press("Enter");
|
||||
}
|
||||
|
||||
async function expectRejectedSubmissionRestored(
|
||||
page: Page,
|
||||
input: { prompt: string; errorMessage: string },
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toHaveCount(1);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function queueMessage(page: Page, prompt: string): Promise<void> {
|
||||
await fillComposerDraft(page, prompt);
|
||||
await sendDraftToQueue(page);
|
||||
}
|
||||
|
||||
async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount(
|
||||
prompts.length,
|
||||
);
|
||||
for (const prompt of prompts) {
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise<void> {
|
||||
await expectComposerDraft(page, prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectInterruptedTurnOrderAfterReconnect(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`,
|
||||
title: "Submission reconnect ordering",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const prompt = "Keep this prompt before its response.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted.");
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await queueMessage(page, prompt);
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).click();
|
||||
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible();
|
||||
await gate.waitForServerMessage("send_agent_message_response");
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.forceNextTimelineEpochReset();
|
||||
gate.restoreFresh();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
|
||||
title: "Submission missed running transition",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
gate.holdNextClientRequest("send_agent_message_request");
|
||||
const userMessage = await submitImageOnlyMessage(page);
|
||||
await gate.waitForHeldClientRequest();
|
||||
gate.setServerMessageSuppressed("agent_status", true);
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
gate.releaseHeldClientRequest();
|
||||
await gate.waitForServerMessage("send_agent_message_response");
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
gate.setServerMessageSuppressed("agent_status", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
gate.restoreFresh();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`,
|
||||
title: "Submission acknowledgement before RPC",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const prompt = "Settle this provider-acknowledged submission.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
gate.setServerMessageSuppressed("agent_status", true);
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
const userMessage = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForHeldServerMessage();
|
||||
await gate.waitForAgentStreamItem("user_message");
|
||||
gate.releaseHeldServerMessage();
|
||||
await gate.drop();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLegacyAssistantStartsAfterInterruptedPrompt(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`,
|
||||
title: "Legacy assistant interrupt boundary",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const prompt = "Start the replacement answer after this prompt.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer.");
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await queueMessage(page, prompt);
|
||||
gate.setAssistantMessageIdsStripped(true);
|
||||
gate.setAgentStreamEventSuppressed("turn_canceled", true);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).click();
|
||||
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(replacementAnswer).toBeVisible({ timeout: 30_000 });
|
||||
await expectRenderedBefore(promptRow, replacementAnswer);
|
||||
} finally {
|
||||
gate.setAssistantMessageIdsStripped(false);
|
||||
gate.setAgentStreamEventSuppressed("turn_canceled", false);
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectStaleCanonicalPagePreservesNewerLiveOutput(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`,
|
||||
title: "Stale canonical page race",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call.");
|
||||
await awaitToolCall(page, "read");
|
||||
await page
|
||||
.getByRole("button", { name: /stop|cancel/i })
|
||||
.first()
|
||||
.click();
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.requestTimelineTail(agent.agentId);
|
||||
await gate.waitForHeldServerMessage();
|
||||
gate.truncateHeldTimelineAfterLast("tool_call");
|
||||
expect(gate.getHeldTimelineLastItemType()).toBe("tool_call");
|
||||
|
||||
const nextPrompt = "Stream after the stale snapshot.";
|
||||
await agent.client.sendAgentMessage(agent.agentId, nextPrompt);
|
||||
const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt });
|
||||
const liveAssistant = nextPromptRow.locator(
|
||||
'xpath=following::*[@data-testid="assistant-message"][1]',
|
||||
);
|
||||
await expect(nextPromptRow).toBeVisible();
|
||||
await expect(liveAssistant).toContainText("Cycle 1");
|
||||
gate.releaseHeldServerMessage();
|
||||
await expect(liveAssistant).toContainText("Cycle 1");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCanonicalOrderWinsAcrossOverlappingClients(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
|
||||
title: "Cross-client submission order",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const localPrompt = "Send this after the other client turn.";
|
||||
const remotePrompt = "Commit this other client turn first.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
gate.holdNextClientRequest("send_agent_message_request");
|
||||
const localRow = await submitMessageWithImage(page, localPrompt);
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, remotePrompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt });
|
||||
await expect(remoteRow).toBeVisible();
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.releaseHeldClientRequest();
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
await expect(localRow).toHaveAttribute("aria-busy", "false");
|
||||
await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const localElement = await localRow.elementHandle();
|
||||
if (!localElement) return false;
|
||||
return remoteRow.evaluate(
|
||||
(remoteElement, localNode) =>
|
||||
Boolean(
|
||||
remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
localElement,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRenderedBefore(first: Locator, second: Locator): Promise<void> {
|
||||
const secondElement = await second.elementHandle();
|
||||
if (!secondElement) throw new Error("Expected the second timeline item to be rendered");
|
||||
expect(
|
||||
await first.evaluate(
|
||||
(firstElement, secondNode) =>
|
||||
Boolean(
|
||||
firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
secondElement,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectCreatedAgentHandoff(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
userMessage: Locator,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
}
|
||||
|
||||
interface DraftCreatePendingSubmission {
|
||||
prompt: string;
|
||||
userMessage: Locator;
|
||||
}
|
||||
|
||||
async function beginDraftCreateSubmission(
|
||||
page: Page,
|
||||
scenario: DraftCreateScenario,
|
||||
): Promise<DraftCreatePendingSubmission> {
|
||||
await openWorkspaceDraft(page, scenario.workspaceId);
|
||||
await selectModel(page, "one-minute-stream");
|
||||
const prompt = "Keep this row through create handoff.";
|
||||
const userMessage = await submitMessageWithImage(page, prompt);
|
||||
await scenario.agentCreatedDelay.waitForCreateRequest();
|
||||
await scenario.agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
await expectPendingSubmission(page, userMessage);
|
||||
return { prompt, userMessage };
|
||||
}
|
||||
|
||||
async function completeDraftCreateSubmission(
|
||||
page: Page,
|
||||
scenario: DraftCreateScenario,
|
||||
pending: DraftCreatePendingSubmission,
|
||||
): Promise<void> {
|
||||
scenario.agentCreatedDelay.release();
|
||||
await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage);
|
||||
}
|
||||
|
||||
test.describe("Agent message submission", () => {
|
||||
test("keeps the submitted row stable when the host accepts", async ({
|
||||
page,
|
||||
submissionScenario,
|
||||
}) => {
|
||||
const userMessage = await submitMessageWithImage(page, "Hold this submission.");
|
||||
await expectPendingSubmission(page, userMessage);
|
||||
await submissionScenario.gate.waitForRequest();
|
||||
const submittedGeometry = await readMessageGeometry(page, userMessage);
|
||||
const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page);
|
||||
submissionScenario.gate.accept();
|
||||
await expectAcceptedSubmission(page, userMessage, submittedGeometry);
|
||||
await finishFooterContinuityCheck();
|
||||
});
|
||||
|
||||
test("keeps the submitted row stable through draft create handoff", async ({
|
||||
page,
|
||||
draftCreateScenario,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
page,
|
||||
rejectionScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this rejected submission.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
test("restores overlapping queued sends when their connection fails", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `overlapping-queued-send-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep the agent running while messages queue.",
|
||||
});
|
||||
const prompts = ["Restore the first queued send.", "Restore the second queued send."];
|
||||
try {
|
||||
await queueMessage(page, prompts[0]);
|
||||
await queueMessage(page, prompts[1]);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).first().click();
|
||||
await gate.waitForRequest(1);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).first().click();
|
||||
await gate.waitForRequest(2);
|
||||
await gate.disconnect();
|
||||
await expectQueuedSendFailuresRestored(page, prompts);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not accept a failed submission from an unrelated running turn", async ({
|
||||
page,
|
||||
unrelatedRunningScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this unsent prompt.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await unrelatedRunningScenario.gate.waitForRequest();
|
||||
await unrelatedRunningScenario.agent.client.sendAgentMessage(
|
||||
unrelatedRunningScenario.agent.agentId,
|
||||
"Start an unrelated turn.",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }),
|
||||
).toBeVisible();
|
||||
await unrelatedRunningScenario.gate.disconnect();
|
||||
await expectFailedSubmissionRestored(page, prompt);
|
||||
});
|
||||
|
||||
test("keeps a submitted prompt before its response when canonical history arrives", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears a provider acknowledgement that arrives before RPC acceptance", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo);
|
||||
});
|
||||
|
||||
test("keeps an old-daemon replacement answer after its interrupted prompt", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo);
|
||||
});
|
||||
|
||||
test("preserves newer live output when a stale canonical page arrives", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo);
|
||||
});
|
||||
|
||||
test("uses canonical order when another client turn overtakes a held submission", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test as base } from "./fixtures";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
createNearTenMegabyteAssistantPng,
|
||||
createSettledMockAgent,
|
||||
createSmallAssistantPng,
|
||||
emitSettledAssistantImage,
|
||||
expectAssistantImageNotMounted,
|
||||
expectAssistantImageRendered,
|
||||
openAssistantImageTimeline,
|
||||
openExistingImageAgentTabs,
|
||||
remountAndRecoverAssistantImageFromHistory,
|
||||
sendFollowUpAndExpectVisibleResponse,
|
||||
switchAwayAndBackWithoutImageInstability,
|
||||
userPagesUntilAssistantImageRenders,
|
||||
} from "./helpers/assistant-images";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
imageWorkspace: async ({ page: _page }, provide) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
|
||||
try {
|
||||
await provide(workspace);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Real file image",
|
||||
fileName: "assistant-preview.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Image timeline");
|
||||
const otherAgent = await createSettledMockAgent(workspace, "Other timeline");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
|
||||
await openExistingImageAgentTabs(page, { imageAgent, otherAgent });
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
|
||||
});
|
||||
|
||||
test("a real assistant PNG remains reachable through pagination and remount", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Paginated real file image",
|
||||
fileName: "paginated-assistant-preview.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await appendSettledTimelineTurns(workspace.client, imageAgent, 40);
|
||||
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageNotMounted(page, image);
|
||||
await userPagesUntilAssistantImageRenders(page, image);
|
||||
await remountAndRecoverAssistantImageFromHistory(page, image);
|
||||
});
|
||||
|
||||
test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const image = await createNearTenMegabyteAssistantPng(workspace, {
|
||||
alt: "Large real file image",
|
||||
fileName: "large-assistant-preview.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Large image timeline");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await sendFollowUpAndExpectVisibleResponse(page, {
|
||||
prompt: "confirm responsiveness: emit 1 coalesced agent stream updates",
|
||||
response: "stress-update-0",
|
||||
});
|
||||
});
|
||||
@@ -1,197 +1,46 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectLoadedTimelineDoesNotScroll,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptPositionPreserved,
|
||||
expectTimelinePromptVisible,
|
||||
holdBootstrapTimelinePage,
|
||||
holdDaemonHydration,
|
||||
holdOlderHistoryPages,
|
||||
holdNextOlderTimelinePage,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
seedLongMockAgentTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "./helpers/timeline-pagination";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("loads one page each time the user returns to history start", async ({ page }) => {
|
||||
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
|
||||
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps visible history anchored when live output grows during a prepend", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
|
||||
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
"timeline live during held older page: emit 20 coalesced agent stream updates",
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
history.releasePage(1);
|
||||
|
||||
await expectTimelinePromptPositionPreserved(page, position);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("finishes loading an older page while live output continues", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles");
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
);
|
||||
history.releasePage(1);
|
||||
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
|
||||
const running = await agent.client.fetchAgents({ scope: "active" });
|
||||
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
|
||||
"running",
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
|
||||
|
||||
history.releasePage(1);
|
||||
|
||||
await expectTimelinePromptPositionPreserved(page, position);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("continues one loading operation while older pages still leave history start exposed", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
try {
|
||||
await makeLoadedTimelineFitViewport(page);
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
const olderPage = await holdNextOlderTimelinePage(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
const loading = await rememberOlderHistoryLoadingOperation(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectTimelineAtHistoryStart(page);
|
||||
await expectSameOlderHistoryLoadingOperation(page, loading);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps complete loaded history reachable after reload", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectLoadedTimelineDoesNotScroll(page);
|
||||
await olderPage.expectLoading();
|
||||
olderPage.release();
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
|
||||
const hydration = await holdDaemonHydration(page);
|
||||
await reloadAgentTimelineFromPersistedReplica(page, agent);
|
||||
hydration.release();
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves a live row received before replica hydration", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
|
||||
const hydration = await holdBootstrapTimelinePage(page, agent);
|
||||
await reloadAgentTimelineFromPersistedReplica(page, agent);
|
||||
await hydration.waitForDelayedResponse();
|
||||
const livePrompt = await sendLiveTurnBeforeHydration(agent);
|
||||
await expectTimelinePromptVisible(page, livePrompt);
|
||||
|
||||
hydration.release();
|
||||
await hydration.waitForDelayedCatchUp();
|
||||
await expectTimelinePromptVisible(page, livePrompt);
|
||||
hydration.releaseCatchUp();
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, livePrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface SendAgentMessageRequest {
|
||||
type: "send_agent_message_request";
|
||||
requestId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: Record<string, unknown>;
|
||||
};
|
||||
const request = envelope.type === "session" ? envelope.message : null;
|
||||
if (
|
||||
request?.type !== "send_agent_message_request" ||
|
||||
typeof request.requestId !== "string" ||
|
||||
typeof request.agentId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "send_agent_message_request",
|
||||
requestId: request.requestId,
|
||||
agentId: request.agentId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function gateNextAgentMessage(page: Page) {
|
||||
let serverSocket: WebSocketRoute | null = null;
|
||||
let browserSocket: WebSocketRoute | null = null;
|
||||
const heldMessages: Array<WebSocketMessage | null> = [];
|
||||
const requests: SendAgentMessageRequest[] = [];
|
||||
const requestWaiters = new Set<() => void>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
browserSocket = ws;
|
||||
const server = ws.connectToServer();
|
||||
serverSocket = server;
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = readSendRequest(message);
|
||||
if (request) {
|
||||
heldMessages.push(message);
|
||||
requests.push(request);
|
||||
for (const resolve of requestWaiters) resolve();
|
||||
requestWaiters.clear();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => ws.send(message));
|
||||
});
|
||||
|
||||
const waitForRequest = async (count = 1): Promise<SendAgentMessageRequest> => {
|
||||
while (requests.length < count) {
|
||||
await new Promise<void>((resolve) => requestWaiters.add(resolve));
|
||||
}
|
||||
return requests[count - 1];
|
||||
};
|
||||
|
||||
return {
|
||||
waitForRequest,
|
||||
accept(index = 0) {
|
||||
const heldMessage = heldMessages[index];
|
||||
if (!serverSocket || !heldMessage) {
|
||||
throw new Error("No held send-agent-message request to accept");
|
||||
}
|
||||
serverSocket.send(heldMessage);
|
||||
heldMessages[index] = null;
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
|
||||
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,21 +15,6 @@ export interface AgentTimelineResponseGate {
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesGate {
|
||||
getRequestCount(): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
|
||||
releaseCatchUp(): void;
|
||||
waitForDelayedCatchUp(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -60,32 +45,6 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
|
||||
let released = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (released) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
released = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
@@ -168,142 +127,6 @@ export async function delayCreatedAgentInitialTailResponse(
|
||||
export async function delayAgentOlderTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
return delayAgentTimelineResponse(page, agentId, "before");
|
||||
}
|
||||
|
||||
export async function holdAgentOlderTimelinePages(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId &&
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
) {
|
||||
responseCount += 1;
|
||||
const pageNumber = responseCount;
|
||||
if (releasedPages.has(pageNumber)) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
const forwards = delayedForwards.get(pageNumber) ?? [];
|
||||
forwards.push(() => ws.send(message));
|
||||
delayedForwards.set(pageNumber, forwards);
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getRequestCount: () => requestCount,
|
||||
releasePage(pageNumber) {
|
||||
releasedPages.add(pageNumber);
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentBootstrapTailResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<BootstrapTimelineGate> {
|
||||
let tailReleased = false;
|
||||
let catchUpReleased = false;
|
||||
const delayedTailForwards: Array<() => void> = [];
|
||||
const delayedCatchUpForwards: Array<() => void> = [];
|
||||
let resolveDelayedTail: (() => void) | null = null;
|
||||
let resolveDelayedCatchUp: (() => void) | null = null;
|
||||
const delayedTail = new Promise<void>((resolve) => {
|
||||
resolveDelayedTail = resolve;
|
||||
});
|
||||
const delayedCatchUp = new Promise<void>((resolve) => {
|
||||
resolveDelayedCatchUp = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
const isTimelineResponse =
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
|
||||
if (isTimelineResponse && payload.direction === "tail") {
|
||||
resolveDelayedTail?.();
|
||||
if (tailReleased) ws.send(message);
|
||||
else delayedTailForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
if (isTimelineResponse && payload.direction === "after") {
|
||||
resolveDelayedCatchUp?.();
|
||||
if (catchUpReleased) ws.send(message);
|
||||
else delayedCatchUpForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
tailReleased = true;
|
||||
for (const forward of delayedTailForwards.splice(0)) forward();
|
||||
},
|
||||
releaseCatchUp() {
|
||||
catchUpReleased = true;
|
||||
for (const forward of delayedCatchUpForwards.splice(0)) forward();
|
||||
},
|
||||
waitForDelayedResponse: () => delayedTail,
|
||||
waitForDelayedCatchUp: () => delayedCatchUp,
|
||||
};
|
||||
}
|
||||
|
||||
async function delayAgentTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
direction: "before" | "tail",
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
@@ -325,7 +148,7 @@ async function delayAgentTimelineResponse(
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === direction
|
||||
payload.direction === "before"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { ArchiveTabAgent } from "./archive-tab";
|
||||
import { openWorkspaceWithAgents } from "./archive-tab";
|
||||
import { submitMessage } from "./composer";
|
||||
import type { SeedDaemonClient, SeededWorkspace } from "./seed-client";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { rememberTimelineViewport, userScrollsTimelineToHistoryStart } from "./timeline-pagination";
|
||||
|
||||
const IMAGE_PREVIEW_ERROR = "Unable to load image preview.";
|
||||
const SMALL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const TEN_MEBIBYTES = 10 * 1024 * 1024;
|
||||
|
||||
export interface AssistantImageFixture {
|
||||
alt: string;
|
||||
height: number;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export async function createSmallAssistantPng(
|
||||
workspace: SeededWorkspace,
|
||||
input: { alt: string; fileName: string },
|
||||
): Promise<AssistantImageFixture> {
|
||||
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
|
||||
return {
|
||||
alt: input.alt,
|
||||
height: 1,
|
||||
relativePath: input.fileName,
|
||||
size: SMALL_PNG.byteLength,
|
||||
width: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNearTenMegabyteAssistantPng(
|
||||
workspace: SeededWorkspace,
|
||||
input: { alt: string; fileName: string },
|
||||
): Promise<AssistantImageFixture> {
|
||||
const width = 2_048;
|
||||
const height = 1_280;
|
||||
const bytes = encodeDeterministicPng(width, height);
|
||||
if (bytes.byteLength < TEN_MEBIBYTES * 0.95 || bytes.byteLength > TEN_MEBIBYTES * 1.05) {
|
||||
throw new Error(`Expected a PNG near 10 MiB, encoded ${bytes.byteLength} bytes`);
|
||||
}
|
||||
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
|
||||
return {
|
||||
alt: input.alt,
|
||||
height,
|
||||
relativePath: input.fileName,
|
||||
size: bytes.byteLength,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSettledMockAgent(
|
||||
workspace: SeededWorkspace,
|
||||
title: string,
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
return {
|
||||
id: agent.id,
|
||||
title,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function emitSettledAssistantImage(
|
||||
client: SeedDaemonClient,
|
||||
agent: ArchiveTabAgent,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
`Emit settled assistant image Markdown: `,
|
||||
);
|
||||
const result = await client.waitForFinish(agent.id, 30_000);
|
||||
if (result.status !== "idle" || result.final?.lastError) {
|
||||
throw new Error(
|
||||
`Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendSettledTimelineTurns(
|
||||
client: SeedDaemonClient,
|
||||
agent: ArchiveTabAgent,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
`image-history-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
const result = await client.waitForFinish(agent.id, 30_000);
|
||||
if (result.status !== "idle" || result.final?.lastError) {
|
||||
throw new Error(
|
||||
`Assistant image history turn did not settle: ${result.final?.lastError ?? result.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectAssistantImageRendered(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
const rendered = page.getByRole("img", { name: image.alt }).first();
|
||||
await expect(rendered).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(async () =>
|
||||
rendered.evaluate((element) => {
|
||||
const imageElement =
|
||||
element instanceof HTMLImageElement ? element : element.querySelector("img");
|
||||
return imageElement?.complete
|
||||
? { height: imageElement.naturalHeight, width: imageElement.naturalWidth }
|
||||
: null;
|
||||
}),
|
||||
)
|
||||
.toEqual({ height: image.height, width: image.width });
|
||||
}
|
||||
|
||||
export async function switchAwayAndBackWithoutImageInstability(
|
||||
page: Page,
|
||||
input: {
|
||||
image: AssistantImageFixture;
|
||||
imageAgent: ArchiveTabAgent;
|
||||
otherAgent: ArchiveTabAgent;
|
||||
},
|
||||
): Promise<void> {
|
||||
await beginVisibleImageStabilityObservation(page, input.image.alt, input.imageAgent.id);
|
||||
await selectSettledAgentTab(page, input.otherAgent);
|
||||
await selectSettledAgentTab(page, input.imageAgent);
|
||||
await expectAssistantImageRendered(page, input.image);
|
||||
await expectNoVisibleImageInstability(page);
|
||||
}
|
||||
|
||||
export async function openExistingImageAgentTabs(
|
||||
page: Page,
|
||||
input: { imageAgent: ArchiveTabAgent; otherAgent: ArchiveTabAgent },
|
||||
): Promise<void> {
|
||||
await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]);
|
||||
}
|
||||
|
||||
export async function openAssistantImageTimeline(
|
||||
page: Page,
|
||||
agent: ArchiveTabAgent,
|
||||
): Promise<void> {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
|
||||
}
|
||||
|
||||
export async function expectAssistantImageNotMounted(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function userPagesUntilAssistantImageRenders(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
const rendered = page.getByRole("img", { name: image.alt });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if ((await rendered.count()) > 0) {
|
||||
await expectAssistantImageRendered(page, image);
|
||||
return;
|
||||
}
|
||||
const previous = await rememberTimelineViewport(page);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expect
|
||||
.poll(async () => (await rememberTimelineViewport(page)).scrollHeight)
|
||||
.toBeGreaterThan(previous.scrollHeight);
|
||||
}
|
||||
await expectAssistantImageRendered(page, image);
|
||||
}
|
||||
|
||||
export async function remountAndRecoverAssistantImageFromHistory(
|
||||
page: Page,
|
||||
image: AssistantImageFixture,
|
||||
): Promise<void> {
|
||||
await page.reload();
|
||||
await userPagesUntilAssistantImageRenders(page, image);
|
||||
}
|
||||
|
||||
export async function sendFollowUpAndExpectVisibleResponse(
|
||||
page: Page,
|
||||
input: { prompt: string; response: string },
|
||||
): Promise<void> {
|
||||
await submitMessage(page, input.prompt);
|
||||
await expect(page.getByText(input.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(input.response, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function selectSettledAgentTab(page: Page, agent: ArchiveTabAgent): Promise<void> {
|
||||
const tab = page.getByRole("button", { name: agent.title, exact: true });
|
||||
await tab.click();
|
||||
await expect(page).toHaveTitle(agent.title);
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator('[data-testid="agent-chat-scroll"]:visible').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function beginVisibleImageStabilityObservation(
|
||||
page: Page,
|
||||
alt: string,
|
||||
imageAgentId: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ accessibleName, errorText, tabTestId }) => {
|
||||
const record = {
|
||||
errorSeen: false,
|
||||
missingSeen: false,
|
||||
inspect: () => undefined,
|
||||
observer: null as MutationObserver | null,
|
||||
};
|
||||
const isVisible = (element: Element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
record.inspect = () => {
|
||||
record.errorSeen ||= Array.from(document.querySelectorAll("*")).some(
|
||||
(element) =>
|
||||
element.children.length === 0 &&
|
||||
element.textContent?.trim() === errorText &&
|
||||
isVisible(element),
|
||||
);
|
||||
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||
if (imageTab?.getAttribute("aria-selected") !== "true") return;
|
||||
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
|
||||
);
|
||||
record.missingSeen ||= !imageVisible;
|
||||
};
|
||||
record.observer = new MutationObserver(record.inspect);
|
||||
record.observer.observe(document.body, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
record.inspect();
|
||||
(
|
||||
window as unknown as {
|
||||
__paseoAssistantImageObservation?: typeof record;
|
||||
}
|
||||
).__paseoAssistantImageObservation = record;
|
||||
},
|
||||
{
|
||||
accessibleName: alt,
|
||||
errorText: IMAGE_PREVIEW_ERROR,
|
||||
tabTestId: `workspace-tab-agent_${imageAgentId}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function expectNoVisibleImageInstability(page: Page): Promise<void> {
|
||||
const observation = await page.evaluate(() => {
|
||||
const owner = window as unknown as {
|
||||
__paseoAssistantImageObservation?: {
|
||||
errorSeen: boolean;
|
||||
missingSeen: boolean;
|
||||
inspect(): void;
|
||||
observer: MutationObserver | null;
|
||||
};
|
||||
};
|
||||
const record = owner.__paseoAssistantImageObservation;
|
||||
if (!record) throw new Error("Assistant image stability observation was not started");
|
||||
record.inspect();
|
||||
record.observer?.disconnect();
|
||||
delete owner.__paseoAssistantImageObservation;
|
||||
return { errorSeen: record.errorSeen, missingSeen: record.missingSeen };
|
||||
});
|
||||
expect(observation).toEqual({ errorSeen: false, missingSeen: false });
|
||||
}
|
||||
|
||||
function encodeDeterministicPng(width: number, height: number): Buffer {
|
||||
const stride = width * 4 + 1;
|
||||
const scanlines = Buffer.allocUnsafe(stride * height);
|
||||
let state = 0x9e3779b9;
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const rowOffset = row * stride;
|
||||
scanlines[rowOffset] = 0;
|
||||
for (let offset = 1; offset < stride; offset += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
scanlines[rowOffset + offset] = state & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk("IHDR", header),
|
||||
pngChunk("IDAT", deflateSync(scanlines, { level: 0 })),
|
||||
pngChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBytes = Buffer.from(type, "ascii");
|
||||
const chunk = Buffer.allocUnsafe(12 + data.byteLength);
|
||||
chunk.writeUInt32BE(data.byteLength, 0);
|
||||
typeBytes.copy(chunk, 4);
|
||||
data.copy(chunk, 8);
|
||||
chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.byteLength);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function crc32(bytes: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
@@ -16,20 +16,6 @@ interface ClientRequest {
|
||||
type?: unknown;
|
||||
subscribe?: unknown;
|
||||
page?: { cursor?: unknown };
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.message ?? envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
@@ -52,111 +38,15 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripAssistantMessageId(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
|
||||
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
|
||||
};
|
||||
const event = (envelope.message?.payload ?? envelope.payload)?.event;
|
||||
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
|
||||
delete event.item.messageId;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function stripMessageSubmissionDisposition(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") {
|
||||
return message;
|
||||
}
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload) return message;
|
||||
delete payload.outOfBand;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
|
||||
if (!enabled || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload) return message;
|
||||
payload.epoch = `playwright-reset-${Date.now()}`;
|
||||
payload.reset = true;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function readAgentStreamEventType(message: ClientRequest | null): string | null {
|
||||
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const event = (message.payload as { event?: { type?: unknown } }).event;
|
||||
return typeof event?.type === "string" ? event.type : null;
|
||||
}
|
||||
|
||||
function readAgentStreamItemType(message: ClientRequest | null): string | null {
|
||||
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
|
||||
.event;
|
||||
return event?.type === "timeline" && typeof event.item?.type === "string"
|
||||
? event.item.type
|
||||
: null;
|
||||
}
|
||||
|
||||
function shouldSuppressServerMessage(input: {
|
||||
message: ClientRequest | null;
|
||||
messageTypes: ReadonlySet<string>;
|
||||
agentStreamEventTypes: ReadonlySet<string>;
|
||||
suppressAgentStream: boolean;
|
||||
}): boolean {
|
||||
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
|
||||
if (messageType && input.messageTypes.has(messageType)) return true;
|
||||
if (input.suppressAgentStream && messageType === "agent_stream") return true;
|
||||
const eventType = readAgentStreamEventType(input.message);
|
||||
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
|
||||
}
|
||||
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
let reconnectWithFreshClient = false;
|
||||
let suppressAgentStream = false;
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripSubmissionDisposition = false;
|
||||
let heldClientRequestType: string | null = null;
|
||||
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldClientRequest: (() => void) | null = null;
|
||||
let heldServerMessageType: string | null = null;
|
||||
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldServerMessage: (() => void) | null = null;
|
||||
const suppressedServerMessageTypes = new Set<string>();
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
let latestServer: WebSocketRoute | null = null;
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
unsubscribed: { agents: 0, workspaces: 0 },
|
||||
total: { agents: 0, workspaces: 0 },
|
||||
};
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
const serverMessageCounts = new Map<string, number>();
|
||||
const agentStreamItemCounts = new Map<string, number>();
|
||||
const serverMessageWaiters = new Set<() => void>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
@@ -166,20 +56,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
latestServer = server;
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
if (reconnectWithFreshClient && typeof message === "string") {
|
||||
const hello = readClientRequest(message);
|
||||
if (hello?.type === "hello") {
|
||||
const parsed = JSON.parse(message) as { clientId?: string };
|
||||
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
|
||||
reconnectWithFreshClient = false;
|
||||
server.send(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const request = readClientRequest(message);
|
||||
if (typeof request?.type === "string") {
|
||||
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
|
||||
@@ -190,12 +69,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
}
|
||||
if (request?.type === heldClientRequestType) {
|
||||
heldClientRequest = { server, message };
|
||||
resolveHeldClientRequest?.();
|
||||
resolveHeldClientRequest = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
@@ -205,55 +78,8 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
const serverMessage = readSessionMessage(message);
|
||||
let outboundMessage = stripAssistantMessageId(
|
||||
message,
|
||||
stripAssistantMessageIds,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = stripMessageSubmissionDisposition(
|
||||
outboundMessage,
|
||||
stripSubmissionDisposition,
|
||||
serverMessage?.type,
|
||||
);
|
||||
const shouldForceTimelineReset =
|
||||
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
|
||||
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
|
||||
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
|
||||
if (typeof serverMessage?.type === "string") {
|
||||
serverMessageCounts.set(
|
||||
serverMessage.type,
|
||||
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
|
||||
);
|
||||
for (const resolve of serverMessageWaiters) resolve();
|
||||
serverMessageWaiters.clear();
|
||||
}
|
||||
const agentStreamItemType = readAgentStreamItemType(serverMessage);
|
||||
if (agentStreamItemType) {
|
||||
agentStreamItemCounts.set(
|
||||
agentStreamItemType,
|
||||
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
|
||||
);
|
||||
for (const resolve of serverMessageWaiters) resolve();
|
||||
serverMessageWaiters.clear();
|
||||
}
|
||||
if (serverMessage?.type === heldServerMessageType) {
|
||||
heldServerMessage = { browser: ws, message: outboundMessage };
|
||||
resolveHeldServerMessage?.();
|
||||
resolveHeldServerMessage = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldSuppressServerMessage({
|
||||
message: serverMessage,
|
||||
messageTypes: suppressedServerMessageTypes,
|
||||
agentStreamEventTypes: suppressedAgentStreamEventTypes,
|
||||
suppressAgentStream,
|
||||
})
|
||||
)
|
||||
return;
|
||||
try {
|
||||
ws.send(outboundMessage);
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
@@ -274,125 +100,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
restoreFresh(): void {
|
||||
reconnectWithFreshClient = true;
|
||||
acceptingConnections = true;
|
||||
},
|
||||
holdNextClientRequest(type: string): void {
|
||||
heldClientRequestType = type;
|
||||
heldClientRequest = null;
|
||||
},
|
||||
waitForHeldClientRequest(): Promise<void> {
|
||||
if (heldClientRequest) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveHeldClientRequest = resolve;
|
||||
});
|
||||
},
|
||||
releaseHeldClientRequest(): void {
|
||||
if (!heldClientRequest) throw new Error("No held client request to release");
|
||||
heldClientRequest.server.send(heldClientRequest.message);
|
||||
heldClientRequest = null;
|
||||
heldClientRequestType = null;
|
||||
},
|
||||
holdNextServerMessage(type: string): void {
|
||||
heldServerMessageType = type;
|
||||
heldServerMessage = null;
|
||||
},
|
||||
waitForHeldServerMessage(): Promise<void> {
|
||||
if (heldServerMessage) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveHeldServerMessage = resolve;
|
||||
});
|
||||
},
|
||||
releaseHeldServerMessage(): void {
|
||||
if (!heldServerMessage) throw new Error("No held server message to release");
|
||||
heldServerMessage.browser.send(heldServerMessage.message);
|
||||
heldServerMessage = null;
|
||||
heldServerMessageType = null;
|
||||
},
|
||||
requestTimelineTail(agentId: string): void {
|
||||
if (!latestServer) throw new Error("No daemon WebSocket is connected");
|
||||
latestServer.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "fetch_agent_timeline_request",
|
||||
agentId,
|
||||
requestId: `playwright-timeline-${Date.now()}`,
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
getHeldTimelineLastItemType(): string | null {
|
||||
if (!heldServerMessage) throw new Error("No held server message to inspect");
|
||||
const response = readSessionMessage(heldServerMessage.message);
|
||||
const payload = response?.payload;
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const entries = (payload as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(entries)) return null;
|
||||
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
|
||||
return typeof last?.item?.type === "string" ? last.item.type : null;
|
||||
},
|
||||
truncateHeldTimelineAfterLast(itemType: string): void {
|
||||
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
|
||||
throw new Error("No held text server message to truncate");
|
||||
}
|
||||
const envelope = JSON.parse(heldServerMessage.message) as {
|
||||
message?: { payload?: Record<string, unknown> };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
const payload = envelope.message?.payload ?? envelope.payload;
|
||||
if (!payload) throw new Error("Held message has no payload");
|
||||
const entries = payload.entries;
|
||||
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
|
||||
const index = entries.findLastIndex(
|
||||
(entry) =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as { item?: { type?: unknown } }).item?.type === itemType,
|
||||
);
|
||||
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
|
||||
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
|
||||
const lastSeq = retained.at(-1)?.seqEnd;
|
||||
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
|
||||
payload.entries = retained;
|
||||
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
|
||||
payload.hasNewer = false;
|
||||
if (payload.window && typeof payload.window === "object") {
|
||||
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
|
||||
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
|
||||
}
|
||||
heldServerMessage.message = JSON.stringify(envelope);
|
||||
},
|
||||
setServerMessageSuppressed(type: string, suppressed: boolean): void {
|
||||
if (suppressed) {
|
||||
suppressedServerMessageTypes.add(type);
|
||||
} else {
|
||||
suppressedServerMessageTypes.delete(type);
|
||||
}
|
||||
},
|
||||
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
|
||||
if (suppressed) {
|
||||
suppressedAgentStreamEventTypes.add(type);
|
||||
} else {
|
||||
suppressedAgentStreamEventTypes.delete(type);
|
||||
}
|
||||
},
|
||||
setAssistantMessageIdsStripped(stripped: boolean): void {
|
||||
stripAssistantMessageIds = stripped;
|
||||
},
|
||||
setMessageSubmissionDispositionStripped(stripped: boolean): void {
|
||||
stripSubmissionDisposition = stripped;
|
||||
},
|
||||
setAgentStreamSuppressed(suppressed: boolean): void {
|
||||
suppressAgentStream = suppressed;
|
||||
},
|
||||
forceNextTimelineEpochReset(): void {
|
||||
forceTimelineEpochReset = true;
|
||||
},
|
||||
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
|
||||
return {
|
||||
subscribed: { ...directoryStarts.subscribed },
|
||||
@@ -403,18 +110,5 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 0;
|
||||
},
|
||||
getAgentStreamItemCount(type: string): number {
|
||||
return agentStreamItemCounts.get(type) ?? 0;
|
||||
},
|
||||
async waitForServerMessage(type: string, count = 1): Promise<void> {
|
||||
while ((serverMessageCounts.get(type) ?? 0) < count) {
|
||||
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
|
||||
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
|
||||
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import {
|
||||
delayAgentBootstrapTailResponse,
|
||||
delayAgentOlderTimelineResponse,
|
||||
holdDaemonHydration,
|
||||
holdAgentOlderTimelinePages,
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
}
|
||||
|
||||
interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
initialTailOldestPrompt: string;
|
||||
oldestPrompt: string;
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
interface TimelineViewportSnapshot {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface TimelinePromptPositionSnapshot {
|
||||
prompt: string;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface OlderHistoryLoadingOperation {
|
||||
animationStartTime: number | null;
|
||||
marker: string;
|
||||
}
|
||||
|
||||
interface OlderHistoryPages {
|
||||
expectRequestedPages(count: number): Promise<void>;
|
||||
expectSettledWithRequestedPages(count: number): Promise<void>;
|
||||
releasePage(pageNumber: number): void;
|
||||
}
|
||||
|
||||
function promptForTurn(index: number): string {
|
||||
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
|
||||
@@ -67,41 +36,11 @@ export async function seedLongMockAgentTimeline(
|
||||
|
||||
return {
|
||||
...agent,
|
||||
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function rememberTimelinePromptPosition(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
): Promise<TimelinePromptPositionSnapshot> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const item = timeline.getByText(prompt, { exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
const box = await item.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Expected a rendered timeline item for ${prompt}`);
|
||||
}
|
||||
return { prompt, top: box.y };
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptPositionPreserved(
|
||||
page: Page,
|
||||
before: TimelinePromptPositionSnapshot,
|
||||
): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const item = timeline.getByText(before.prompt, { exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await item.boundingBox();
|
||||
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
@@ -111,17 +50,15 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 20_000 });
|
||||
await page.setViewportSize({ width: 1280, height: 8_000 });
|
||||
}
|
||||
|
||||
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
||||
@@ -138,33 +75,6 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<voi
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function reloadAgentTimelineFromPersistedReplica(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((agentId) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: unknown[];
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
|
||||
return timeline?.items?.length === 50;
|
||||
}, agent.agentId),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
@@ -180,106 +90,6 @@ export async function holdNextOlderTimelinePage(
|
||||
};
|
||||
}
|
||||
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
releasePage(pageNumber) {
|
||||
gate.releasePage(pageNumber);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
|
||||
return readTimelineViewport(page);
|
||||
}
|
||||
|
||||
export async function expectTimelineViewportAnchoredAfterPrepend(
|
||||
page: Page,
|
||||
before: TimelineViewportSnapshot,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
|
||||
.toBeGreaterThan(before.scrollHeight);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const after = await readTimelineViewport(page);
|
||||
const contentGrowth = after.scrollHeight - before.scrollHeight;
|
||||
const scrollAdjustment = after.scrollTop - before.scrollTop;
|
||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function rememberOlderHistoryLoadingOperation(
|
||||
page: Page,
|
||||
): Promise<OlderHistoryLoadingOperation> {
|
||||
const slot = page.getByTestId("load-older-history-spinner");
|
||||
await expect(slot).toBeVisible();
|
||||
const marker = `older-history-loading-${Date.now()}`;
|
||||
await slot.evaluate((element, operationMarker) => {
|
||||
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
|
||||
const animated = candidates.find(
|
||||
(candidate) => getComputedStyle(candidate).animationName !== "none",
|
||||
);
|
||||
if (!(animated instanceof HTMLElement)) {
|
||||
throw new Error("Expected the older-history loader to contain an animated element");
|
||||
}
|
||||
animated.dataset.olderHistoryLoadingOperation = operationMarker;
|
||||
}, marker);
|
||||
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
})
|
||||
.not.toBeNull();
|
||||
const animationStartTime = await animated.evaluate((element) => {
|
||||
const startTime = element.getAnimations()[0]?.startTime;
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
});
|
||||
return { animationStartTime, marker };
|
||||
}
|
||||
|
||||
export async function expectSameOlderHistoryLoadingOperation(
|
||||
page: Page,
|
||||
operation: OlderHistoryLoadingOperation,
|
||||
): Promise<void> {
|
||||
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
|
||||
await expect(animated).toBeVisible();
|
||||
const animationStartTime = await animated.evaluate((element) => {
|
||||
const startTime = element.getAnimations()[0]?.startTime;
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
});
|
||||
expect(animationStartTime).toBe(operation.animationStartTime);
|
||||
}
|
||||
|
||||
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function holdBootstrapTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<BootstrapTimelineGate> {
|
||||
return delayAgentBootstrapTailResponse(page, agent.agentId);
|
||||
}
|
||||
|
||||
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
return LIVE_BEFORE_HYDRATION_PROMPT;
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
@@ -299,95 +109,31 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
|
||||
});
|
||||
}
|
||||
|
||||
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
||||
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
for (let step = 0; step < 60; step += 1) {
|
||||
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
break;
|
||||
}
|
||||
await page.mouse.wheel(0, -1_000);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
}),
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.evaluate((element) => {
|
||||
const previousHeight = await scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
return element.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollTimelineUntilOlderHistoryIsReachable(
|
||||
page: Page,
|
||||
oldestPrompt: string,
|
||||
): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const prompt = scroll.getByText(oldestPrompt, { exact: true });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if ((await prompt.count()) > 0) {
|
||||
await expect(prompt).toBeVisible();
|
||||
return;
|
||||
}
|
||||
const previousHeight = await readTimelineViewport(page);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
|
||||
.toBeGreaterThan(previousHeight.scrollHeight);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
}
|
||||
await expect(prompt).toBeVisible();
|
||||
}
|
||||
|
||||
async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
return scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
reject(new Error("Agent chat scroll element is not an HTMLElement"));
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
let stableFrames = 0;
|
||||
let previous = `${element.scrollTop}:${element.scrollHeight}`;
|
||||
const sample = () => {
|
||||
const current = `${element.scrollTop}:${element.scrollHeight}`;
|
||||
stableFrames = current === previous ? stableFrames + 1 : 0;
|
||||
previous = current;
|
||||
if (stableFrames >= 4) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (performance.now() - startedAt > 5_000) {
|
||||
reject(new Error("Timeline geometry did not settle"));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight;
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(previousHeight);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { submitMessage } from "./helpers/composer";
|
||||
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
|
||||
test.describe("Codex out-of-band commands", () => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test("settles the submitted row when a goal command completes without a turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
|
||||
await submitMessage(page, "/goal clear");
|
||||
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
|
||||
|
||||
await expect(command).toBeVisible();
|
||||
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("settles the submitted row when an older daemon omits submission disposition", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setMessageSubmissionDispositionStripped(true);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
|
||||
await submitMessage(page, "/goal clear");
|
||||
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
|
||||
|
||||
await expect(command).toBeVisible();
|
||||
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { scrollChatAwayFromBottom } from "./helpers/agent-bottom-anchor";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
@@ -25,152 +23,7 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
|
||||
await expect(userMessage(page, text)).toBeVisible();
|
||||
}
|
||||
|
||||
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
for (const host of cache.hosts ?? []) {
|
||||
for (const item of host.timeline?.items ?? []) {
|
||||
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, prompt),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.evaluate((messageText) => {
|
||||
const key = "@paseo:replica-cache";
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) throw new Error("Replica cache was not persisted");
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
const cachedMessage = cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.find((item) => item.kind === "user_message" && item.text === messageText);
|
||||
if (!cachedMessage) throw new Error("Cached user message was not found");
|
||||
delete cachedMessage.messageId;
|
||||
localStorage.setItem(key, JSON.stringify(cache));
|
||||
}, prompt);
|
||||
}
|
||||
|
||||
async function waitForCurrentSubmissionExcludedFromCache(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return !cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
typeof item.clientMessageId === "string" &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((messageText) => {
|
||||
const raw = localStorage.getItem("@paseo:replica-cache");
|
||||
if (!raw) return false;
|
||||
const cache = JSON.parse(raw) as {
|
||||
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
|
||||
};
|
||||
return cache.hosts
|
||||
?.flatMap((host) => host.timeline?.items ?? [])
|
||||
.some(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.text === messageText &&
|
||||
item.messageId === undefined,
|
||||
);
|
||||
}, prompt),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise<void> {
|
||||
const prompt = "Keep this cached submission pending.";
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "rewind-current-cache-e2e-",
|
||||
title: "Current cache submission e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
gate.holdNextClientRequest("send_agent_message_request");
|
||||
await submitMessage(page, prompt);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await waitForCurrentSubmissionExcludedFromCache(page, prompt);
|
||||
await gate.drop();
|
||||
await page.reload();
|
||||
|
||||
await expect(userMessage(page, prompt)).toHaveCount(0);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("does not restore a local-only submission from the display cache", async ({ page }) => {
|
||||
await expectPendingSubmissionNotRestoredAfterReload(page);
|
||||
});
|
||||
|
||||
test("does not invent rewind identity for an ID-less cached message", async ({ page }) => {
|
||||
const prompt = "Restore this rewind identity from the legacy cache.";
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "rewind-cache-upgrade-e2e-",
|
||||
title: "Rewind cache upgrade e2e",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
let heldTimelineRequest = false;
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectUserMessageVisible(page, prompt);
|
||||
await rewriteCachedMessageAsLegacyRow(page, prompt);
|
||||
gate.holdNextClientRequest("fetch_agent_timeline_request");
|
||||
await page.reload();
|
||||
await gate.waitForHeldClientRequest();
|
||||
heldTimelineRequest = true;
|
||||
|
||||
const restoredMessage = userMessage(page, prompt);
|
||||
await expect(restoredMessage).toBeVisible();
|
||||
await restoredMessage.hover();
|
||||
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
|
||||
await waitForCachedMessageWithoutProviderId(page, prompt);
|
||||
} finally {
|
||||
if (heldTimelineRequest) gate.releaseHeldClientRequest();
|
||||
gate.restore();
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||
@@ -192,10 +45,6 @@ test.describe("Rewind sheet", () => {
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await userMessage(page, firstPrompt).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
|
||||
Reference in New Issue
Block a user