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
1 change: 1 addition & 0 deletions docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ These records predate [`Decision 0001`](../decisions/0001-documentation-and-evid
- [`OPENPI_WEB_ARCHITECTURE.md`](OPENPI_WEB_ARCHITECTURE.md) — draft architecture, protocol boundaries, delivery phases, and visual direction for the local Web workbench
- [`OPENPI_WEB_REACT_MVP.md`](OPENPI_WEB_REACT_MVP.md) — draft local validation design for a behavior-compatible React, Astryx, and Tailwind browser migration
- [`OPENPI_WEB_DEV_PORT_CONFLICTS.md`](OPENPI_WEB_DEV_PORT_CONFLICTS.md) — draft local design for development-port fallback, strict explicit ports, startup diagnostics, and TUI `/web` error projection
- [`WEB_SESSION_CREATION_TARGET.md`](WEB_SESSION_CREATION_TARGET.md) — stable receipt identity and fail-closed target binding for a newly created Web Session's model selection and first prompt
- [`COMPLETION_INBOX.md`](COMPLETION_INBOX.md) — shared owner, epoch, consumption, retry, and receipt contract for background completions

开发与热更新流程见 [`docs/development/OPENPI_WEB_DEVELOPMENT.md`](../development/OPENPI_WEB_DEVELOPMENT.md)。
86 changes: 86 additions & 0 deletions docs/design/WEB_SESSION_CREATION_TARGET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Web Session Creation Target Binding

- Status: validated
- Created: 2026-09-08
- Verified: 2026-09-08
- Source boundary: the implementation in this record's commit, based on
`upstream/main` at `0d17f4577fe31315fe6c95370d251bdb4e2413cf`
- Related Issue: [#466](https://github.com/openpi-dev/openpi/issues/466)
- Related PR: [#490](https://github.com/openpi-dev/openpi/pull/490)
- Supersedes: none

## Problem

Creating a Web Session and sending its first prompt spans an HTTP creation
receipt, snapshot refreshes, optional model selection, and prompt admission.
Another browser tab can activate a different Session between those operations.
If the browser discards the creation receipt and reads the target back from the
latest snapshot, the original prompt can be sent to the other tab's active
Session.

The Host and runtime already reject prompts whose `sessionId` is not active.
That guard cannot recover the user's intent after the browser has replaced the
intended target with a different but currently valid Session id.

## Decision

The creation receipt carries three distinct facts:

- `commandId` correlates the creation request and its events;
- `sessionId` is the required stable target identity;
- `sessionPath` is optional because a new Pi Session may not have a persisted
file before its first message.

The Web store retains those facts as the target of the creation operation. A
snapshot refresh may confirm the target but cannot replace it. Before model
selection, after model selection, and before prompt admission, the store checks
that both `currentSessionId` and the selected Session still match the receipt's
`sessionId`. When the receipt included a path, the selected path must also
match.

If another tab changes the active Session, the operation stops without calling
model selection or prompt admission. The Composer keeps its input because
`sendPrompt` returns `false`; the store reports that the active Session changed.
The Host's existing `SESSION_CONFLICT` validation and prompt `commandId`
idempotency remain the final runtime boundaries.

This is a browser admission context, not a second Session state machine. Pi's
runtime and `SessionManager` remain authoritative for Session activation and
persistence.

## Evidence

Validated tests cover:

- a second tab becoming active between the creation receipt and snapshot;
- creation and correlated SSE events before a Session has a persisted path;
- draft model selection refusing to target the externally activated Session;
- the runtime and Host returning the same stable Session identity;
- existing Session selection, prompt retry, and model-selection races.

Repository validation on 2026-09-08:

- `bun run check` passed;
- `bun run test` passed with 1,465 Node tests passed, 1 platform-specific test
skipped, and 134 Web tests passed;
- `bun run test:web:e2e` passed 12/12. The new browser case starts the current
checkout's standalone Host and Pi runtime, delays the created Session's HTTP
receipt, switches the runtime through an independent request, suppresses the
SSE transition, and verifies that no prompt is retargeted. No model call was
made;
- the local shell had no separately installed `pi` executable, so `pi list`
provenance for an installed package was unavailable. The browser test runs
`bin/openpi.js` directly from the named checkout instead.

## Ablation

An initial implementation recorded both the expected receipt identity and
separate Session/path identities observed from creation events. Removing the
observed-identity state left the merged Web store suite at 60/60: `commandId`
already correlates the event stream, while the receipt's `sessionId` is the
only target authority needed after HTTP completion. The redundant state was
removed.

Removing receipt-bound `sessionId` validation restores the reported failure:
the snapshot can supply another tab's active Session as the first prompt target.
That validation is therefore required.
87 changes: 87 additions & 0 deletions tests/web/openpi-web.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,3 +851,90 @@ test("workspace selection survives refresh and creates the exact native Session
await rm(workspace, { recursive: true, force: true });
}
});

test("a delayed creation receipt never retargets the first prompt to another tab's Session", async ({
page,
}) => {
const workspaceA = await mkdtemp(join(tmpdir(), "openpi-issue-466-a-"));
const workspaceB = await mkdtemp(join(tmpdir(), "openpi-issue-466-b-"));
const headers = {
Authorization: `Bearer ${token}`,
Origin: "http://127.0.0.1:57109",
};
const promptRequests: unknown[] = [];
let createdSessionId: string | undefined;
let externalSessionId: string | undefined;
try {
const importedA = await page.request.post("/api/workspaces", {
headers,
data: { path: workspaceA },
});
const importedB = await page.request.post("/api/workspaces", {
headers,
data: { path: workspaceB },
});
expect(importedA.status()).toBe(201);
expect(importedB.status()).toBe(201);
const { path: canonicalA } = await importedA.json();
const { path: canonicalB } = await importedB.json();
const workspaceNameA = canonicalA.split("/").at(-1);

await page.route("**/events?**", (route) =>
route.fulfill({
contentType: "text/event-stream",
body: ": heartbeat\n\n",
}),
);
await page.route("**/api/prompt", async (route) => {
promptRequests.push(route.request().postDataJSON());
await route.fulfill({
status: 202,
json: {
id: route.request().postDataJSON().commandId,
accepted: true,
},
});
});
await page.route("**/api/sessions", async (route) => {
const createdResponse = await route.fetch();
const created = await createdResponse.json();
createdSessionId = created.sessionId;
const external = await page.request.post("/api/sessions", {
headers,
data: {
workspacePath: canonicalB,
commandId: "external-tab-switch",
},
});
expect(external.status()).toBe(201);
externalSessionId = (await external.json()).sessionId;
await route.fulfill({ response: createdResponse, json: created });
});

await openWorkbench(page);
const picker = page.locator(".workspace-picker");
await picker.click();
await page
.getByRole("menuitem", { name: workspaceNameA, exact: true })
.click();
const composer = page.getByRole("textbox", { name: "描述任务" });
await composer.fill("Only edit repository A");
await page.getByRole("button", { name: "发送", exact: true }).click();

await expect(page.locator(".notice")).toContainText("no longer active");
await expect(composer).toHaveValue("Only edit repository A");
expect(promptRequests).toEqual([]);
expect(createdSessionId).toEqual(expect.any(String));
expect(externalSessionId).toEqual(expect.any(String));
expect(createdSessionId).not.toBe(externalSessionId);
const snapshot = await page.request.get("/api/snapshot", { headers });
expect((await snapshot.json()).currentSessionId).toBe(externalSessionId);
} finally {
await page.close();
await Promise.all(
[workspaceA, workspaceB].map((path) =>
rm(path, { recursive: true, force: true }),
),
);
}
});
5 changes: 4 additions & 1 deletion tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ function runtimeFor(
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
sendPrompt: async () => ({ pendingFollowUps: 0 }),
newSession: async () => ({ cancelled: false }),
newSession: async () => ({
cancelled: false,
sessionId: sessionManager.getSessionId(),
}),
switchSession: async () => ({ cancelled: false }),
listModels: () => [],
setModel: async () => {
Expand Down
4 changes: 3 additions & 1 deletion tests/web/pi-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ test("dispose waits for pending candidate creation and cleans it before releasin
}
});

test("new session projects its command id and activated session path", async () => {
test("new session projects its command id and stable activated identity", async () => {
const active = lifecycleRuntime(lifecycleSession("session-a", false));
const candidateSession = lifecycleSession("session-b", false, 0);
Object.assign(candidateSession.sessionManager, {
Expand Down Expand Up @@ -1162,12 +1162,14 @@ test("new session projects its command id and activated session path", async ()
assert.deepEqual(result, {
cancelled: false,
commandId: "create-command",
sessionId: "session-b",
sessionPath: "/tmp/session-b.jsonl",
});
assert.deepEqual(events.at(-1), {
type: "session_switched",
detail: {
commandId: "create-command",
sessionId: "session-b",
sessionPath: "/tmp/session-b.jsonl",
},
});
Expand Down
28 changes: 23 additions & 5 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
for (const listener of listeners) listener({ type: "session_start" });
return {
cancelled: false,
sessionId: sessionManager.getSessionId(),
...(options?.commandId ? { commandId: options.commandId } : {}),
};
},
Expand Down Expand Up @@ -742,7 +743,11 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
}),
});
assert.equal(importedSession.status, 201);
assert.equal((await importedSession.json()).commandId, "create-imported");
assert.deepEqual(await importedSession.json(), {
cancelled: false,
commandId: "create-imported",
sessionId: sessionManager.getSessionId(),
});
assert.equal(runtimeCwd, importedWorkspace.path);

const newSession = await fetch(`${launched.origin}/api/sessions`, {
Expand All @@ -751,7 +756,11 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
body: JSON.stringify({ workspacePath: cwd, commandId: "create-current" }),
});
assert.equal(newSession.status, 201);
assert.equal((await newSession.json()).commandId, "create-current");
assert.deepEqual(await newSession.json(), {
cancelled: false,
commandId: "create-current",
sessionId: sessionManager.getSessionId(),
});
assert.equal(runtimeCwd, cwd);
assert.equal(newSessions, 2);
assert.deepEqual(creationCommandIds, ["create-imported", "create-current"]);
Expand Down Expand Up @@ -851,7 +860,10 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses",
prompts++;
return { pendingFollowUps: 0 };
},
newSession: async () => ({ cancelled: false }),
newSession: async () => ({
cancelled: false,
sessionId: sessionManager.getSessionId(),
}),
switchSession: async () => ({ cancelled: false }),
listModels: () => [],
setModel: async () => {
Expand Down Expand Up @@ -951,7 +963,10 @@ test("returns accepted only after Pi admits the prompt", async () => {
await promptAdmitted;
return { pendingFollowUps: 0 };
},
newSession: async () => ({ cancelled: false }),
newSession: async () => ({
cancelled: false,
sessionId: sessionManager.getSessionId(),
}),
switchSession: async () => ({ cancelled: false }),
listModels: () => [],
setModel: async () => {
Expand Down Expand Up @@ -1081,7 +1096,10 @@ function testRuntime(
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
sendPrompt,
newSession: async () => ({ cancelled: false }),
newSession: async () => ({
cancelled: false,
sessionId: sessionManager.getSessionId(),
}),
switchSession: async () => ({ cancelled: false }),
listModels: () => [],
setModel: async () => {
Expand Down
Loading
Loading