Skip to content
Merged
44 changes: 43 additions & 1 deletion tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { createElement } from "react";
import { I18nextProvider } from "react-i18next";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WebSnapshot } from "../../web/protocol/types.ts";
import { Providers } from "../../web/ui/src/app/providers.tsx";
import { App } from "../../web/ui/src/app/App.tsx";
import { Providers } from "../../web/ui/src/app/providers.tsx";
import { Markdown } from "../../web/ui/src/components/Markdown.tsx";
import { OpenPiLogo } from "../../web/ui/src/components/OpenPiLogo.tsx";
import { ActivityBar } from "../../web/ui/src/features/activity/ActivityBar.tsx";
Expand Down Expand Up @@ -784,3 +784,45 @@ it("does not repeat a provider identity used as the fallback model label", () =>
screen.queryByText("provider-alpha/model-a (provider-alpha/model-a)"),
).toBeNull();
});

it("renders explicit choices for an unknown prompt admission", () => {
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
const store = createWebStore();
renderWithI18n(
createElement(Composer, {
snapshot,
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
promptAdmissionRecovery: {
sessionId: "session",
content: "keep this draft",
commandId: "unknown-command",
optimisticKey: "optimistic-unknown-command",
checking: false,
},
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: store.getState().actions,
}),
);

expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByDisplayValue("keep this draft")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Refresh status" })).toBeNull();
expect(
screen.getByRole("button", { name: "Don't resend for now" }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Send as new message" }),
).toBeTruthy();
expect(
(screen.getByRole("button", { name: "Send" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
125 changes: 123 additions & 2 deletions tests/web/openpi-web.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { AxeBuilder } from "@axe-core/playwright";
import { expect, type Page, test } from "@playwright/test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AxeBuilder } from "@axe-core/playwright";
import { expect, type Page, test } from "@playwright/test";

const token = process.env.OPENPI_WEB_E2E_TOKEN;
if (!token) throw new Error("OPENPI_WEB_E2E_TOKEN is required");
Expand Down Expand Up @@ -255,6 +255,127 @@ test("restores a running turn and canonical dark theme without losing cancellati
expect(accessibility.violations).toEqual([]);
});

test("recovers an unknown prompt admission only after an explicit user decision", async ({
page,
}, testInfo) => {
const sessionId = "unknown-admission-session";
const promptRequests: Array<{
commandId: string;
content: string;
retry: boolean;
}> = [];
await page.route("**/api/snapshot**", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
snapshot.currentSessionId = sessionId;
snapshot.workspaces = [
{ path: "/unknown-admission", name: "Recovery", current: true },
];
snapshot.sessions = [
{
id: sessionId,
path: "/unknown-admission/session.jsonl",
cwd: "/unknown-admission",
name: "Recovery",
modified: "2026-09-08T00:00:00Z",
created: "2026-09-08T00:00:00Z",
source: "web-session",
origin: "web",
controller: "web",
readOnly: false,
messageCount: 0,
},
];
snapshot.selectedSession = {
id: sessionId,
path: "/unknown-admission/session.jsonl",
cwd: "/unknown-admission",
entries: [],
bytes: 0,
truncation: {
truncated: false,
maxBytes: 2097152,
entriesOmitted: 0,
messagesTruncated: 0,
messagePartsOmitted: 0,
},
};
snapshot.runtime = { status: "idle", capabilities: {} };
await route.fulfill({ response, json: snapshot });
});
await page.route("**/events?**", (route) =>
route.fulfill({
status: 200,
contentType: "text/event-stream",
body: ": idle\n\n",
}),
);
await page.route("**/api/prompt", async (route) => {
const body = route.request().postDataJSON() as {
commandId: string;
content: string;
retry: boolean;
};
promptRequests.push(body);
if (promptRequests.length === 1) {
await route.abort("failed");
return;
}
if (promptRequests.length === 2) {
await route.fulfill({
status: 409,
json: {
code: "COMMAND_ADMISSION_UNKNOWN",
error: "previous prompt admission is unknown",
},
});
return;
}
await route.fulfill({
status: 202,
json: { id: body.commandId, accepted: true },
});
});

await openWorkbench(page);
const draft = page.getByRole("textbox", { name: "描述任务" });
await draft.fill("可能产生副作用的请求");
await page.getByRole("button", { name: "发送", exact: true }).click();
await expect.poll(() => promptRequests.length).toBe(1);
await page.getByRole("button", { name: "发送", exact: true }).click();

await expect(
page
.getByRole("alert")
.filter({ hasText: "无法确认上次发送的消息是否已被接收" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "刷新状态" })).toHaveCount(0);
await expect(draft).toHaveValue("可能产生副作用的请求");
await expect(page.getByText("正在准备任务...", { exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
await page.screenshot({
path: testInfo.outputPath("unknown-admission-recovery.png"),
fullPage: true,
});
expect(promptRequests[1]?.commandId).toBe(promptRequests[0]?.commandId);
expect(promptRequests[1]?.retry).toBe(true);

await page.getByRole("button", { name: "作为新消息发送" }).click();
await expect.poll(() => promptRequests.length).toBe(3);
expect(promptRequests[2]?.commandId).not.toBe(promptRequests[0]?.commandId);
expect(promptRequests[2]?.retry).toBe(false);
await expect(draft).toHaveValue("");
await expect(
page.getByText("无法确认上次发送的消息是否已被接收。", {
exact: true,
}),
).toHaveCount(0);
});

test("inspects session-scoped runtime and terminal details on desktop and mobile", async ({
page,
}, testInfo) => {
Expand Down
94 changes: 94 additions & 0 deletions tests/web/web-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,100 @@ describe("OpenPI Web store", () => {
store.getState().actions.stop();
});

it("reconciles an unknown admission and requires an explicit new request", async () => {
const client = new FakeClient();
client.snapshots.push(
Promise.resolve(snapshot()),
Promise.resolve(snapshot()),
);
const prompt = vi
.spyOn(client, "prompt")
.mockRejectedValueOnce(new TypeError("lost receipt"))
.mockRejectedValueOnce(
new WebApiError("unknown", 409, "COMMAND_ADMISSION_UNKNOWN"),
)
.mockResolvedValueOnce({ id: "new", accepted: true });
const store = createWebStore(client);
await store.getState().actions.refreshSnapshot();

expect(await store.getState().actions.sendPrompt("once")).toBe(false);
expect(await store.getState().actions.sendPrompt("once")).toBe(false);
expect(store.getState().promptAdmissionRecovery).toMatchObject({
content: "once",
checking: false,
});
expect(store.getState().livePhase).toBe("idle");
expect(store.getState().liveRunning).toBe(false);
expect(await store.getState().actions.sendPrompt("once")).toBe(false);
expect(prompt).toHaveBeenCalledTimes(2);

expect(await store.getState().actions.sendPromptAsNew("edited")).toBe(true);
expect(prompt.mock.calls[2]?.[1]).toBe("edited");
expect(prompt.mock.calls[2]?.[2]).not.toBe(prompt.mock.calls[0]?.[2]);
expect(prompt.mock.calls[2]?.[3]).toBe(false);
expect(store.getState().promptAdmissionRecovery).toBeNull();
});

it("abandons unknown admission recovery without clearing its draft content", async () => {
const client = new FakeClient();
client.snapshots.push(
Promise.resolve(snapshot()),
Promise.resolve(snapshot()),
);
vi.spyOn(client, "prompt")
.mockRejectedValueOnce(new TypeError("lost receipt"))
.mockRejectedValueOnce(
new WebApiError("unknown", 409, "COMMAND_ADMISSION_UNKNOWN"),
);
const store = createWebStore(client);
await store.getState().actions.refreshSnapshot();
await store.getState().actions.sendPrompt("keep me");
await store.getState().actions.sendPrompt("keep me");

expect(store.getState().promptAdmissionRecovery?.content).toBe("keep me");
store.getState().actions.abandonPromptAdmission();
expect(store.getState().promptAdmissionRecovery).toBeNull();
expect(store.getState().liveMessages).toHaveLength(0);
});

it("keeps canonical running state during recovery and clears it on Session switch", async () => {
const client = new FakeClient();
const running = snapshot();
running.runtime = {
status: "running",
activeTurn: {
sessionId: "session-1",
commandId: "another-command",
epoch: 2,
},
capabilities: {},
};
client.snapshots.push(
Promise.resolve(snapshot()),
Promise.resolve(running),
);
vi.spyOn(client, "prompt")
.mockRejectedValueOnce(new TypeError("lost receipt"))
.mockRejectedValueOnce(
new WebApiError("unknown", 409, "COMMAND_ADMISSION_UNKNOWN"),
);
const store = createWebStore(client);
await store.getState().actions.refreshSnapshot();
await store.getState().actions.sendPrompt("once");
await store.getState().actions.sendPrompt("once");

expect(store.getState().promptAdmissionRecovery).not.toBeNull();
expect(store.getState().livePhase).toBe("running");
expect(store.getState().liveRunning).toBe(true);

const next = activeSnapshot("session-2", "/tmp/ws/session-2.jsonl", {
cursor: 9,
});
client.snapshots.push(Promise.resolve(next));
await store.getState().actions.selectSession(next.selectedSession!.path);
expect(store.getState().promptAdmissionRecovery).toBeNull();
});

it("restores the canonical running turn from a snapshot", async () => {
const client = new FakeClient();
const running = snapshot();
Expand Down
50 changes: 25 additions & 25 deletions web/dist/app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/dist/styles.css

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion web/ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { OpenPiLogo } from "../components/OpenPiLogo.tsx";
import { Composer } from "../features/composer/Composer.tsx";
import {
InspectionPanel,
type InspectionTarget,
} from "../features/inspection/InspectionPanel.tsx";
import { Composer } from "../features/composer/Composer.tsx";
import { SessionSidebar } from "../features/sessions/SessionSidebar.tsx";
import { Trajectory } from "../features/trajectory/Trajectory.tsx";
import { Transcript } from "../features/transcript/Transcript.tsx";
Expand Down Expand Up @@ -180,6 +180,7 @@ export function App() {
selectedWorkspace={state.selectedWorkspace}
sessionSwitching={state.sessionSwitching}
promptAdmissionPending={state.promptAdmissionPending}
promptAdmissionRecovery={state.promptAdmissionRecovery}
liveRunning={state.liveRunning}
landing={landing}
actions={actions}
Expand Down
Loading
Loading