* fix(app): stop completed turns from appearing stuck Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow. * fix(app): guard pending message identity * test(app): measure submission layout independent of scroll * fix(app): keep submitted messages consistent through reconnects Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization. * fix(app): close canonical submission races * test(app): cover canonical submission races in browser * fix(app): keep replacement submissions authoritative Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle. * fix(app): keep submitted messages stable across sync Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows. * fix(app): settle submissions in either acknowledgement order Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity. * fix(app): keep agent visible during history handoff Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied. * fix(app): settle attachment-only submissions Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row. * fix(agent): settle out-of-band message submissions Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer. * fix(app): settle out-of-band submissions compatibly * fix(app): preserve canonical prompt order * test(app): keep workspace status check timing-independent The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
686 lines
27 KiB
TypeScript
686 lines
27 KiB
TypeScript
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);
|
|
});
|
|
});
|