Files
paseo/packages/app/e2e/browser/agent-message-submission.spec.ts
Mohamed Boudra eb1a8a2ec7 fix(markdown): one renderer on react-native-enriched-markdown for iOS, Android and web (#6973)
* fix(app): group native assistant prose into selectable runs

* fix(app): render selectable native prose with enriched markdown

* docs: explain native prose selection boundaries and tradeoffs

* fix(app): preserve markdown semantics and render limits in prose runs

* docs: record native prose compatibility fixes and heading limits

* fix(app): allow additive task lists and admonitions in native prose

* fix(app): preserve inline image formatting and reference scope

* fix(app): preserve markdown source and file actions in native prose

Prepare original source blocks with source-position edits from the assistant parser. Keep reference scope and live/history projection consistent, and retain link text when dispatching workspace file actions.

* fix(app): preserve native prose entities and links while bounding preparation cost

* fix(app): preserve provisional syntax at cached reveal boundaries

* fix(app): bound prose append work and preserve markdown edge cases

* fix(app): preserve home paths and normalized prose links

* fix(app): preserve parser-owned links and streaming tilde tails

* fix(app): keep growing markdown on the streaming block renderer

* fix(app): preserve bracketed IPv6 links in native prose

* fix(app): skip prose classification for blocks over the render cap

* fix(app): share selectable native prose across Markdown surfaces

* fix(app): preserve Markdown containers before grouping prose

* revert(app): remove shared prose-run renderer changes

Reverts ab4737bb2 and ac47e1063 before replacing the Markdown engine across all platforms.

* feat(markdown): use enriched renderer across all platforms

* test: verify library image controls and isolate website config

* fix(markdown): preserve file links and image revisions

* fix(markdown): restore diagrams and preserve link origin

* fix(markdown): retain diagram identity and reference scope

* fix(markdown): avoid empty web history measurements

* fix(markdown): preserve authored empty link titles

* test(markdown): exercise find across whole-message history

* fix(markdown): preserve encoded image paths after rebase

Keep the shared renderer image normalization and file-read regression coverage that originally lived in the main integration merge.

* fix(markdown): derive table and task list colours from the theme

* fix(markdown): restore selection highlight on web

* fix(markdown): restore file link hover tooltips on web

* fix(markdown): normalize inline HTML before rendering

* fix(markdown): create renderer types when applying package patch

The published nightly omits both MarkdownRenderers type files. Represent them as new files so patch-package succeeds on a fresh npm install, matching the already patched development package.

* fix(markdown): keep the renderer tree stable while images resolve

* fix(markdown): size inline images to the line and collapse failed loads

* fix(markdown): normalize only parsed HTML nodes

* fix(markdown): keep the first image reference definition

* fix(markdown): preserve complete assistant messages on native

* fix(markdown): classify web links explicitly and complete renderer types

* fix(markdown): preserve spacing between HTML elements

* fix(markdown): normalize HTML following code elements

* fix(markdown): preserve literal image fallback labels

* fix(markdown): retry transient image decode failures

* docs(markdown): record native selection and image limitations

* fix(markdown): preserve first definitions across Mermaid segments

* fix(markdown): reserve pending images and harden web rendering

* test(markdown): cover state retention during image arrival

* fix(markdown): preserve reader position while responses grow

Keep the same message component boundary when a live row becomes retained history, preserving Mermaid runtimes and selection nodes. Observe paced child text commits at the web viewport so sticky-bottom follows before the next frame.

* test(markdown): require growth while holding a streaming paragraph

* test(markdown): isolate image arrival from reload positioning

Mounted row totals change as history virtualizes, so observe the new response and tool directly. Hold the real PNG response after reload until the reading position settles, then verify its reserved height and viewport instead of attributing initial bottom-follow positioning to image decoding.

* fix(markdown): preserve adjacency around inline HTML

* fix(markdown): keep summary block boundaries outside bold text

* perf(markdown): scan HTML block boundaries incrementally

* test(markdown): gate delivery while establishing a held reader

* test(markdown): name shared renderer setup and file actions

* test(markdown): assert linear HTML boundary work without timing

* test(markdown): check large HTML table output without observing built-ins

* fix(markdown): restore accentBright link colour

* fix(markdown): require letters and known extensions in bare path recognition

* fix(markdown): match main's blockquote, bullet and h6 colours

* fix(android): keep inverted chat still during text selection

React Native decomposes Android list inversion into a 180-degree rotation with positive scaleY, so the existing visibility-scroll guard never recognized the inverted viewport. Inspect the composed matrix and reproduce the actual list transform through the native view manager in regression tests.

* fix(markdown): linked inline code keeps inline code size

* fix(markdown): reserve a placeholder box with an image glyph while images load

* fix(markdown): bare prose paths need an extension, line suffix or depth

Slash-separated English pairs were recognized as file links in prose. Require stronger path intent there while preserving the existing inline-code recognition rule.

* fix(markdown): horizontal blocks own their drags on compact layouts

* perf(markdown): keep native streaming updates in the unfinished tail

Native markdown reparses each updated view in full. Keep parser-closed blocks in a memoized head during paced reveal, then reunite completed prose for cross-paragraph selection. Preserve trailing spacing and source indentation at the split.

* perf(markdown): reuse Android measurements across view mounts

* test(markdown): bare Fabel/model and daemon/client stay plain text when copied

a502cfd0d stopped linking bare two-segment prose paths; the clipboard
expectation still carried the links from the earlier loose pattern and
failed on CI (playwright shard 1/4 on 60b4e2d05). main expects the plain
text, so restore that.

* perf(app): limit native chat mount batches

Whole-message Markdown rows measure synchronously during Fabric layout. Render fewer history rows per batch and retain a smaller offscreen window so opening a chat does not measure twelve history rows at once.

* fix(app): keep populated markdown in sync with native layout

Fabric can collapse streaming blocks before asynchronous rendering replaces the old text, producing a blank frame. Commit populated-view updates synchronously while retaining worker preparation for first mounts to avoid moving large history parsing onto the UI thread.

* perf(app): preserve unchanged sidebar workspace projections

Reopening a retained sidebar rebuilt its entry collection and invalidated every project row despite unchanged workspace data. Preserve collection identity as well as row identity, while keeping membership and ordering changes observable.

* Revert "perf(app): preserve unchanged sidebar workspace projections"

This reverts commit a3919faed4.
2026-10-16 21:03:37 +07:00

1404 lines
56 KiB
TypeScript

import type { Locator, Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import {
awaitToolCall,
expectAgentIdle,
expectAgentReadyToInterrupt,
expectAgentSurfacesIdle,
expectInlineWorkingIndicator,
expectRunningAgentChrome,
expectVisibleAgentSurfacesIdle,
} from "../support/helpers/agent-stream";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
attachImageFromMenu,
expectComposerDraft,
expectComposerEditable,
expectAttachmentPill,
expectComposerVisible,
cancelAgent,
composerLocator,
fillComposerDraft,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
createAgentTabFromMenu,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
import {
recordPanelToasts,
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
} from "../support/helpers/workspace-ui";
import {
openSessions,
clickSessionRow,
expectWorkspaceTabVisible,
} from "../support/helpers/archive-tab";
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination";
import {
appendSettledTimelineTurns,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageRendered,
} from "../support/helpers/assistant-images";
const IMAGE = {
name: "message-submission.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
interface SubmissionScenario {
existingPrompt: string;
}
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) => {
await page.setViewportSize({ width: 1280, height: 960 });
const workspace = await seedWorkspace({
repoPrefix: `message-submission-layout-${testInfo.workerIndex}-`,
});
try {
const agent = await createSettledMockAgent(workspace, "Message submission layout regression");
await appendSettledTimelineTurns(workspace.client, agent, 80);
const image = await createSmallAssistantPng(workspace, {
alt: "Existing timeline image",
fileName: "existing-timeline.png",
});
await emitSettledAssistantImage(workspace.client, agent, image);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
await expectComposerVisible(page);
await expectAgentIdle(page);
const oldestPrompt = "image-history-turn-0: emit 1 coalesced agent stream updates";
await scrollTimelineUntilOlderHistoryIsReachable(page, oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectAssistantImageRendered(page, image);
await provide({
existingPrompt:
"Emit settled assistant image Markdown: ![Existing timeline image](existing-timeline.png)",
});
} finally {
await workspace.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 beginTimelineRowStabilityCheck(
page: Page,
timelineRow: Locator,
): Promise<() => Promise<void>> {
await expect(timelineRow).toBeVisible();
await timelineRow.evaluate((initialRow) => {
const state = {
active: true,
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
};
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionTimelineRowStability = state;
const checkFrame = () => {
if (!state.active) return;
if (!initialRow.isConnected) {
state.sawMissing = true;
requestAnimationFrame(checkFrame);
return;
}
const top = initialRow.getBoundingClientRect().top;
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
state.lastTop = top;
requestAnimationFrame(checkFrame);
};
requestAnimationFrame(checkFrame);
});
return async () => {
const result = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionTimelineRowStability as
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Timeline-row stability check was not started");
state.active = false;
delete windowState.__messageSubmissionTimelineRowStability;
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
});
expect(result.sawMissing).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
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; preservesActiveTurn?: boolean },
): Promise<void> {
await expect(page.getByRole("alert").filter({ hasText: input.errorMessage })).toBeVisible({
timeout: 30_000,
});
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
if (input.preservesActiveTurn) {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
return;
}
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
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 attempts = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(attempts).toHaveCount(2);
const userMessage = attempts.last();
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 expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
}
async function configureSteerInSettings(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
await expect(page).toHaveURL(/\/settings\/general$/);
await selectSteerInSettings(page);
}
async function selectSteerInSettings(page: Page): Promise<void> {
await selectSendBehaviorInSettings(page, "Steer", "steer");
}
/** Steer is the default, so the interrupt path only gets exercised by opting back into it. */
async function configureInterruptInSettings(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
await expect(page).toHaveURL(/\/settings\/general$/);
await selectSendBehaviorInSettings(page, "Interrupt", "interrupt");
}
async function selectSendBehaviorInSettings(
page: Page,
behaviorLabel: string,
stored: string,
): Promise<void> {
await page.getByRole("button", { name: /^Default send: / }).click();
await page.getByRole("menuitem", { name: behaviorLabel, exact: true }).click();
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
return raw ? (JSON.parse(raw) as { sendBehavior?: unknown }).sendBehavior : null;
})
.toBe(stored);
}
async function replaySteeredSleepTurnInBrowser(
page: Page,
testInfo: { workerIndex: number },
shape: "claude" | "codex",
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
gate.holdNextShellToolCall("completed");
await gotoAppShell(page);
await openSettings(page);
await selectSteerInSettings(page);
const agent = await startRunningMockAgent(page, {
prefix: `steer-replay-${shape}-${testInfo.workerIndex}-`,
model: "ten-second-stream",
prompt: `Replay a ${shape}-shaped foreground shell tool call while the user steers this turn.`,
});
try {
await expect(page.getByTestId("tool-call-badge").last()).toBeVisible({ timeout: 30_000 });
await expectComposerVisible(page);
await submitMessage(page, "hello");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(0);
await expectInFlightForkAvailable(page);
await gate.waitForHeldServerMessage();
gate.releaseHeldServerMessage();
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Fork chat" }).last()).toBeVisible();
} finally {
gate.restore();
await agent.cleanup();
}
}
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 visitEvictionWorkspaces(
page: Page,
agents: readonly { agentId: string }[],
): Promise<void> {
for (const [index, agent] of agents.entries()) {
await openSessions(page);
await clickSessionRow(page, `Workspace eviction ${index + 1}.`);
await expectWorkspaceTabVisible(page, agent.agentId);
await expectComposerVisible(page);
}
}
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const subscriptions = observeTimelineSubscriptions(page);
const gate = await installDaemonWebSocketGate(page);
const target = await seedMockAgentWorkspace({
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
title: "Hidden streaming submission",
model: "ten-second-stream",
});
const evictionAgents = await Promise.all(
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}.`,
}),
),
);
const prompt = "Keep this hidden image prompt before its streaming output.";
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
const openAgentIds = [target.agentId, ...evictionAgents.map((agent) => agent.agentId)];
try {
await openAgentRoute(page, target);
await expectComposerVisible(page);
// Open every chat before holding output. Adding chats replaces the timeline
// subscription and retires the IDs stamped on already-buffered frames.
await visitEvictionWorkspaces(page, evictionAgents);
await subscriptions.waitForSubscribedAgents(openAgentIds);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await expectWorkspaceTabVisible(page, target.agentId);
await expectComposerVisible(page);
const subscriptionRequests = gate.getClientRequestCount(
"agent.timeline.set_subscription.request",
);
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.setAgentStreamItemSuppressed("user_message", true);
gate.holdNextAgentStreamEvent("turn_started");
const promptRow = await submitMessageWithImage(page, prompt);
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
await gate.waitForHeldAgentStreamEvent("turn_started");
// Finish production before navigation so passing cannot depend on late
// chunks arriving after the final workspace switch.
await target.client.waitForFinish(target.agentId, 30_000);
// Navigate inside the app: a document reload discards the retained deck and
// adds startup history fetches, so it cannot prove eviction/resume behavior.
await visitEvictionWorkspaces(page, evictionAgents);
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents(openAgentIds);
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
subscriptionRequests,
);
gate.releaseHeldAgentStreamEvent("turn_started");
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents(openAgentIds);
const responseStart = page.getByText("Cycle 1", { exact: true });
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(responseStart).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, responseStart);
await expectRenderedBefore(promptRow, response);
// Open chats stay subscribed when their workspace view is evicted. Returning
// uses that live timeline without another resume check or startup tail fetch.
expect(rememberTimelineRequestCounts(gate, target.agentId)).toEqual(requestsBeforeReturn);
} finally {
gate.setAgentStreamItemSuppressed("user_message", false);
gate.restore();
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.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");
await gate.waitForAgentStreamEvent("turn_started");
gate.releaseHeldServerMessage();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await gate.drop();
await expectInlineWorkingIndicator(page);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setServerMessageSuppressed("agent_status", false);
gate.setServerMessageSuppressed("agent_update", false);
gate.restoreFresh();
await expectVisibleAgentSurfacesIdle(page);
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 expectDaemonHandledSubmissionSurvivesReload(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const agent = await seedMockAgentWorkspace({
repoPrefix: `daemon-handled-submission-${testInfo.workerIndex}-`,
title: "Daemon-handled submission",
});
const prompt = "/mock handled-command";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await submitMessage(page, prompt);
const command = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(command).toHaveAttribute("aria-busy", "false");
await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await page.reload();
await expectComposerVisible(page);
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false");
await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
await agent.cleanup();
}
}
async function expectOldHostSubmissionBehavior(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
gate.setCanonicalSubmittedPromptsStripped(true);
const agent = await seedMockAgentWorkspace({
repoPrefix: `old-host-submission-${testInfo.workerIndex}-`,
title: "Old host submission",
});
const prompt = "/mock handled-command";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
const nextFrame = await composer.evaluate(
(_, submittedPrompt) =>
new Promise<{ rowPresent: boolean; ariaBusy: string | null; workingPresent: boolean }>(
(resolve) => {
requestAnimationFrame(() => {
const row = Array.from(
document.querySelectorAll('[data-testid="user-message"]'),
).find((candidate) => candidate.textContent?.includes(submittedPrompt));
resolve({
rowPresent: Boolean(row),
ariaBusy: row?.getAttribute("aria-busy") ?? null,
workingPresent: Boolean(
document.querySelector('[data-testid="turn-working-indicator"]'),
),
});
});
},
),
prompt,
);
expect(nextFrame).toEqual({ rowPresent: true, ariaBusy: "false", workingPresent: false });
await gate.waitForHeldClientRequest();
gate.releaseHeldClientRequest();
const command = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(command).toHaveAttribute("aria-busy", "false");
await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} 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 createAgentTabFromMenu(page);
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("settles an immediately interrupted first prompt", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: `submission-immediate-interrupt-${testInfo.workerIndex}-`,
});
const prompt = "Withhold synthetic user message until interrupted.";
try {
await openWorkspaceDraft(page, workspace.workspaceId);
await selectModel(page, "one-minute-stream");
await submitMessage(page, prompt);
await expectAgentReadyToInterrupt(page);
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expectComposerEditable(page);
await page.reload();
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
await expectVisibleAgentSurfacesIdle(page);
await expectComposerEditable(page);
} finally {
await workspace.cleanup();
}
});
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: `submission-echo-before-accept-${testInfo.workerIndex}-`,
});
const prompt = "Emit synthetic user message before accepting turn.";
try {
await openWorkspaceDraft(page, workspace.workspaceId);
await selectModel(page, "one-minute-stream");
await submitMessage(page, prompt);
await expectAgentReadyToInterrupt(page);
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
await expectVisibleAgentSurfacesIdle(page);
for (const [index, nextPrompt] of [
"emit 1 coalesced agent stream updates for the second turn.",
"emit 1 coalesced agent stream updates for the third turn.",
].entries()) {
await submitMessage(page, nextPrompt);
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
await expectVisibleAgentSurfacesIdle(page);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
}
await fillComposerDraft(page, "Keep this unsent draft.");
await composerLocator(page).blur();
await expect(page.getByTestId("user-message")).toHaveCount(3);
await expectComposerEditable(page);
} finally {
await workspace.cleanup();
}
});
test("enriches a visible prompt when the provider identity arrives later", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: `submission-late-provider-identity-${testInfo.workerIndex}-`,
});
const prompt = "Delay synthetic user message by 2000ms.";
try {
await openWorkspaceDraft(page, workspace.workspaceId);
await selectModel(page, "one-minute-stream");
await submitMessage(page, prompt);
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await submittedPrompt.hover();
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
timeout: 5_000,
});
await expect(submittedPrompt).toHaveCount(1);
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await expectVisibleAgentSurfacesIdle(page);
} finally {
await workspace.cleanup();
}
});
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: `submission-echo-past-tail-${testInfo.workerIndex}-`,
});
const prompt = "Emit 205 assistant messages before synthetic user message.";
try {
await openWorkspaceDraft(page, workspace.workspaceId);
await selectModel(page, "one-minute-stream");
await submitMessage(page, prompt);
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
await expect(submittedPrompt).toHaveCount(0);
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally {
await workspace.cleanup();
}
});
test("keeps one Stop action while submitting into a running agent", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: "submission-running-action-",
title: "Running submission action",
model: "one-minute-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, "Keep running while the next prompt is submitted.");
await expectAgentReadyToInterrupt(page);
gate.holdNextClientRequest("send_agent_message_request");
await fillComposerDraft(page, "Replace the running turn without duplicating its action.");
await expect(page.getByRole("button", { name: "Send and steer", exact: true })).toHaveCount(
1,
);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount(
0,
);
await composerLocator(page).press("Enter");
await gate.waitForHeldClientRequest();
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount(
0,
);
gate.releaseHeldClientRequest();
} finally {
gate.restore();
await agent.cleanup();
}
});
test("makes the next queued turn interruptible after cancellation settles", async ({ page }) => {
const title = "Queued turn after interrupt";
const secondPrompt = "Run the queued turn after interruption.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "submission-queued-after-interrupt-",
title,
model: "one-minute-stream",
});
try {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, "Keep running until the queued turn is ready.");
await expectRunningAgentChrome(page, title);
await queueMessage(page, secondPrompt);
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible();
gate.holdNextServerMessage("cancel_agent_response");
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await gate.waitForHeldServerMessage();
await expect(page.getByTestId("user-message").filter({ hasText: secondPrompt })).toHaveCount(
1,
);
await expectRunningAgentChrome(page, title);
await expectAgentReadyToInterrupt(page);
gate.holdNextClientRequest("cancel_agent_request");
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await gate.waitForHeldClientRequest();
await expect(
page.getByRole("button", { name: "Canceling agent", exact: true }),
).toBeVisible();
gate.releaseHeldServerMessage();
await expect(
page.getByRole("button", { name: "Canceling agent", exact: true }),
).toBeVisible();
gate.releaseHeldClientRequest();
await expectVisibleAgentSurfacesIdle(page);
} finally {
await agent.cleanup();
}
});
test("shows every agent surface idle after interrupting a submitted turn", async ({ page }) => {
const title = "Interrupted submission";
const agent = await seedMockAgentWorkspace({
repoPrefix: "submission-interrupt-",
title,
model: "one-minute-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, "Interrupt this submitted turn.");
await expectRunningAgentChrome(page, title);
await cancelAgent(page);
await expectAgentSurfacesIdle(page, title);
} finally {
await agent.cleanup();
}
});
test("reconciles every agent surface when interrupt completion is missed", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const title = "Missed interrupt completion";
const agent = await seedMockAgentWorkspace({
repoPrefix: "submission-missed-interrupt-",
title,
model: "one-minute-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, "Interrupt without delivering the terminal event.");
await expectRunningAgentChrome(page, title);
gate.setAgentStreamEventSuppressed("turn_canceled", true);
await cancelAgent(page);
await gate.waitForAgentStreamEvent("turn_canceled");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectAgentSurfacesIdle(page, title);
} finally {
gate.setAgentStreamEventSuppressed("turn_canceled", false);
await agent.cleanup();
}
});
test("keeps layout stable when submitting to an agent with existing history", async ({
page,
submissionScenario,
}) => {
const prompt = "Hold this submission.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await expect(composer).toHaveValue(prompt);
await expect(
page.getByTestId("user-message").filter({ hasText: submissionScenario.existingPrompt }),
).toBeVisible();
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
page,
page.getByTestId("assistant-message").last(),
);
await composer.press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
await expect(userMessage).toBeVisible();
await expect(
page.getByTestId("assistant-message").filter({
hasText: "I'll take a look at the scroll anchor behavior you described.",
}),
).toBeVisible();
await awaitToolCall(page, "Read");
await finishTimelineRowStabilityCheck();
});
test("keeps the submitted row stable through draft create handoff", async ({
page,
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
test("retains a rejected submission before its error and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
await retryRestoredSubmission(page, prompt);
});
test("restores an ambiguous Steer failure without retrying or interrupting", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const prompt = "Restore this ambiguous Steer submission.";
const agent = await startRunningMockAgent(page, {
prefix: `steer-ambiguous-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while a Steer request fails.",
featureValues: { mockSteerAmbiguousFailures: 1 },
});
try {
await configureSteerInSettings(page);
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const sendsBefore = gate.getClientRequestCount("send_agent_message_request");
const cancelsBefore = gate.getClientRequestCount("cancel_agent_request");
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, {
prompt,
errorMessage: "Requested mock steer transport failure",
preservesActiveTurn: true,
});
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
expect(gate.getClientRequests("send_agent_message_request").at(-1)).toMatchObject({
text: prompt,
activeTurnBehavior: "steer",
});
} finally {
gate.restore();
await agent.cleanup();
}
});
test("keeps an optimistic Steer prompt inside the active turn before acknowledgement", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await startRunningMockAgent(page, {
prefix: `steer-optimistic-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user steers it.",
});
try {
await configureSteerInSettings(page);
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
gate.holdNextServerMessage("send_agent_message_response");
await submitMessage(page, "hello");
await gate.waitForHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
await expectAgentReadyToInterrupt(page);
gate.releaseHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("sends interrupt behavior on the wire when the user opts out of steering", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `interrupt-submission-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active until the user interrupts it.",
});
try {
await configureInterruptInSettings(page);
await page.goBack();
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const prompt = "Interrupt the running turn.";
await fillComposerDraft(page, prompt);
await expect(
page.getByRole("button", { name: "Send and interrupt", exact: true }),
).toHaveCount(1);
await composerLocator(page).press("Enter");
const request = await gate.waitForRequest();
expect(request.activeTurnBehavior).toBe("interrupt");
gate.accept();
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
});
test("replays Codex-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "codex");
});
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("keeps a streaming hidden submission before its output after workspace eviction", async ({
page,
}, testInfo) => {
test.setTimeout(240_000);
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(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);
});
test("keeps a daemon-handled submitted row after authoritative reload", async ({
page,
}, testInfo) => {
await expectDaemonHandledSubmissionSurvivesReload(page, testInfo);
});
test("uses untracked optimistic rows for an old host", async ({ page }, testInfo) => {
await expectOldHostSubmissionBehavior(page, testInfo);
});
});