Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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