Files
paseo/packages/app/e2e/browser/assistant-fork-menu.spec.ts
Mohamed Boudra b21c004ffe Keep open chats current and show reconnection progress (#4863)
* fix(app): keep open chats current and show resume progress

Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume.

* fix(app): prevent sync status from overlapping action errors

Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze.

* test(hub): expose complete creation and message retry failures

Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload.

* fix(app): dismiss retained chat menus before freezing

An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension.

Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion.

* test(app): expect the reloaded plugin to contribute its pill again

A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then.

* fix(app): suspend hidden chats on web only

react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten.

* fix(creation): never read a receipt while replacing it

Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated.

Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change.
2026-09-15 04:30:25 +02:00

224 lines
8.0 KiB
TypeScript

import { expect, test as base } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
expectChatHistoryAttachment,
expectInFlightForkAvailable,
expectLiveAssistantText,
forkInFlightTurnToNewTab,
forkMostRecentAssistantTurnToNewTab,
forkMostRecentAssistantTurnToNewWorkspace,
observeForkAttachment,
} from "../support/helpers/assistant-fork";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentOptions,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { submitNewWorkspaceEmpty } from "../support/helpers/new-workspace";
const test = base.extend<{
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
}>({
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
const sessions: MockAgentWorkspace[] = [];
await provide(async (options) => {
const session = await seedMockAgentWorkspace(options);
sessions.push(session);
return session;
});
await Promise.allSettled(sessions.map((session) => session.cleanup()));
},
});
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
test("forks a failed assistant turn that has no provider message id", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-failed-turn-",
title: "Assistant fork failed turn",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessage(page, "Emit a synthetic turn failure.");
await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({
timeout: 30_000,
});
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
});
test("forks a streaming assistant turn without interrupting it", async ({
page,
seedForkWorkspace,
}) => {
const visibleBeforeFork = "where the auto-scroll logic actually lives";
const visibleAfterFork = "the first useful step is to read the relevant files";
const sourceAgentTitle = "Assistant fork in flight";
const forkAttachment = observeForkAttachment(page);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-in-flight-",
title: sourceAgentTitle,
model: "thirty-minute-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessage(page, "Walk me through the scroll anchor behavior.");
await expectInFlightForkAvailable(page);
await expectLiveAssistantText(page, visibleBeforeFork);
await forkInFlightTurnToNewTab(page);
await expectChatHistoryAttachment(page);
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: sourceAgentTitle }).click();
await expectLiveAssistantText(page, visibleAfterFork);
});
test("focuses a forked assistant turn in a new workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-focused-tab-",
title: "Assistant fork focused tab",
initialPrompt: "emit 1 coalesced agent stream updates for initial assistant fork turn.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
await session.client.waitForFinish(session.agentId, 45_000);
await awaitAssistantMessage(page);
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await forkMostRecentAssistantTurnToNewTab(page);
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-tab-submit-",
title: "Assistant fork tab submit",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewTab(page);
await expectChatHistoryAttachment(page);
await submitMessage(page, "");
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
await expect(userMessage).toBeVisible({ timeout: 30_000 });
await expect(userMessage).not.toContainText("Source agent:");
});
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
page,
seedForkWorkspace,
}) => {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: "secondary-assistant-fork-host",
label: "Secondary host",
// The host does not need to be reachable; this pins that the draft-scoped
// attachment survives changing the selected target host.
endpoint: "127.0.0.1:9",
},
]);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-workspace-",
title: "Assistant fork workspace",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
await expectChatHistoryAttachment(page);
await page.getByTestId("host-picker-trigger").click();
await page
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
.click();
await expectChatHistoryAttachment(page);
});
test("keeps the fork attachment after the new agent receives its user message", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-submit-",
title: "Assistant fork submit",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork submit.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await expectChatHistoryAttachment(page);
await submitNewWorkspaceEmpty(page);
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
await expect(userMessage).toBeVisible({ timeout: 30_000 });
await expect(userMessage).not.toContainText("Source agent:");
});
});