From 94a979ef0eaacda936e7e903e16d9600090e1a0b Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:22:37 +0800 Subject: [PATCH 01/16] fix(web): recover from quiet or stalled connections --- tests/web/app-render.test.ts | 55 +++++++++++++++ tests/web/web-host.test.ts | 40 +++++++++++ web/host/web-host.ts | 47 +++++++++++-- web/ui/app.js | 133 +++++++++++++++++++++++++++++------ web/ui/styles.css | 4 ++ 5 files changed, 254 insertions(+), 25 deletions(-) diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index c35a3f88..4e59a666 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -348,6 +348,7 @@ async function renderApp( URL, TextDecoder, TextEncoder, + AbortController, Element: class Element {}, }; context.window = { @@ -401,6 +402,17 @@ async function renderApp( "sendPrompt", context as vm.Context, ) as () => Promise, + api: vm.runInContext("api", context as vm.Context) as ( + path: string, + options?: Record, + ) => Promise, + readEventChunk: vm.runInContext( + "readEventChunk", + context as vm.Context, + ) as ( + reader: { read(): Promise }, + timeoutMs?: number, + ) => Promise, updateComposer: vm.runInContext( "updateComposer", context as vm.Context, @@ -463,6 +475,49 @@ test("app.js resnapshots on an SSE cursor gap", async () => { assert.ok(app.readerCancellations() >= 1); }); +test("app.js uses quiet-stream heartbeats for bounded snapshot recovery", async () => { + const heartbeat = ": heartbeat\n\n"; + const app = await renderApp({ + eventRecords: [heartbeat.repeat(4)], + }); + assert.equal(app.eventFetches(), 1); + assert.ok(app.snapshotFetches() >= 2); + assert.equal(app.state.cursor, SNAPSHOT.cursor); +}); + +test("app.js bounds API waits and explains duplicate prompt admission", async () => { + const app = await renderApp(); + app.context.fetch = async ( + _url: unknown, + options?: { signal?: AbortSignal }, + ) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + await assert.rejects( + app.api("/api/stuck", { timeoutMs: 5, timeoutMessage: "bounded timeout" }), + /bounded timeout/u, + ); + await assert.rejects( + app.readEventChunk({ read: () => new Promise(() => {}) }, 5), + /event stream stalled/u, + ); + + app.state.promptAdmissionPending = true; + const input = app.elements.get("prompt-input"); + assert.ok(input); + input.value = "another message"; + await app.sendPrompt(); + assert.equal( + app.elements.get("composer-hint")?.textContent, + "OpenPI is still accepting the previous message.", + ); +}); + test("app.js invalidates snapshots for cross-tab session metadata events", async () => { const app = await renderApp({ eventRecords: [ diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..1f0ff162 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -697,6 +697,46 @@ async function startTestHost(runtime: WebRuntimeController) { return { host, launched, headers }; } +test("quiet SSE clients receive heartbeats without advancing the event cursor", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-heartbeat-")); + const host = new WebHost({ + runtime: testRuntime(cwd), + sseHeartbeatMs: 10, + }); + try { + await host.start(); + const launched = new URL(host.url); + const token = new URLSearchParams(launched.hash.slice(1)).get("token"); + assert.ok(token); + const headers = { Authorization: `Bearer ${token}` }; + const before = (await ( + await fetch(`${launched.origin}/api/snapshot`, { headers }) + ).json()) as { cursor: number }; + const response = await fetch( + `${launched.origin}/events?cursor=${before.cursor}`, + { headers }, + ); + assert.equal(response.status, 200); + assert.ok(response.body); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let received = ""; + while (!received.includes(": heartbeat\n\n")) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + received += decoder.decode(chunk.value, { stream: true }); + } + const after = (await ( + await fetch(`${launched.origin}/api/snapshot`, { headers }) + ).json()) as { cursor: number }; + assert.equal(after.cursor, before.cursor); + await reader.cancel(); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("adapter initialization fails before the Host starts listening", async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-startup-failure-")); const runtime = testRuntime(cwd); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..d9a59cf3 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -33,6 +33,7 @@ const MAX_COMMAND_BYTES = 16 * 1024; const MAX_SSE_CLIENTS = 8; const MAX_SSE_BUFFER_BYTES = 256 * 1024; const MAX_SSE_REPLAY_BYTES = MAX_SSE_BUFFER_BYTES; +const DEFAULT_SSE_HEARTBEAT_MS = 15_000; const SERVER_CLOSE_DRAIN_MS = 500; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; const execFileAsync = promisify(execFile); @@ -45,6 +46,7 @@ export interface WebHostOptions { allowedOrigins?: readonly string[]; directoryChooser?: (signal: AbortSignal) => Promise; shutdownTimeoutMs?: number; + sseHeartbeatMs?: number; } export class WebHost { @@ -52,6 +54,10 @@ export class WebHost { private readonly token: Buffer; private readonly adapter: PiWebAdapter; private readonly clients = new Set(); + private readonly clientHeartbeats = new Map< + ServerResponse, + ReturnType + >(); private readonly events: WebEvent[] = []; private sequence = 0; private port = 0; @@ -63,6 +69,7 @@ export class WebHost { WebHostOptions["directoryChooser"] >; private readonly shutdownTimeoutMs: number; + private readonly sseHeartbeatMs: number; private readonly unsubscribeCapabilities: () => void; private readonly unsubscribeRuntime: () => void; private readonly chooserAbort = new AbortController(); @@ -90,6 +97,14 @@ export class WebHost { ) { throw new Error("Web host shutdown timeout must be a positive integer"); } + this.sseHeartbeatMs = + options.sseHeartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS; + if ( + !Number.isSafeInteger(this.sseHeartbeatMs) || + this.sseHeartbeatMs <= 0 + ) { + throw new Error("SSE heartbeat interval must be a positive integer"); + } this.adapter = new PiWebAdapter(options.runtime); this.onEvent = options.onEvent; this.unsubscribeCapabilities = subscribeWebCapabilities((scope) => { @@ -185,8 +200,7 @@ export class WebHost { client.writableLength > MAX_SSE_BUFFER_BYTES || !client.write(record) ) { - this.clients.delete(client); - client.destroy(); + this.removeSseClient(client, "destroy"); } } this.onEvent?.(event.type, event.detail); @@ -204,8 +218,7 @@ export class WebHost { this.unsubscribeCapabilities(); this.unsubscribeRuntime(); this.chooserAbort.abort(); - for (const client of this.clients) client.end(); - this.clients.clear(); + for (const client of [...this.clients]) this.removeSseClient(client, "end"); const closeServer = this.server.listening ? new Promise((resolve) => { const forceClose = setTimeout( @@ -736,7 +749,31 @@ export class WebHost { // ordering without treating normal backpressure as a broken client. for (const record of replay) response.write(record); this.clients.add(response); - response.on("close", () => this.clients.delete(response)); + const heartbeat = setInterval(() => { + if ( + response.destroyed || + response.writableEnded || + response.writableLength > MAX_SSE_BUFFER_BYTES || + !response.write(": heartbeat\n\n") + ) { + this.removeSseClient(response, "destroy"); + } + }, this.sseHeartbeatMs); + heartbeat.unref(); + this.clientHeartbeats.set(response, heartbeat); + response.on("close", () => this.removeSseClient(response)); + } + + private removeSseClient( + response: ServerResponse, + close?: "destroy" | "end", + ) { + this.clients.delete(response); + const heartbeat = this.clientHeartbeats.get(response); + if (heartbeat) clearInterval(heartbeat); + this.clientHeartbeats.delete(response); + if (close === "destroy" && !response.destroyed) response.destroy(); + else if (close === "end" && !response.writableEnded) response.end(); } private parseCursor(value: string | undefined | null) { diff --git a/web/ui/app.js b/web/ui/app.js index bcff449d..38ff60b9 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -30,6 +30,7 @@ const state = { snapshotGeneration: 0, livePhase: "idle", liveRetry: null, + composerFeedback: null, query: "", selectedWorkspace: null, language: navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en", @@ -67,6 +68,10 @@ const translations = { enterHint: "Enter to send, Shift+Enter for a new line.", activeOnlyHint: "Only the active Web session accepts messages.", acceptedHint: "Message accepted by OpenPI Web.", + admissionPendingHint: "OpenPI is still accepting the previous message.", + admissionTimeout: "Prompt admission timed out. Your draft was restored; try again.", + requestTimeout: "The Web request timed out.", + reconnectingHint: "Live updates were interrupted. Reconnecting and checking canonical state...", modelRunning: "Working...", modelPreparing: "Preparing task...", modelRetrying: "Retrying model request...", @@ -103,6 +108,10 @@ const translations = { enterHint: "按 Enter 发送,Shift+Enter 换行。", activeOnlyHint: "只有当前 Web 会话可以接收消息。", acceptedHint: "OpenPI Web 已接收消息。", + admissionPendingHint: "OpenPI 仍在接收上一条消息,请稍候。", + admissionTimeout: "消息接收超时,草稿已恢复,请重试。", + requestTimeout: "Web 请求已超时。", + reconnectingHint: "实时更新已中断,正在重连并核对权威状态……", modelRunning: "正在运行...", modelPreparing: "正在准备任务...", modelRetrying: "模型请求重试中...", @@ -127,6 +136,10 @@ function applyLanguage() { const $ = (id) => document.getElementById(id); const tokenStorageKey = "openpi.web.token"; +const DEFAULT_API_TIMEOUT_MS = 15_000; +const PROMPT_ADMISSION_TIMEOUT_MS = 30_000; +const SSE_STALE_TIMEOUT_MS = 45_000; +const HEARTBEATS_PER_SNAPSHOT = 4; const fragmentToken = new URLSearchParams(location.hash.slice(1)).get("token"); let token = fragmentToken; if (fragmentToken) { @@ -139,6 +152,21 @@ const headers = (json = false) => ({ Authorization: `Bearer ${token}`, ...(json ? { "Content-Type": "application/json" } : {}), }); + +function setComposerFeedback(message, kind = "status") { + state.composerFeedback = message ? { message, kind } : null; + const hint = $("composer-hint"); + if (!hint) return; + hint.textContent = message || ""; + for (const candidate of ["status", "error", "connection"]) { + hint.classList.toggle(candidate, candidate === kind && Boolean(message)); + } +} + +function clearComposerFeedback(kind) { + if (kind && state.composerFeedback?.kind !== kind) return; + setComposerFeedback(""); +} const escapeHtml = (value) => String(value ?? "").replace( /[&<>"']/g, @@ -196,13 +224,37 @@ function renderMarkdown(value) { } async function api(path, options = {}) { - const response = await fetch(path, { - ...options, - headers: { ...headers(Boolean(options.body)), ...options.headers }, - }); - const body = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`); - return body; + const { + timeoutMs = DEFAULT_API_TIMEOUT_MS, + timeoutMessage = t("requestTimeout"), + ...requestOptions + } = options; + const controller = new AbortController(); + let timedOut = false; + const timer = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + timer.unref?.(); + try { + const response = await fetch(path, { + ...requestOptions, + signal: controller.signal, + headers: { + ...headers(Boolean(requestOptions.body)), + ...requestOptions.headers, + }, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error(body.error || `Request failed (${response.status})`); + return body; + } catch (error) { + if (timedOut) throw new Error(timeoutMessage); + throw error; + } finally { + window.clearTimeout(timer); + } } function sessionTitle(session) { @@ -527,11 +579,17 @@ function updateComposer() { : active ? t("promptMessage") : t("promptReadonly"); - $("composer-hint").textContent = canCompose + const defaultHint = canCompose ? state.snapshot.runtime.status === "running" || state.liveRunning ? t("queuedHint") : t("enterHint") : t("activeOnlyHint"); + const feedback = state.composerFeedback; + const hint = $("composer-hint"); + hint.textContent = feedback?.message || defaultHint; + for (const candidate of ["status", "error", "connection"]) { + hint.classList.toggle(candidate, feedback?.kind === candidate); + } } async function selectModel(value) { @@ -640,8 +698,7 @@ async function refreshSnapshot({ ) return false; $("connection-state").textContent = "Unavailable"; $("connection-state").classList.add("reconnecting"); - $("composer-hint").textContent = error.message; - $("composer-hint").classList.add("error"); + setComposerFeedback(error.message, "error"); return false; } } @@ -718,11 +775,14 @@ async function sendPrompt() { await chooseWorkspace(); } const content = $("prompt-input").value.trim(); + if (state.promptAdmissionPending) { + setComposerFeedback(t("admissionPendingHint")); + return; + } if ( !content || !state.selectedWorkspace || - state.sessionSwitching || - state.promptAdmissionPending + state.sessionSwitching ) return; if (!state.snapshot?.selectedSession?.id) { await createSession(state.selectedWorkspace); @@ -738,12 +798,14 @@ async function sendPrompt() { ].slice(-8); state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; + clearComposerFeedback(); renderConversation(); - $("composer-hint").classList.remove("error"); try { const receipt = await api("/api/prompt", { method: "POST", body: JSON.stringify({ sessionId, content }), + timeoutMs: PROMPT_ADMISSION_TIMEOUT_MS, + timeoutMessage: t("admissionTimeout"), }); if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return; const alreadySettled = state.terminalPromptIds.has(receipt.id); @@ -751,7 +813,7 @@ async function sendPrompt() { state.livePhase = alreadySettled ? "idle" : "preparing"; $("prompt-input").value = ""; resizePrompt(); - $("composer-hint").textContent = t("acceptedHint"); + setComposerFeedback(t("acceptedHint")); scheduleSnapshotRefresh(120); } catch (error) { if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return; @@ -761,8 +823,7 @@ async function sendPrompt() { state.liveMessages = state.liveMessages.filter( (entry) => entry.key !== optimisticKey, ); - $("composer-hint").textContent = error.message; - $("composer-hint").classList.add("error"); + setComposerFeedback(error.message, "error"); } finally { if (epoch === state.sessionEpoch && state.promptAdmissionToken === admissionToken) { state.promptAdmissionPending = false; @@ -897,8 +958,7 @@ async function createSession(workspacePath) { } function showNotice(message) { - $("composer-hint").textContent = message; - $("composer-hint").classList.add("error"); + setComposerFeedback(message, "error"); } function openWorkspaceMenu(path, anchor) { @@ -1193,17 +1253,27 @@ async function connectEvents() { if (!response.ok || !response.body) throw new Error("event connection failed"); $("connection-state").textContent = "Connected"; $("connection-state").classList.remove("reconnecting"); + clearComposerFeedback("connection"); reconnectDelay = 500; reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let heartbeatCount = 0; while (true) { - const { done, value } = await reader.read(); + const { done, value } = await readEventChunk(reader); if (done) throw new Error("event connection closed"); buffer += decoder.decode(value, { stream: true }); const records = buffer.split("\n\n"); buffer = records.pop() || ""; for (const record of records) { + if (record.split("\n").some((item) => item === ": heartbeat")) { + heartbeatCount++; + if (heartbeatCount >= HEARTBEATS_PER_SNAPSHOT) { + heartbeatCount = 0; + scheduleSnapshotRefresh(0); + } + continue; + } const line = record.split("\n").find((item) => item.startsWith("data: ")); if (!line) continue; const event = JSON.parse(line.slice(6)); @@ -1224,6 +1294,7 @@ async function connectEvents() { ? false : await refreshSnapshot({ resetCursor: true }); if (!recovered) resetLiveState(); + setComposerFeedback(t("reconnectingHint"), "connection"); await new Promise((resolve) => setTimeout(resolve, reconnectDelay)); reconnectDelay = Math.min(reconnectDelay * 2, 5_000); } finally { @@ -1232,6 +1303,24 @@ async function connectEvents() { } } +async function readEventChunk(reader, timeoutMs = SSE_STALE_TIMEOUT_MS) { + let timer; + try { + return await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = window.setTimeout( + () => reject(new Error("event stream stalled")), + timeoutMs, + ); + timer.unref?.(); + }), + ]); + } finally { + if (timer !== undefined) window.clearTimeout(timer); + } +} + const collapseButton = $("collapse-sidebar"); const collapsedStorageKey = "openpi.sidebar-collapsed"; const setSidebarCollapsed = (collapsed) => { @@ -1384,7 +1473,11 @@ $("composer")?.addEventListener("submit", (event) => { if (state.selectedWorkspace) void sendPrompt(); else void chooseWorkspace(); }); -$("prompt-input")?.addEventListener("input", resizePrompt); +$("prompt-input")?.addEventListener("input", () => { + if (!state.promptAdmissionPending) clearComposerFeedback(); + resizePrompt(); + updateComposer(); +}); $("prompt-input")?.addEventListener("keydown", (event) => { if (event.isComposing || event.keyCode === 229) return; if (event.key === "Enter" && !event.shiftKey) { diff --git a/web/ui/styles.css b/web/ui/styles.css index 0270ed9a..54d4a402 100644 --- a/web/ui/styles.css +++ b/web/ui/styles.css @@ -507,7 +507,9 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .send-button:disabled { background: var(--warm-accent-disabled); color: var(--subtle); } .send-button svg { width: 17px; height: 17px; stroke-width: 2; } .composer-hint { display: none; } +.composer-hint.status, .composer-hint.error, .composer-hint.connection { display: block; margin: 7px 2px 0; color: var(--muted); font-size: 11px; line-height: 1.35; } .composer-hint.error { color: var(--error); } +.composer-hint.connection { color: #9a6700; } .sidebar-scrim { display: none; } @media (max-width: 760px) { @@ -551,6 +553,8 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .conversation { scroll-behavior: auto; } } +.composer-hint.status, .composer-hint.error, .composer-hint.connection { display: block; } + /* The conversation view should start with the actual messages. On narrow screens keep only the sidebar trigger as an overlay so navigation remains available without restoring the session header. */ From 72b334ded7b3dc0c77de066930546ef004fd797c Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:32:58 +0800 Subject: [PATCH 02/16] test(web): keep browser watchdog timers referenced --- web/ui/app.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/ui/app.js b/web/ui/app.js index 38ff60b9..770f7ec7 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -235,7 +235,6 @@ async function api(path, options = {}) { timedOut = true; controller.abort(); }, timeoutMs); - timer.unref?.(); try { const response = await fetch(path, { ...requestOptions, @@ -1313,7 +1312,6 @@ async function readEventChunk(reader, timeoutMs = SSE_STALE_TIMEOUT_MS) { () => reject(new Error("event stream stalled")), timeoutMs, ); - timer.unref?.(); }), ]); } finally { From bd854751c1afbd0de9be7cbd04bf51cb069ecfdb Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:44:32 +0800 Subject: [PATCH 03/16] feat(web): add turn-scoped cancellation --- docs/development/OPENPI_WEB_DEVELOPMENT.md | 8 + tests/web/app-render.test.ts | 70 +++++++++ tests/web/pi-adapter.test.ts | 2 + tests/web/pi-runtime.test.ts | 175 ++++++++++++++++++++- tests/web/web-host.test.ts | 84 ++++++++++ web/adapter/pi-adapter.ts | 3 + web/host/web-host.ts | 45 ++++++ web/protocol/types.ts | 2 + web/runtime/pi-runtime.ts | 168 +++++++++++++++++++- web/runtime/types.ts | 24 +++ web/ui/app.js | 109 +++++++++++-- web/ui/index.html | 3 + web/ui/styles.css | 5 + 13 files changed, 684 insertions(+), 14 deletions(-) diff --git a/docs/development/OPENPI_WEB_DEVELOPMENT.md b/docs/development/OPENPI_WEB_DEVELOPMENT.md index eef5bc97..8a2f71f6 100644 --- a/docs/development/OPENPI_WEB_DEVELOPMENT.md +++ b/docs/development/OPENPI_WEB_DEVELOPMENT.md @@ -51,6 +51,14 @@ bun run dev:web -- /absolute/path/to/workspace 异常进程恢复会在 Web Session 目录的 `.openpi-web-host.artifacts/` 中保留安全围栏。只有确认没有存活或暂停的 Web Host 仍依赖这些记录后,才可人工删除其中过期的 `candidate-*`、`released-*` 或 `stale-*` 目录。OpenPI 不会自动删除围栏;达到 128 个租约产物或 64 个 stale 围栏时会 fail closed,并在错误信息中给出该目录。普通 Session 文件不占用这个预算。 +## 活动回合取消协议 + +Web 的 Stop 只取消当前活动的 provider 回合,不等同于停止 Host,也不会清空已经排队的 follow-up。取消请求必须回传当前快照或 `turn_started` 事件给出的 `sessionId`、`commandId` 和 `epoch`;Runtime 在自己的串行 mutation 边界内重新核对三者,再调用 Pi 原生 `AgentSession.abort()`。 + +Host 返回 `accepted`、`already-settled`、`stale-session`、`stale-turn` 或 `failed` 的明确收据。浏览器不会因点击按钮而乐观结束运行态;只有 Pi 消息的 `stopReason: "aborted"` 投影成 `turn_settled(outcome: "cancelled")` 后才显示取消终态。活动回合身份也包含在快照中,因此刷新和 SSE 重连仍能恢复正确的 Stop 控件。多客户端的旧请求不能取消更新的回合,重复请求则按已终结回合幂等返回。 + +这个边界源自 [Issue #342](https://github.com/openpi-dev/openpi/issues/342)。Host disposal 仍由独立生命周期处理;全局暂停属于其他设计范围。 + `dev:web` 和 `dev:web:backend` 默认会在启动它们的终端输出 Web 诊断日志;设置 `OPENPI_WEB_DEBUG=0` 可关闭。正式运行 `openpi web` 默认关闭日志,排查时设置 `OPENPI_WEB_DEBUG=1`。 ## 对话无响应排查 diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index c35a3f88..a19c9cba 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -401,6 +401,10 @@ async function renderApp( "sendPrompt", context as vm.Context, ) as () => Promise, + cancelActiveTurn: vm.runInContext( + "cancelActiveTurn", + context as vm.Context, + ) as () => Promise, updateComposer: vm.runInContext( "updateComposer", context as vm.Context, @@ -867,6 +871,72 @@ test("app.js settles an admitted prompt that Pi handles without an agent turn", assert.equal((app.state.terminalPromptIds as Set).size, 32); }); +test("app.js stops only the canonical active turn without optimistic settlement", async () => { + const app = await renderApp(); + const cancellation = deferred>(); + app.context.fetch = async (url: unknown) => { + if (String(url) === "/api/turns/cancel") return cancellation.promise; + if (String(url).startsWith("/api/snapshot")) return response(SNAPSHOT); + throw new Error(`unexpected request: ${String(url)}`); + }; + vm.runInContext( + 'applyRuntimeEvent({sequence: 2, type: "turn_started", detail: {sessionId: "s1", commandId: "c1", epoch: 4}})', + app.context as vm.Context, + ); + + assert.equal(app.state.liveRunning, true); + assert.equal(app.elements.get("stop-turn")?.hidden, false); + assert.equal(app.elements.get("send-prompt")?.hidden, true); + const stopping = app.cancelActiveTurn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(app.state.liveRunning, true); + assert.equal(app.state.turnCancellationPending, true); + + vm.runInContext( + 'applyRuntimeEvent({sequence: 3, type: "turn_settled", detail: {sessionId: "s1", commandId: "c1", epoch: 4, outcome: "cancelled"}})', + app.context as vm.Context, + ); + cancellation.resolve( + response({ + sessionId: "s1", + commandId: "c1", + epoch: 4, + state: "accepted", + accepted: true, + }), + ); + await stopping; + + assert.equal(app.state.liveRunning, false); + assert.equal(app.state.activeTurn, null); + assert.equal(app.elements.get("stop-turn")?.hidden, true); + assert.equal(app.elements.get("send-prompt")?.hidden, false); + assert.equal( + app.elements.get("composer-hint")?.textContent, + "Current turn stopped.", + ); +}); + +test("app.js restores the active turn and Stop control from a snapshot", async () => { + const running = structuredClone(SNAPSHOT) as SnapshotFixture & { + runtime: typeof SNAPSHOT.runtime & { + activeTurn: { sessionId: string; commandId: string; epoch: number }; + }; + }; + running.runtime.status = "running"; + running.runtime.activeTurn = { + sessionId: "s1", + commandId: "c1", + epoch: 9, + }; + const app = await renderApp({ snapshot: running }); + + assert.deepEqual(app.state.activeTurn, running.runtime.activeTurn); + assert.equal(app.state.liveRunning, true); + assert.equal(app.elements.get("stop-turn")?.hidden, false); + assert.equal(app.elements.get("send-prompt")?.hidden, true); +}); + test("app.js scopes model selection to its session epoch", async () => { const app = await renderApp(); const model = deferred>(); diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index af2f7476..bdb4fcff 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -29,6 +29,8 @@ function runtimeFor( sessionDirectory, sessionManager, isIdle: () => true, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), sendPrompt: async () => {}, newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index ae6f3f78..b96106c1 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -17,6 +17,9 @@ type Trace = { startedAt: number; started: boolean; queued: boolean; + userMessageObserved?: boolean; + epoch?: number; + outcome?: "completed" | "cancelled" | "failed"; }; type RuntimeHarness = { @@ -26,6 +29,9 @@ type RuntimeHarness = { liveMessageSequence: number; liveMessageKey?: string; listeners: Set<(event: WebRuntimeEvent) => void>; + nextTurnEpoch: number; + terminalTurnKeys: Set; + turnSettlementWaiters: Map void>>; }; function deferred() { @@ -84,11 +90,17 @@ type PromptRuntimeHarness = { runtimeDisposalPromises: WeakMap>; promptAdmission: Promise; pendingPromptTraces: Trace[]; + activePromptTrace?: Trace; + nextTurnEpoch: number; + terminalTurnKeys: Set; + turnSettlementWaiters: Map void>>; + controllerMutation: Promise; disposed: boolean; hasSelectedWorkspace: boolean; dispatcherLease: { release: () => Promise }; webHostLease: { release: () => Promise }; sendPrompt: PiWebRuntime["sendPrompt"]; + cancelTurn: PiWebRuntime["cancelTurn"]; subscribe: PiWebRuntime["subscribe"]; dispose: PiWebRuntime["dispose"]; }; @@ -237,6 +249,10 @@ function promptHarness(session: ReturnType) { harness.runtimeDisposalPromises = new WeakMap(); harness.promptAdmission = Promise.resolve(); harness.pendingPromptTraces = []; + harness.nextTurnEpoch = 0; + harness.terminalTurnKeys = new Set(); + harness.turnSettlementWaiters = new Map(); + harness.controllerMutation = Promise.resolve(); harness.disposed = false; harness.hasSelectedWorkspace = true; harness.dispatcherLease = { release: async () => undefined }; @@ -556,6 +572,108 @@ test("later prompt failures retain their command and Session correlation", async }); }); +test("turn cancellation is bound, canonical, and idempotent", async () => { + const session = promptSession("session-a"); + let aborts = 0; + session.abort = async () => { + aborts += 1; + }; + const runtime = promptHarness(session); + const trace: Trace = { + commandId: "command-a", + sessionId: "session-a", + startedAt: 1, + started: true, + queued: false, + epoch: 7, + outcome: "cancelled", + }; + runtime.activePromptTrace = trace; + const settlePromptTrace = ( + PiWebRuntime.prototype as unknown as { + settlePromptTrace(this: PromptRuntimeHarness, trace: Trace): void; + } + ).settlePromptTrace; + + const cancellation = runtime.cancelTurn({ + sessionId: "session-a", + commandId: "command-a", + epoch: 7, + }); + await Promise.resolve(); + settlePromptTrace.call(runtime, trace); + + assert.deepEqual(await cancellation, { + sessionId: "session-a", + commandId: "command-a", + epoch: 7, + state: "accepted", + }); + assert.equal(aborts, 1); + assert.equal( + ( + await runtime.cancelTurn({ + sessionId: "session-a", + commandId: "command-a", + epoch: 7, + }) + ).state, + "already-settled", + ); + assert.equal( + ( + await runtime.cancelTurn({ + sessionId: "session-a", + commandId: "command-a", + epoch: 8, + }) + ).state, + "stale-turn", + ); + assert.equal( + ( + await runtime.cancelTurn({ + sessionId: "session-b", + commandId: "command-a", + epoch: 7, + }) + ).state, + "stale-session", + ); + assert.equal(aborts, 1); +}); + +test("turn cancellation reports native abort failures", async () => { + const session = promptSession("session-a"); + session.abort = async () => { + throw new Error("abort failed"); + }; + const runtime = promptHarness(session); + runtime.activePromptTrace = { + commandId: "command-a", + sessionId: "session-a", + startedAt: 1, + started: true, + queued: false, + epoch: 1, + }; + + assert.deepEqual( + await runtime.cancelTurn({ + sessionId: "session-a", + commandId: "command-a", + epoch: 1, + }), + { + sessionId: "session-a", + commandId: "command-a", + epoch: 1, + state: "failed", + error: "abort failed", + }, + ); +}); + test("retained Session cleanup waits for all of its prompt operations", async () => { const sessionA = promptSession("session-a"); const sessionB = promptSession("session-b"); @@ -910,12 +1028,17 @@ test("runtime creation failure releases the Web Host lease", async () => { }); test("prompt traces advance with queued user messages", () => { - const session = {}; + const session = { sessionManager: { getSessionId: () => "session" } }; const harness = Object.create(PiWebRuntime.prototype) as RuntimeHarness; harness.runtime = { session }; harness.pendingPromptTraces = []; harness.liveMessageSequence = 0; harness.listeners = new Set(); + harness.nextTurnEpoch = 0; + harness.terminalTurnKeys = new Set(); + harness.turnSettlementWaiters = new Map(); + const events: WebRuntimeEvent[] = []; + harness.listeners.add((event) => events.push(event)); harness.activePromptTrace = { commandId: "first", sessionId: "session", @@ -941,12 +1064,62 @@ test("prompt traces advance with queued user messages", () => { message: { role: "user", content: [{ type: "text", text }] }, }); + projectEvent.call(harness, session, { type: "agent_start" }); projectEvent.call(harness, session, userMessage("first")); assert.equal(harness.activePromptTrace?.commandId, "first"); assert.equal(harness.activePromptTrace?.started, true); + projectEvent.call(harness, session, { + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "aborted", + timestamp: 3, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + }, + }); + projectEvent.call(harness, session, userMessage("second")); assert.equal(harness.activePromptTrace?.commandId, "second"); assert.equal(harness.activePromptTrace?.started, true); + assert.equal(harness.activePromptTrace?.epoch, 2); assert.equal(harness.pendingPromptTraces.length, 0); + assert.deepEqual( + events + .filter((event) => event.type.startsWith("turn_")) + .map((event) => ({ type: event.type, detail: event.detail })), + [ + { + type: "turn_started", + detail: { sessionId: "session", commandId: "first", epoch: 1 }, + }, + { + type: "turn_settled", + detail: { + sessionId: "session", + commandId: "first", + epoch: 1, + outcome: "cancelled", + }, + }, + { + type: "turn_started", + detail: { sessionId: "session", commandId: "second", epoch: 2 }, + }, + ], + ); }); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..681f7c26 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -49,6 +49,8 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn return sessionManager; }, isIdle: () => false, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), sendPrompt: async (content) => { prompts.push(content); }, @@ -527,6 +529,8 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", sessionDirectory: root, sessionManager, isIdle: () => true, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), sendPrompt: async () => { prompts++; }, @@ -586,6 +590,21 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", }); assert.equal(prompts, 0); + const cancellation = await fetch( + `${launched.origin}/api/turns/cancel`, + { + method: "POST", + headers, + body: JSON.stringify({ + sessionId: sessionManager.getSessionId(), + commandId: "command-a", + epoch: 1, + }), + }, + ); + assert.equal(cancellation.status, 409); + assert.equal((await cancellation.json()).code, "WORKSPACE_REQUIRED"); + const started = events.find((event) => event.type === "web_host_started"); assert.ok(started); assert.equal("cwd" in (started.detail ?? {}), false); @@ -611,6 +630,8 @@ test("returns accepted only after Pi admits the prompt", async () => { cwd, sessionManager, isIdle: () => false, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), sendPrompt: async () => { promptStarted = true; await promptAdmitted; @@ -663,6 +684,67 @@ test("returns accepted only after Pi admits the prompt", async () => { } }); +test("returns an exact receipt for a turn-bound cancellation", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-cancel-")); + const runtime = testRuntime(cwd); + const activeTurn = { + sessionId: runtime.sessionManager.getSessionId(), + commandId: "command-a", + epoch: 3, + }; + runtime.getActiveTurn = () => activeTurn; + runtime.cancelTurn = async (options) => ({ + ...options, + state: "accepted", + }); + const { host, launched, headers } = await startTestHost(runtime); + try { + const snapshotResponse = await fetch(`${launched.origin}/api/snapshot`, { + headers, + }); + assert.equal(snapshotResponse.status, 200); + assert.deepEqual( + (await snapshotResponse.json()).runtime.activeTurn, + activeTurn, + ); + + const response = await fetch(`${launched.origin}/api/turns/cancel`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(activeTurn), + }); + assert.equal(response.status, 202); + assert.deepEqual(await response.json(), { + ...activeTurn, + state: "accepted", + accepted: true, + cursor: 1, + }); + + runtime.cancelTurn = async (options) => ({ + ...options, + state: "stale-turn", + }); + const stale = await fetch(`${launched.origin}/api/turns/cancel`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify(activeTurn), + }); + assert.equal(stale.status, 409); + assert.equal((await stale.json()).state, "stale-turn"); + + const invalid = await fetch(`${launched.origin}/api/turns/cancel`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ ...activeTurn, epoch: 0 }), + }); + assert.equal(invalid.status, 400); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + function testRuntime( cwd: string, sendPrompt: WebRuntimeController["sendPrompt"] = async () => {}, @@ -674,6 +756,8 @@ function testRuntime( cwd, sessionManager, isIdle: () => true, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), sendPrompt, newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index fb4c0bc9..ba46852c 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -490,6 +490,9 @@ export class PiWebAdapter { status: this.runtime.isIdle() ? ("idle" as const) : ("running" as const), + ...(this.runtime.getActiveTurn() + ? { activeTurn: this.runtime.getActiveTurn() } + : {}), capabilities: webCapabilitySnapshot(this.runtime.sessionManager), }, truncation: { diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..f542d155 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -276,6 +276,7 @@ export class WebHost { if (request.method === "GET" || request.method === "HEAD") return false; const pathname = new URL(request.url ?? "/", `http://${HOST}`).pathname; if (pathname === "/api/prompt") return false; + if (pathname === "/api/turns/cancel") return true; return pathname.startsWith("/api/workspaces") || pathname.startsWith("/api/sessions") || pathname === "/api/model"; @@ -541,6 +542,50 @@ export class WebHost { cursor: this.sequence, }); } + if (url.pathname === "/api/turns/cancel" && request.method === "POST") { + const body = await this.readJson(request); + if ( + typeof body.sessionId !== "string" || + body.sessionId.length === 0 || + body.sessionId.length > 128 || + typeof body.commandId !== "string" || + body.commandId.length === 0 || + body.commandId.length > 128 || + typeof body.epoch !== "number" || + !Number.isSafeInteger(body.epoch) || + body.epoch <= 0 + ) { + return this.json(response, 400, { + code: "INVALID_TURN", + error: "bounded sessionId, commandId, and positive turn epoch are required", + }); + } + if (this.runtime.workspaceSelected !== true) { + return this.json(response, 409, { + code: "WORKSPACE_REQUIRED", + error: "Choose a workspace before using the Web runtime", + }); + } + const result = await this.runtime.cancelTurn({ + sessionId: body.sessionId, + commandId: body.commandId, + epoch: body.epoch, + }); + traceWeb("turn_cancel_receipt", { ...result }); + const status = + result.state === "accepted" + ? 202 + : result.state === "already-settled" + ? 200 + : result.state === "failed" + ? 500 + : 409; + return this.json(response, status, { + ...result, + accepted: result.state === "accepted", + cursor: this.sequence, + }); + } if (request.method !== "GET") { return this.json(response, 405, { error: "method not allowed" }); } diff --git a/web/protocol/types.ts b/web/protocol/types.ts index a7429b6e..ecaa6632 100644 --- a/web/protocol/types.ts +++ b/web/protocol/types.ts @@ -1,5 +1,6 @@ import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import type { WebCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts"; +import type { WebActiveTurn } from "../runtime/types.ts"; export const WEB_PROTOCOL_VERSION = 1; export const WEB_MAX_EVENTS = 200; @@ -113,6 +114,7 @@ export interface WebSnapshot { models: WebModelSummary[]; runtime: { status: "idle" | "running" | "unknown"; + activeTurn?: WebActiveTurn; capabilities: WebCapabilitySnapshot; }; truncation: WebSnapshotTruncation; diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index cf792d39..2df53879 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -15,11 +15,14 @@ import { hasTrustRequiringProjectResources, } from "@earendil-works/pi-coding-agent"; import { + type WebActiveTurn, type WebModelSelectionOptions, type WebPromptOptions, type WebRuntimeController, type WebRuntimeEvent, type WebSessionCreationOptions, + type WebTurnCancellationOptions, + type WebTurnCancellationResult, WebRuntimeRequestError, } from "./types.ts"; import { projectMessage } from "../protocol/types.ts"; @@ -43,6 +46,13 @@ type PromptTrace = { startedAt: number; started: boolean; queued: boolean; + userMessageObserved: boolean; + epoch?: number; + outcome?: "completed" | "cancelled" | "failed"; +}; + +type TurnSettlement = WebActiveTurn & { + outcome: "completed" | "cancelled" | "failed"; }; function errorText(error: unknown) { @@ -77,6 +87,12 @@ export class PiWebRuntime implements WebRuntimeController { private promptAdmission: Promise = Promise.resolve(); private activePromptTrace?: PromptTrace; private readonly pendingPromptTraces: PromptTrace[] = []; + private nextTurnEpoch = 0; + private readonly terminalTurnKeys = new Set(); + private readonly turnSettlementWaiters = new Map< + string, + Set<(settlement: TurnSettlement) => void> + >(); private liveMessageKey?: string; private liveMessageSequence = 0; private readonly webSessionDirectory: string; @@ -174,6 +190,74 @@ export class PiWebRuntime implements WebRuntimeController { return !this.runtime.session.isStreaming; } + getActiveTurn() { + return this.activeTurnFromTrace(this.activePromptTrace); + } + + cancelTurn(options: WebTurnCancellationOptions) { + return this.serializeControllerMutation(() => + this.cancelActiveTurn(options), + ); + } + + private async cancelActiveTurn( + options: WebTurnCancellationOptions, + ): Promise { + this.assertActive(); + this.assertWorkspaceSelected(); + const activeSessionId = this.runtime.session.sessionManager.getSessionId(); + if (options.sessionId !== activeSessionId) { + return { ...options, state: "stale-session" }; + } + const key = this.turnKey(options); + if (this.terminalTurnKeys.has(key)) { + return { ...options, state: "already-settled" }; + } + const activeTurn = this.getActiveTurn(); + if ( + !activeTurn || + activeTurn.commandId !== options.commandId || + activeTurn.epoch !== options.epoch + ) { + return { ...options, state: "stale-turn" }; + } + + let ownWaiter: ((settlement: TurnSettlement) => void) | undefined; + const settlement = new Promise((resolveSettlement) => { + ownWaiter = resolveSettlement; + const waiters = this.turnSettlementWaiters.get(key) ?? new Set(); + waiters.add(resolveSettlement); + this.turnSettlementWaiters.set(key, waiters); + }); + try { + const abortOperation = this.runtime.session.abort(); + const abortFailure = new Promise((_, reject) => { + void abortOperation.catch(reject); + }); + const terminal = await Promise.race([settlement, abortFailure]); + return { + ...options, + state: + terminal.outcome === "cancelled" + ? "accepted" + : terminal.outcome === "completed" + ? "already-settled" + : "failed", + ...(terminal.outcome === "failed" + ? { error: "The active turn failed while cancellation was requested" } + : {}), + }; + } catch (error) { + return { ...options, state: "failed", error: errorText(error) }; + } finally { + const waiters = this.turnSettlementWaiters.get(key); + if (waiters && ownWaiter) { + waiters.delete(ownWaiter); + if (waiters.size === 0) this.turnSettlementWaiters.delete(key); + } + } + } + listModels() { const current = this.runtime.session.model; const available = [...this.runtime.services.modelRuntime.getAvailableSnapshot()]; @@ -295,6 +379,7 @@ export class PiWebRuntime implements WebRuntimeController { startedAt, started: false, queued, + userMessageObserved: false, } : undefined; this.retainRuntimeReference(agentRuntime); @@ -432,6 +517,7 @@ export class PiWebRuntime implements WebRuntimeController { }); } if (promptTrace) { + promptTrace.outcome = "failed"; traceWeb("prompt_operation_failed", { commandId: promptTrace.commandId, sessionId, @@ -725,13 +811,23 @@ export class PiWebRuntime implements WebRuntimeController { private projectEvent(session: AgentSession, event: AgentSessionEvent) { if (session !== this.runtime.session) return; + if (event.type === "agent_start" && this.activePromptTrace) { + this.startPromptTrace(this.activePromptTrace); + } if (event.type === "message_start" && event.message.role === "user") { if (!this.activePromptTrace) { this.activePromptTrace = this.pendingPromptTraces.shift(); - } else if (this.activePromptTrace.started && this.pendingPromptTraces.length > 0) { + } else if ( + this.activePromptTrace.userMessageObserved && + this.pendingPromptTraces.length > 0 + ) { + this.settlePromptTrace(this.activePromptTrace); this.activePromptTrace = this.pendingPromptTraces.shift(); } - if (this.activePromptTrace) this.activePromptTrace.started = true; + if (this.activePromptTrace) { + this.startPromptTrace(this.activePromptTrace); + this.activePromptTrace.userMessageObserved = true; + } } const promptTrace = this.activePromptTrace; if (promptTrace) { @@ -770,15 +866,24 @@ export class PiWebRuntime implements WebRuntimeController { } switch (event.type) { case "agent_start": + this.emit(event.type, { + sessionId: session.sessionManager.getSessionId(), + ...(this.getActiveTurn() + ? { activeTurn: this.getActiveTurn() } + : {}), + }); + break; case "agent_settled": - this.emit(event.type); if ( - event.type === "agent_settled" && this.activePromptTrace?.started && this.pendingPromptTraces.length === 0 ) { + this.settlePromptTrace(this.activePromptTrace); this.activePromptTrace = undefined; } + this.emit(event.type, { + sessionId: session.sessionManager.getSessionId(), + }); break; case "auto_retry_start": this.emit(event.type, { @@ -796,6 +901,18 @@ export class PiWebRuntime implements WebRuntimeController { break; case "message_update": case "message_end": + if ( + event.type === "message_end" && + event.message.role === "assistant" && + this.activePromptTrace + ) { + this.activePromptTrace.outcome = + event.message.stopReason === "aborted" + ? "cancelled" + : event.message.stopReason === "error" + ? "failed" + : "completed"; + } this.emit(event.type, { message: projectMessage(event.message), ...(this.liveMessageKey ? { messageKey: this.liveMessageKey } : {}), @@ -819,10 +936,53 @@ export class PiWebRuntime implements WebRuntimeController { for (const listener of this.listeners) listener({ type, detail }); } + private activeTurnFromTrace(trace?: PromptTrace): WebActiveTurn | undefined { + if (!trace?.started || trace.epoch === undefined) return undefined; + return { + sessionId: trace.sessionId, + commandId: trace.commandId, + epoch: trace.epoch, + }; + } + + private startPromptTrace(trace: PromptTrace) { + if (trace.started) return; + trace.started = true; + trace.epoch = ++this.nextTurnEpoch; + const activeTurn = this.activeTurnFromTrace(trace); + if (activeTurn) this.emit("turn_started", { ...activeTurn }); + } + + private settlePromptTrace(trace: PromptTrace) { + const activeTurn = this.activeTurnFromTrace(trace); + if (!activeTurn) return; + const settlement: TurnSettlement = { + ...activeTurn, + outcome: trace.outcome ?? "completed", + }; + const key = this.turnKey(activeTurn); + if (this.terminalTurnKeys.has(key)) return; + this.terminalTurnKeys.add(key); + while (this.terminalTurnKeys.size > 64) { + const oldest = this.terminalTurnKeys.values().next().value; + if (typeof oldest === "string") this.terminalTurnKeys.delete(oldest); + } + this.emit("turn_settled", { ...settlement }); + for (const resolveSettlement of this.turnSettlementWaiters.get(key) ?? []) { + resolveSettlement(settlement); + } + this.turnSettlementWaiters.delete(key); + } + + private turnKey(turn: WebActiveTurn) { + return `${turn.sessionId}\u0000${turn.commandId}\u0000${turn.epoch}`; + } + private removePromptTrace(trace: PromptTrace) { const pendingIndex = this.pendingPromptTraces.indexOf(trace); if (pendingIndex !== -1) this.pendingPromptTraces.splice(pendingIndex, 1); if (this.activePromptTrace !== trace) return; + if (trace.started) this.settlePromptTrace(trace); this.activePromptTrace = this.pendingPromptTraces.shift(); } diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 2b66c3f0..6bc8b9ee 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -33,6 +33,26 @@ export interface WebPromptOptions { expectedSessionId?: string; } +export interface WebActiveTurn { + sessionId: string; + commandId: string; + epoch: number; +} + +export interface WebTurnCancellationOptions extends WebActiveTurn {} + +export type WebTurnCancellationState = + | "accepted" + | "already-settled" + | "stale-session" + | "stale-turn" + | "failed"; + +export interface WebTurnCancellationResult extends WebActiveTurn { + state: WebTurnCancellationState; + error?: string; +} + export interface WebModelSelectionOptions { expectedSessionId?: string; } @@ -54,7 +74,11 @@ export interface WebRuntimeController { readonly sessionDirectory: string; readonly sessionManager: SessionManager; isIdle(): boolean; + getActiveTurn(): WebActiveTurn | undefined; sendPrompt(content: string, options?: WebPromptOptions): Promise; + cancelTurn( + options: WebTurnCancellationOptions, + ): Promise; newSession( workspacePath: string, options?: WebSessionCreationOptions, diff --git a/web/ui/app.js b/web/ui/app.js index bcff449d..575ca5a2 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -19,6 +19,9 @@ const state = { collapsed: readCollapsedWorkspaces(), liveMessages: [], liveRunning: false, + activeTurn: null, + turnCancellationPending: false, + turnTerminalStatus: null, promptAdmissionPending: false, promptAdmissionToken: null, promptAdmissionSequence: 0, @@ -67,6 +70,9 @@ const translations = { enterHint: "Enter to send, Shift+Enter for a new line.", activeOnlyHint: "Only the active Web session accepts messages.", acceptedHint: "Message accepted by OpenPI Web.", + stopTurn: "Stop turn", + stoppingTurn: "Stopping current turn...", + stoppedTurn: "Current turn stopped.", modelRunning: "Working...", modelPreparing: "Preparing task...", modelRetrying: "Retrying model request...", @@ -103,6 +109,9 @@ const translations = { enterHint: "按 Enter 发送,Shift+Enter 换行。", activeOnlyHint: "只有当前 Web 会话可以接收消息。", acceptedHint: "OpenPI Web 已接收消息。", + stopTurn: "停止当前回合", + stoppingTurn: "正在停止当前回合...", + stoppedTurn: "当前回合已停止。", modelRunning: "正在运行...", modelPreparing: "正在准备任务...", modelRetrying: "模型请求重试中...", @@ -482,6 +491,13 @@ function updateComposer() { !selected && !state.snapshot?.currentSessionId; const canCompose = active || newSessionDraft; + const activeTurn = active + ? state.activeTurn || state.snapshot?.runtime.activeTurn || null + : null; + const canStop = Boolean( + activeTurn && + (state.snapshot?.runtime.status === "running" || state.liveRunning), + ); $("prompt-input").disabled = state.sessionSwitching || (!canCompose && Boolean(state.selectedWorkspace)); $("send-prompt").disabled = @@ -489,6 +505,9 @@ function updateComposer() { !canCompose || !state.selectedWorkspace || state.promptAdmissionPending; + $("send-prompt").hidden = canStop; + $("stop-turn").hidden = !canStop; + $("stop-turn").disabled = state.turnCancellationPending; const modelPicker = $("model-picker"); const modelPickerValue = $("model-picker-value"); const modelMenu = $("model-menu"); @@ -527,11 +546,14 @@ function updateComposer() { : active ? t("promptMessage") : t("promptReadonly"); - $("composer-hint").textContent = canCompose - ? state.snapshot.runtime.status === "running" || state.liveRunning - ? t("queuedHint") - : t("enterHint") - : t("activeOnlyHint"); + $("composer-hint").textContent = + state.turnTerminalStatus === "cancelled" + ? t("stoppedTurn") + : canCompose + ? state.snapshot.runtime.status === "running" || state.liveRunning + ? t("queuedHint") + : t("enterHint") + : t("activeOnlyHint"); } async function selectModel(value) { @@ -605,6 +627,9 @@ async function refreshSnapshot({ return false; } state.snapshot = snapshot; + if (resetCursor) resetLiveState(); + state.activeTurn = snapshot.runtime.activeTurn || null; + if (snapshot.runtime.status === "running") state.liveRunning = true; if ( state.snapshot.runtime.status !== "running" && !state.promptAdmissionPending @@ -613,7 +638,6 @@ async function refreshSnapshot({ state.livePhase = "idle"; state.liveRetry = null; } - if (resetCursor) resetLiveState(); state.cursor = resetCursor || state.cursor === null ? state.snapshot.cursor : Math.max(state.cursor, state.snapshot.cursor); @@ -738,6 +762,7 @@ async function sendPrompt() { ].slice(-8); state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; + state.turnTerminalStatus = null; renderConversation(); $("composer-hint").classList.remove("error"); try { @@ -772,6 +797,36 @@ async function sendPrompt() { } } +async function cancelActiveTurn() { + const turn = state.activeTurn || state.snapshot?.runtime.activeTurn; + if (!turn || state.turnCancellationPending || state.sessionSwitching) return; + const epoch = state.sessionEpoch; + state.turnCancellationPending = true; + $("composer-hint").classList.remove("error"); + $("composer-hint").textContent = t("stoppingTurn"); + updateComposer(); + try { + const receipt = await api("/api/turns/cancel", { + method: "POST", + body: JSON.stringify(turn), + }); + if (epoch !== state.sessionEpoch) return; + if (receipt.state === "accepted" || receipt.state === "already-settled") { + $("composer-hint").textContent = t("stoppedTurn"); + } + } catch (error) { + if (epoch !== state.sessionEpoch) return; + $("composer-hint").textContent = error.message; + $("composer-hint").classList.add("error"); + await refreshSnapshot({ epoch }); + } finally { + if (epoch === state.sessionEpoch) { + state.turnCancellationPending = false; + renderConversation(); + } + } +} + function resizePrompt() { const input = $("prompt-input"); const maxHeight = 220; @@ -1070,16 +1125,44 @@ function applyRuntimeEvent(event) { state.livePhase = alreadySettled ? "idle" : "preparing"; state.liveRetry = null; renderConversation(); + } else if (event.type === "turn_started") { + state.activeTurn = { + sessionId: event.detail?.sessionId, + commandId: event.detail?.commandId, + epoch: event.detail?.epoch, + }; + state.liveRunning = true; + state.turnTerminalStatus = null; + state.livePhase = "running"; + state.liveRetry = null; + renderConversation(); } else if (event.type === "agent_start") { + if (event.detail?.activeTurn) state.activeTurn = event.detail.activeTurn; state.liveRunning = true; state.livePhase = "running"; state.liveRetry = null; renderConversation(); + } else if (event.type === "turn_settled") { + rememberTerminalPrompt(event.detail?.commandId); + const isActiveTurn = + state.activeTurn?.sessionId === event.detail?.sessionId && + state.activeTurn?.commandId === event.detail?.commandId && + state.activeTurn?.epoch === event.detail?.epoch; + if (isActiveTurn) { + state.activeTurn = null; + state.liveRunning = false; + state.livePhase = "idle"; + state.liveRetry = null; + state.turnTerminalStatus = event.detail?.outcome || null; + } + renderConversation(); } else if (event.type === "agent_settled" || event.type === "prompt_settled") { if (event.type === "prompt_settled") rememberTerminalPrompt(event.detail?.commandId); - state.liveRunning = false; - state.livePhase = "idle"; - state.liveRetry = null; + if (!state.activeTurn) { + state.liveRunning = false; + state.livePhase = "idle"; + state.liveRetry = null; + } renderConversation(); } else if (event.detail?.message) { if (event.detail.message.role === "user") { @@ -1100,6 +1183,8 @@ function applyRuntimeEvent(event) { [ "agent_start", "agent_settled", + "turn_started", + "turn_settled", "prompt_settled", "message_end", "tool_execution_end", @@ -1163,6 +1248,9 @@ let eventLoopStarted = false; function resetLiveState() { state.liveMessages = []; state.liveRunning = false; + state.activeTurn = null; + state.turnCancellationPending = false; + state.turnTerminalStatus = null; state.livePhase = "idle"; state.liveRetry = null; } @@ -1384,6 +1472,9 @@ $("composer")?.addEventListener("submit", (event) => { if (state.selectedWorkspace) void sendPrompt(); else void chooseWorkspace(); }); +$("stop-turn")?.addEventListener("click", () => { + void cancelActiveTurn(); +}); $("prompt-input")?.addEventListener("input", resizePrompt); $("prompt-input")?.addEventListener("keydown", (event) => { if (event.isComposing || event.keyCode === 229) return; diff --git a/web/ui/index.html b/web/ui/index.html index 7aa60047..3b5da19a 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -118,6 +118,9 @@ +
diff --git a/web/ui/styles.css b/web/ui/styles.css index 0270ed9a..47711c3e 100644 --- a/web/ui/styles.css +++ b/web/ui/styles.css @@ -506,6 +506,11 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .send-button:hover { background: var(--warm-accent-hover); } .send-button:disabled { background: var(--warm-accent-disabled); color: var(--subtle); } .send-button svg { width: 17px; height: 17px; stroke-width: 2; } +.stop-button { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 1px solid #d6cbc1; border-radius: 50%; background: #f7eee8; color: #8a3f32; } +.stop-button:hover { border-color: #c8b3a5; background: #f1e2d9; } +.stop-button:disabled { border-color: var(--border); background: #f3f1ed; color: var(--subtle); } +.stop-button[hidden] { display: none; } +.stop-button svg { width: 16px; height: 16px; fill: currentColor; stroke: none; } .composer-hint { display: none; } .composer-hint.error { color: var(--error); } .sidebar-scrim { display: none; } From a0d18d25fc661d1230b267287d4a8a54d68ca6cf Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:19:56 +0800 Subject: [PATCH 04/16] style(web): format cancellation endpoint test --- tests/web/web-host.test.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 681f7c26..ce683086 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -590,18 +590,15 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", }); assert.equal(prompts, 0); - const cancellation = await fetch( - `${launched.origin}/api/turns/cancel`, - { - method: "POST", - headers, - body: JSON.stringify({ - sessionId: sessionManager.getSessionId(), - commandId: "command-a", - epoch: 1, - }), - }, - ); + const cancellation = await fetch(`${launched.origin}/api/turns/cancel`, { + method: "POST", + headers, + body: JSON.stringify({ + sessionId: sessionManager.getSessionId(), + commandId: "command-a", + epoch: 1, + }), + }); assert.equal(cancellation.status, 409); assert.equal((await cancellation.json()).code, "WORKSPACE_REQUIRED"); From 9e05b1204b1ac8d31b639cf8c02b1f1a22b4f94e Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:21:17 +0800 Subject: [PATCH 05/16] fix(web): report queued prompt position --- tests/web/pi-adapter.test.ts | 2 +- tests/web/pi-runtime.test.ts | 17 ++++++++++++++++- tests/web/web-host.test.ts | 19 +++++++++++++++++-- web/host/web-host.ts | 12 ++++++++++-- web/runtime/pi-runtime.ts | 23 ++++++++++++++++++----- web/runtime/types.ts | 10 +++++++++- 6 files changed, 71 insertions(+), 12 deletions(-) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index af2f7476..c17af4a3 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -29,7 +29,7 @@ function runtimeFor( sessionDirectory, sessionManager, isIdle: () => true, - sendPrompt: async () => {}, + sendPrompt: async () => ({ queued: false, queuePosition: 0 }), newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), listModels: () => [], diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index 996012f7..3e7ee8bc 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -262,13 +262,28 @@ test("prompt admission waits for Pi preflight acceptance", async () => { assert.equal(settled, false); session.calls[0].options.preflightResult?.(true); - await admission; + assert.deepEqual(await admission, { queued: false, queuePosition: 0 }); assert.equal(settled, true); session.calls[0].run.resolve(); await Promise.resolve(); }); +test("prompt admission reports the canonical queue position", async () => { + const session = promptSession("session-a"); + session.isStreaming = true; + const runtime = promptHarness(session); + const admission = runtime.sendPrompt("queued", { + commandId: "command-queued", + expectedSessionId: "session-a", + }); + await Promise.resolve(); + session.calls[0].options.preflightResult?.(true); + assert.deepEqual(await admission, { queued: true, queuePosition: 1 }); + session.calls[0].run.resolve(); + await Promise.resolve(); +}); + test("prompt preflight rejection is a typed non-admission", async () => { const session = promptSession("session-a"); const runtime = promptHarness(session); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..d058f625 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -51,6 +51,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn isIdle: () => false, sendPrompt: async (content) => { prompts.push(content); + return { queued: false, queuePosition: 0 }; }, newSession: async (workspacePath, options) => { newSessions++; @@ -529,6 +530,7 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", isIdle: () => true, sendPrompt: async () => { prompts++; + return { queued: false, queuePosition: 0 }; }, newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), @@ -614,6 +616,7 @@ test("returns accepted only after Pi admits the prompt", async () => { sendPrompt: async () => { promptStarted = true; await promptAdmitted; + return { queued: false, queuePosition: 0 }; }, newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), @@ -654,7 +657,14 @@ test("returns accepted only after Pi admits the prompt", async () => { resolvePrompt(); const response = await responsePromise; assert.equal(response.status, 202); - assert.equal((await response.json()).accepted, true); + const responseBody = (await response.json()) as { + accepted: boolean; + queued: boolean; + queuePosition: number; + }; + assert.equal(responseBody.accepted, true); + assert.equal(responseBody.queued, false); + assert.equal(responseBody.queuePosition, 0); } finally { resolvePrompt(); await host.stop(); @@ -665,7 +675,10 @@ test("returns accepted only after Pi admits the prompt", async () => { function testRuntime( cwd: string, - sendPrompt: WebRuntimeController["sendPrompt"] = async () => {}, + sendPrompt: WebRuntimeController["sendPrompt"] = async () => ({ + queued: false, + queuePosition: 0, + }), ) { const sessionManager = SessionManager.inMemory(cwd); const runtime: WebRuntimeController = { @@ -1120,6 +1133,7 @@ test("stop rejects a late keepalive mutation before it enters the drain", async const runtime = testRuntime(cwd, async () => { promptStarted(); await promptBarrier; + return { queued: false, queuePosition: 0 }; }); runtime.dispose = async () => { releasePrompt(); @@ -1304,6 +1318,7 @@ test("stop disposes the runtime before waiting for an in-flight prompt request", const runtime = testRuntime(cwd, async () => { promptStarted(); await pendingPrompt; + return { queued: false, queuePosition: 0 }; }); runtime.dispose = async () => { disposeCalls++; diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..e3c1df60 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -507,8 +507,9 @@ export class WebHost { sessionId: body.sessionId, chars: content.length, }); + let admission: { queued: boolean; queuePosition: number }; try { - await this.runtime.sendPrompt(content, { + admission = await this.runtime.sendPrompt(content, { commandId, expectedSessionId: body.sessionId, }); @@ -528,7 +529,12 @@ export class WebHost { error: failure.error, }); } - this.publish("prompt_accepted", { commandId, sessionId: body.sessionId }); + this.publish("prompt_accepted", { + commandId, + sessionId: body.sessionId, + queuePosition: admission.queuePosition, + queued: admission.queued, + }); traceWeb("prompt_response_sent", { commandId, sessionId: body.sessionId, @@ -538,6 +544,8 @@ export class WebHost { id: commandId, accepted: true, state: "accepted", + queued: admission.queued, + queuePosition: admission.queuePosition, cursor: this.sequence, }); } diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index cf792d39..bd44454d 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -17,6 +17,7 @@ import { import { type WebModelSelectionOptions, type WebPromptOptions, + type WebPromptAdmissionReceipt, type WebRuntimeController, type WebRuntimeEvent, type WebSessionCreationOptions, @@ -298,12 +299,14 @@ export class PiWebRuntime implements WebRuntimeController { } : undefined; this.retainRuntimeReference(agentRuntime); - let resolveRequest: () => void = () => undefined; + let resolveRequest: (receipt: WebPromptAdmissionReceipt) => void = () => undefined; let rejectRequest: (error: unknown) => void = () => undefined; - const requestAdmission = new Promise((resolveRequestAdmission, reject) => { + const requestAdmission = new Promise( + (resolveRequestAdmission, reject) => { resolveRequest = resolveRequestAdmission; rejectRequest = reject; - }); + }, + ); const operation = (async () => { let preflightObserved = false; let admitted = false; @@ -363,7 +366,17 @@ export class PiWebRuntime implements WebRuntimeController { ); } if (accepted) { - resolveRequest(); + const queuePosition = queued + ? Math.max( + 1, + this.pendingPromptTraces.length + + (this.activePromptTrace ? 1 : 0), + ) + : 0; + resolveRequest({ + queued, + queuePosition, + }); } else { rejectRequest( new WebRuntimeRequestError( @@ -451,7 +464,7 @@ export class PiWebRuntime implements WebRuntimeController { () => this.promptOperations.delete(operation), () => this.promptOperations.delete(operation), ); - await requestAdmission; + return requestAdmission; } newSession(workspacePath: string, options?: WebSessionCreationOptions) { diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 2b66c3f0..f90814ea 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -33,6 +33,11 @@ export interface WebPromptOptions { expectedSessionId?: string; } +export interface WebPromptAdmissionReceipt { + queued: boolean; + queuePosition: number; +} + export interface WebModelSelectionOptions { expectedSessionId?: string; } @@ -54,7 +59,10 @@ export interface WebRuntimeController { readonly sessionDirectory: string; readonly sessionManager: SessionManager; isIdle(): boolean; - sendPrompt(content: string, options?: WebPromptOptions): Promise; + sendPrompt( + content: string, + options?: WebPromptOptions, + ): Promise; newSession( workspacePath: string, options?: WebSessionCreationOptions, From c4305ad039b3cff9b073c77eeed9a9ff5a4ae087 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:37:44 +0800 Subject: [PATCH 06/16] fix(web): bound cancellation settlement wait --- web/runtime/pi-runtime.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 2df53879..c7c3e1be 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -38,6 +38,7 @@ import { } from "./web-host-lease.ts"; const STARTUP_TIMEOUT_MS = 15_000; +const TURN_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 10_000; const BOOTSTRAP_WORKSPACE_DIRECTORY = ".bootstrap-workspace"; type PromptTrace = { @@ -140,6 +141,7 @@ export class PiWebRuntime implements WebRuntimeController { const webSessionDirectory = join(getAgentDir(), "web-sessions"); const webHostLease = await acquireWebHostLease(webSessionDirectory); let runtime: PiWebRuntime | undefined; + let timeoutHandle: ReturnType | undefined; try { const created = await PiWebRuntime.createRuntime( canonicalCwd, @@ -229,12 +231,28 @@ export class PiWebRuntime implements WebRuntimeController { waiters.add(resolveSettlement); this.turnSettlementWaiters.set(key, waiters); }); + let timeoutHandle: ReturnType | undefined; try { const abortOperation = this.runtime.session.abort(); const abortFailure = new Promise((_, reject) => { void abortOperation.catch(reject); }); - const terminal = await Promise.race([settlement, abortFailure]); + const settlementTimeout = new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => + reject( + new Error( + "Cancellation did not settle within the bounded wait window", + ), + ), + TURN_CANCELLATION_SETTLEMENT_TIMEOUT_MS, + ); + }); + const terminal = await Promise.race([ + settlement, + abortFailure, + settlementTimeout, + ]); return { ...options, state: @@ -250,6 +268,7 @@ export class PiWebRuntime implements WebRuntimeController { } catch (error) { return { ...options, state: "failed", error: errorText(error) }; } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); const waiters = this.turnSettlementWaiters.get(key); if (waiters && ownWaiter) { waiters.delete(ownWaiter); From 0997d4038d2cc73b8afbfacde50da632ecc9226c Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:42:29 +0800 Subject: [PATCH 07/16] fix(web): make prompt admission retries idempotent --- tests/web/web-host.test.ts | 12 +++++++++ web/host/web-host.ts | 52 +++++++++++++++++++++++++++++++++----- web/ui/app.js | 6 +++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 1f0ff162..fe7cfcab 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -345,10 +345,22 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn body: JSON.stringify({ sessionId: sessionManager.getSessionId(), content: "continue here", + commandId: "retry-command-1", }), }); assert.equal(prompt.status, 202); assert.deepEqual(prompts, ["continue here"]); + const retriedPrompt = await fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: authorized, + body: JSON.stringify({ + sessionId: sessionManager.getSessionId(), + content: "continue here", + commandId: "retry-command-1", + }), + }); + assert.equal(retriedPrompt.status, 202); + assert.deepEqual(prompts, ["continue here"]); const importResponse = await fetch(`${launched.origin}/api/workspaces`, { method: "POST", diff --git a/web/host/web-host.ts b/web/host/web-host.ts index d9a59cf3..c37754e6 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -75,6 +75,14 @@ export class WebHost { private readonly chooserAbort = new AbortController(); private readonly leaseSensitiveRequests = new Set>(); private readonly leaseSensitiveMessages = new Set(); + private readonly promptAdmissions = new Map< + string, + { + readonly sessionId: string; + readonly content: string; + readonly promise: Promise; + } + >(); private stopping = false; private stopPromise?: Promise; @@ -499,6 +507,15 @@ export class WebHost { error: "prompt must be 1-12000 characters", }); } + const commandId = + typeof body.commandId === "string" && body.commandId.length > 0 + ? body.commandId + : randomUUID(); + if (commandId.length > 128) { + return this.json(response, 400, { + error: "commandId must be at most 128 characters", + }); + } if (this.runtime.workspaceSelected !== true) { return this.json(response, 409, { code: "WORKSPACE_REQUIRED", @@ -514,17 +531,38 @@ export class WebHost { error: "Only the active Web session accepts messages", }); } - const commandId = randomUUID(); traceWeb("prompt_received", { commandId, sessionId: body.sessionId, chars: content.length, }); + let acceptedFresh = true; try { - await this.runtime.sendPrompt(content, { - commandId, - expectedSessionId: body.sessionId, - }); + const existing = this.promptAdmissions.get(commandId); + if (existing) { + acceptedFresh = false; + if ( + existing.sessionId !== body.sessionId || + existing.content !== content + ) { + return this.json(response, 409, { + code: "COMMAND_CONFLICT", + error: "commandId is already bound to a different prompt", + }); + } + await existing.promise; + } else { + const promise = this.runtime.sendPrompt(content, { + commandId, + expectedSessionId: body.sessionId, + }); + this.promptAdmissions.set(commandId, { + sessionId: body.sessionId, + content, + promise, + }); + await promise; + } traceWeb("prompt_admission_finished", { commandId, elapsedMs: elapsed(requestStarted), @@ -541,7 +579,9 @@ export class WebHost { error: failure.error, }); } - this.publish("prompt_accepted", { commandId, sessionId: body.sessionId }); + if (acceptedFresh) { + this.publish("prompt_accepted", { commandId, sessionId: body.sessionId }); + } traceWeb("prompt_response_sent", { commandId, sessionId: body.sessionId, diff --git a/web/ui/app.js b/web/ui/app.js index 770f7ec7..eb6cd646 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -790,7 +790,9 @@ async function sendPrompt() { if (!sessionId || state.sessionSwitching || state.promptAdmissionPending) return; const epoch = state.sessionEpoch; const admissionToken = ++state.promptAdmissionSequence; - const optimisticKey = `optimistic-${Date.now()}`; + const commandId = globalThis.crypto?.randomUUID?.() || + `web-prompt-${Date.now()}-${admissionToken}`; + const optimisticKey = `optimistic-${commandId}`; state.liveMessages = [ ...state.liveMessages, { key: optimisticKey, message: { role: "user", content } }, @@ -802,7 +804,7 @@ async function sendPrompt() { try { const receipt = await api("/api/prompt", { method: "POST", - body: JSON.stringify({ sessionId, content }), + body: JSON.stringify({ sessionId, content, commandId }), timeoutMs: PROMPT_ADMISSION_TIMEOUT_MS, timeoutMessage: t("admissionTimeout"), }); From a90c822689c2334cd9c995a86024b3e7b26d982a Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:47:12 +0800 Subject: [PATCH 08/16] fix(web): report queue state after admission gate --- tests/web/pi-runtime.test.ts | 26 ++++++++++++++++++++++++++ web/runtime/pi-runtime.ts | 22 ++++++++++++---------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index 3e7ee8bc..cb8a3a0c 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -284,6 +284,32 @@ test("prompt admission reports the canonical queue position", async () => { await Promise.resolve(); }); +test("prompt admission observes streaming after an earlier admission gate", async () => { + const session = promptSession("session-a"); + const runtime = promptHarness(session); + const first = runtime.sendPrompt("first", { + commandId: "command-first", + expectedSessionId: "session-a", + }); + await Promise.resolve(); + const second = runtime.sendPrompt("second", { + commandId: "command-second", + expectedSessionId: "session-a", + }); + await Promise.resolve(); + + session.calls[0].options.preflightResult?.(true); + session.isStreaming = true; + assert.deepEqual(await first, { queued: false, queuePosition: 0 }); + session.calls[0].run.resolve(); + await Promise.resolve(); + session.calls[1].options.preflightResult?.(true); + assert.deepEqual(await second, { queued: true, queuePosition: 1 }); + + session.calls[1].run.resolve(); + await Promise.resolve(); +}); + test("prompt preflight rejection is a typed non-admission", async () => { const session = promptSession("session-a"); const runtime = promptHarness(session); diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index bd44454d..0a7ce40a 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -288,16 +288,6 @@ export class PiWebRuntime implements WebRuntimeController { releaseAdmission = resolveAdmission; }); const startedAt = performance.now(); - const queued = session.isStreaming; - const promptTrace: PromptTrace | undefined = options?.commandId - ? { - commandId: options.commandId, - sessionId, - startedAt, - started: false, - queued, - } - : undefined; this.retainRuntimeReference(agentRuntime); let resolveRequest: (receipt: WebPromptAdmissionReceipt) => void = () => undefined; let rejectRequest: (error: unknown) => void = () => undefined; @@ -312,10 +302,22 @@ export class PiWebRuntime implements WebRuntimeController { let admitted = false; let agentLifecycleStarted = false; let queuedForAgent = false; + let queued = false; + let promptTrace: PromptTrace | undefined; let unsubscribePromptLifecycle: (() => void) | undefined; try { await previousAdmission; this.assertActive(); + queued = session.isStreaming; + promptTrace = options?.commandId + ? { + commandId: options.commandId, + sessionId, + startedAt, + started: false, + queued, + } + : undefined; if (promptTrace && agentRuntime === this.runtime) { this.pendingPromptTraces.push(promptTrace); this.activePromptTrace ??= this.pendingPromptTraces.shift(); From 683a6c85393d7b3636ccbf6f8b67d3a177dbe58e Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:47:14 +0800 Subject: [PATCH 09/16] test(web): add real prompt retry verification screenshot --- .../pr-370/prompt-idempotency.png | Bin 0 -> 45445 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/issue-evidence/pr-370/prompt-idempotency.png diff --git a/.github/issue-evidence/pr-370/prompt-idempotency.png b/.github/issue-evidence/pr-370/prompt-idempotency.png new file mode 100644 index 0000000000000000000000000000000000000000..b7887704c1898c0704d774fa90f1d0e6cd01470b GIT binary patch literal 45445 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 G;C}&An^cMb literal 0 HcmV?d00001 From 2f4192e22f8c3c9b08af656023cab531960b0822 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:27:42 +0800 Subject: [PATCH 10/16] fix(web): settle cancellation on Pi execution completion --- docs/development/OPENPI_WEB_DEVELOPMENT.md | 4 +- tests/web/app-render.test.ts | 70 +++++++++ tests/web/pi-runtime.test.ts | 161 +++++++++++++++++++-- web/runtime/pi-runtime.ts | 62 +++++--- web/ui/app.js | 111 ++++++++++++-- 5 files changed, 368 insertions(+), 40 deletions(-) diff --git a/docs/development/OPENPI_WEB_DEVELOPMENT.md b/docs/development/OPENPI_WEB_DEVELOPMENT.md index 8a2f71f6..c8a48eeb 100644 --- a/docs/development/OPENPI_WEB_DEVELOPMENT.md +++ b/docs/development/OPENPI_WEB_DEVELOPMENT.md @@ -53,9 +53,9 @@ bun run dev:web -- /absolute/path/to/workspace ## 活动回合取消协议 -Web 的 Stop 只取消当前活动的 provider 回合,不等同于停止 Host,也不会清空已经排队的 follow-up。取消请求必须回传当前快照或 `turn_started` 事件给出的 `sessionId`、`commandId` 和 `epoch`;Runtime 在自己的串行 mutation 边界内重新核对三者,再调用 Pi 原生 `AgentSession.abort()`。 +Web 的 Stop 请求 Pi 停止当前 agent execution,不等同于停止 Host,也不会清空已经排队的 follow-up。取消请求必须回传当前快照或 `turn_started` 事件给出的 `sessionId`、`commandId` 和 `epoch`;Runtime 在自己的串行 mutation 边界内重新核对三者,再调用 Pi 原生 `AgentSession.abort()`。这不是“一条输入一个回合”的额外队列:Pi 可以在同一次 execution 中继续处理已排队的 follow-up;只有下一次真实 `agent_start` 才会取得新的 Stop identity。 -Host 返回 `accepted`、`already-settled`、`stale-session`、`stale-turn` 或 `failed` 的明确收据。浏览器不会因点击按钮而乐观结束运行态;只有 Pi 消息的 `stopReason: "aborted"` 投影成 `turn_settled(outcome: "cancelled")` 后才显示取消终态。活动回合身份也包含在快照中,因此刷新和 SSE 重连仍能恢复正确的 Stop 控件。多客户端的旧请求不能取消更新的回合,重复请求则按已终结回合幂等返回。 +Host 返回 `accepted`、`already-settled`、`stale-session`、`stale-turn` 或 `failed` 的明确收据。浏览器不会因点击按钮而乐观结束运行态;只有 Pi 的完整 execution 发出 `agent_settled`,并且其中有被 Stop 目标对应的 assistant 结果 `stopReason: "aborted"`,才投影为 `turn_settled(outcome: "cancelled")`。这个 outcome 只描述被请求停止的 provider 结果,不概括同一次 execution 中 Pi 随后处理的 follow-up 是否成功。单条 `message_end` 只提供结果证据,不能单独结束 execution;若 Pi settled 时没有终态 assistant 证据,Runtime 投影 `uncertain` 并返回 `failed`,不会猜测取消成功。活动回合身份也包含在快照中,因此刷新和 SSE 重连仍能恢复正确的 Stop 控件。多客户端的旧请求不能取消更新的回合,重复请求则按已终结回合幂等返回。 这个边界源自 [Issue #342](https://github.com/openpi-dev/openpi/issues/342)。Host disposal 仍由独立生命周期处理;全局暂停属于其他设计范围。 diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index 46848da0..e74a795c 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -401,6 +401,10 @@ async function renderApp( "sendPrompt", context as vm.Context, ) as () => Promise, + cancelActiveTurn: vm.runInContext( + "cancelActiveTurn", + context as vm.Context, + ) as () => Promise, updateComposer: vm.runInContext( "updateComposer", context as vm.Context, @@ -867,6 +871,72 @@ test("app.js settles an admitted prompt that Pi handles without an agent turn", assert.equal((app.state.terminalPromptIds as Set).size, 32); }); +test("app.js stops only the canonical active turn without optimistic settlement", async () => { + const app = await renderApp(); + const cancellation = deferred>(); + app.context.fetch = async (url: unknown) => { + if (String(url) === "/api/turns/cancel") return cancellation.promise; + if (String(url).startsWith("/api/snapshot")) return response(SNAPSHOT); + throw new Error(`unexpected request: ${String(url)}`); + }; + vm.runInContext( + 'applyRuntimeEvent({sequence: 2, type: "turn_started", detail: {sessionId: "s1", commandId: "c1", epoch: 4}})', + app.context as vm.Context, + ); + + assert.equal(app.state.liveRunning, true); + assert.equal(app.elements.get("stop-turn")?.hidden, false); + assert.equal(app.elements.get("send-prompt")?.hidden, true); + const stopping = app.cancelActiveTurn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(app.state.liveRunning, true); + assert.equal(app.state.turnCancellationPending, true); + + vm.runInContext( + 'applyRuntimeEvent({sequence: 3, type: "turn_settled", detail: {sessionId: "s1", commandId: "c1", epoch: 4, outcome: "cancelled"}})', + app.context as vm.Context, + ); + cancellation.resolve( + response({ + sessionId: "s1", + commandId: "c1", + epoch: 4, + state: "accepted", + accepted: true, + }), + ); + await stopping; + + assert.equal(app.state.liveRunning, false); + assert.equal(app.state.activeTurn, null); + assert.equal(app.elements.get("stop-turn")?.hidden, true); + assert.equal(app.elements.get("send-prompt")?.hidden, false); + assert.equal( + app.elements.get("composer-hint")?.textContent, + "Current turn stopped.", + ); +}); + +test("app.js restores the active turn and Stop control from a snapshot", async () => { + const running = structuredClone(SNAPSHOT) as SnapshotFixture & { + runtime: typeof SNAPSHOT.runtime & { + activeTurn: { sessionId: string; commandId: string; epoch: number }; + }; + }; + running.runtime.status = "running"; + running.runtime.activeTurn = { + sessionId: "s1", + commandId: "c1", + epoch: 9, + }; + const app = await renderApp({ snapshot: running }); + + assert.deepEqual(app.state.activeTurn, running.runtime.activeTurn); + assert.equal(app.state.liveRunning, true); + assert.equal(app.elements.get("stop-turn")?.hidden, false); + assert.equal(app.elements.get("send-prompt")?.hidden, true); +}); + test("app.js keeps an active agent running when a handled prompt settles", async () => { const app = await renderApp(); vm.runInContext( diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index 8dee27f1..b7ef155b 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -19,7 +19,7 @@ type Trace = { queued: boolean; userMessageObserved?: boolean; epoch?: number; - outcome?: "completed" | "cancelled" | "failed"; + outcome?: "completed" | "cancelled" | "failed" | "uncertain"; }; type RuntimeHarness = { @@ -32,6 +32,7 @@ type RuntimeHarness = { nextTurnEpoch: number; terminalTurnKeys: Set; turnSettlementWaiters: Map void>>; + turnAbortOperations: Map>; }; function deferred() { @@ -94,6 +95,7 @@ type PromptRuntimeHarness = { nextTurnEpoch: number; terminalTurnKeys: Set; turnSettlementWaiters: Map void>>; + turnAbortOperations: Map>; controllerMutation: Promise; disposed: boolean; hasSelectedWorkspace: boolean; @@ -142,6 +144,7 @@ type LifecycleHarness = { candidateRuntimes: Set; pendingPromptTraces: Trace[]; activePromptTrace?: Trace; + turnAbortOperations: Map>; liveMessageSequence: number; disposed: boolean; dispatcherLease: { release: () => Promise }; @@ -210,6 +213,7 @@ function lifecycleHarness(runtime: LifecycleRuntime) { harness.runtimeOperations = new Set(); harness.candidateRuntimes = new Set(); harness.pendingPromptTraces = []; + harness.turnAbortOperations = new Map(); harness.liveMessageSequence = 0; harness.disposed = false; harness.dispatcherLease = { release: async () => undefined }; @@ -252,6 +256,7 @@ function promptHarness(session: ReturnType) { harness.nextTurnEpoch = 0; harness.terminalTurnKeys = new Set(); harness.turnSettlementWaiters = new Map(); + harness.turnAbortOperations = new Map(); harness.controllerMutation = Promise.resolve(); harness.disposed = false; harness.hasSelectedWorkspace = true; @@ -572,7 +577,7 @@ test("later prompt failures retain their command and Session correlation", async }); }); -test("turn cancellation is bound, canonical, and idempotent", async () => { +test("turn cancellation reports uncertainty without assistant terminal evidence", async () => { const session = promptSession("session-a"); let aborts = 0; session.abort = async () => { @@ -586,14 +591,17 @@ test("turn cancellation is bound, canonical, and idempotent", async () => { started: true, queued: false, epoch: 7, - outcome: "cancelled", }; runtime.activePromptTrace = trace; - const settlePromptTrace = ( + const projectEvent = ( PiWebRuntime.prototype as unknown as { - settlePromptTrace(this: PromptRuntimeHarness, trace: Trace): void; + projectEvent( + this: PromptRuntimeHarness, + session: object, + event: { type: string }, + ): void; } - ).settlePromptTrace; + ).projectEvent; const cancellation = runtime.cancelTurn({ sessionId: "session-a", @@ -601,13 +609,15 @@ test("turn cancellation is bound, canonical, and idempotent", async () => { epoch: 7, }); await Promise.resolve(); - settlePromptTrace.call(runtime, trace); + projectEvent.call(runtime, session, { type: "agent_settled" }); assert.deepEqual(await cancellation, { sessionId: "session-a", commandId: "command-a", epoch: 7, - state: "accepted", + state: "failed", + error: + "Pi settled without a terminal assistant outcome for this cancellation", }); assert.equal(aborts, 1); assert.equal( @@ -643,6 +653,81 @@ test("turn cancellation is bound, canonical, and idempotent", async () => { assert.equal(aborts, 1); }); +test("turn cancellation loses to a naturally completed terminal run", async () => { + const session = promptSession("session-a"); + let aborts = 0; + session.abort = async () => { + aborts += 1; + }; + const runtime = promptHarness(session); + runtime.activePromptTrace = { + commandId: "command-a", + sessionId: "session-a", + startedAt: 1, + started: true, + queued: false, + epoch: 1, + outcome: "completed", + }; + const projectEvent = ( + PiWebRuntime.prototype as unknown as { + projectEvent( + this: PromptRuntimeHarness, + session: object, + event: { type: string }, + ): void; + } + ).projectEvent; + + const cancellation = runtime.cancelTurn({ + sessionId: "session-a", + commandId: "command-a", + epoch: 1, + }); + await Promise.resolve(); + projectEvent.call(runtime, session, { type: "agent_settled" }); + + assert.equal((await cancellation).state, "already-settled"); + assert.equal(aborts, 1); +}); + +test("a repeated cancellation does not issue another native abort while settling", async () => { + const session = promptSession("session-a"); + let aborts = 0; + session.abort = async () => { + aborts += 1; + }; + const runtime = promptHarness(session); + runtime.activePromptTrace = { + commandId: "command-a", + sessionId: "session-a", + startedAt: 1, + started: true, + queued: false, + epoch: 1, + }; + runtime.turnAbortOperations.set( + "session-a\u0000command-a\u00001", + new Promise(() => undefined), + ); + + assert.deepEqual( + await runtime.cancelTurn({ + sessionId: "session-a", + commandId: "command-a", + epoch: 1, + }), + { + sessionId: "session-a", + commandId: "command-a", + epoch: 1, + state: "failed", + error: "Cancellation is already waiting for Pi to settle this turn", + }, + ); + assert.equal(aborts, 0); +}); + test("turn cancellation reports native abort failures", async () => { const session = promptSession("session-a"); session.abort = async () => { @@ -1027,7 +1112,7 @@ test("runtime creation failure releases the Web Host lease", async () => { } }); -test("prompt traces advance with queued user messages", () => { +test("message_end and queued prompts do not settle a running turn", () => { const session = { sessionManager: { getSessionId: () => "session" } }; const harness = Object.create(PiWebRuntime.prototype) as RuntimeHarness; harness.runtime = { session }; @@ -1037,6 +1122,7 @@ test("prompt traces advance with queued user messages", () => { harness.nextTurnEpoch = 0; harness.terminalTurnKeys = new Set(); harness.turnSettlementWaiters = new Map(); + harness.turnAbortOperations = new Map(); const events: WebRuntimeEvent[] = []; harness.listeners.add((event) => events.push(event)); harness.activePromptTrace = { @@ -1093,11 +1179,62 @@ test("prompt traces advance with queued user messages", () => { }, }); + projectEvent.call(harness, session, { + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "stop", + timestamp: 4, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + }, + }); + projectEvent.call(harness, session, userMessage("second")); - assert.equal(harness.activePromptTrace?.commandId, "second"); + assert.equal(harness.activePromptTrace?.commandId, "first"); assert.equal(harness.activePromptTrace?.started, true); - assert.equal(harness.activePromptTrace?.epoch, 2); + assert.equal(harness.activePromptTrace?.epoch, 1); + assert.equal(harness.pendingPromptTraces.length, 1); + assert.deepEqual( + events.filter((event) => event.type === "turn_settled"), + [], + ); + + projectEvent.call(harness, session, { type: "agent_settled" }); + assert.equal(harness.activePromptTrace, undefined); assert.equal(harness.pendingPromptTraces.length, 0); + + harness.activePromptTrace = { + commandId: "third", + sessionId: "session", + startedAt: 5, + started: false, + queued: false, + }; + projectEvent.call(harness, session, { type: "agent_start" }); + projectEvent.call(harness, session, userMessage("third")); + assert.deepEqual(harness.activePromptTrace, { + commandId: "third", + sessionId: "session", + startedAt: 5, + started: true, + queued: false, + userMessageObserved: true, + epoch: 2, + }); assert.deepEqual( events .filter((event) => event.type.startsWith("turn_")) @@ -1118,7 +1255,7 @@ test("prompt traces advance with queued user messages", () => { }, { type: "turn_started", - detail: { sessionId: "session", commandId: "second", epoch: 2 }, + detail: { sessionId: "session", commandId: "third", epoch: 2 }, }, ], ); diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index c7c3e1be..457ab6a3 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -49,11 +49,11 @@ type PromptTrace = { queued: boolean; userMessageObserved: boolean; epoch?: number; - outcome?: "completed" | "cancelled" | "failed"; + outcome?: "completed" | "cancelled" | "failed" | "uncertain"; }; type TurnSettlement = WebActiveTurn & { - outcome: "completed" | "cancelled" | "failed"; + outcome: "completed" | "cancelled" | "failed" | "uncertain"; }; function errorText(error: unknown) { @@ -94,6 +94,8 @@ export class PiWebRuntime implements WebRuntimeController { string, Set<(settlement: TurnSettlement) => void> >(); + /** Native aborts remain owned by Pi until its agent_settled event arrives. */ + private readonly turnAbortOperations = new Map>(); private liveMessageKey?: string; private liveMessageSequence = 0; private readonly webSessionDirectory: string; @@ -223,6 +225,13 @@ export class PiWebRuntime implements WebRuntimeController { ) { return { ...options, state: "stale-turn" }; } + if (this.turnAbortOperations.has(key)) { + return { + ...options, + state: "failed", + error: "Cancellation is already waiting for Pi to settle this turn", + }; + } let ownWaiter: ((settlement: TurnSettlement) => void) | undefined; const settlement = new Promise((resolveSettlement) => { @@ -234,6 +243,12 @@ export class PiWebRuntime implements WebRuntimeController { let timeoutHandle: ReturnType | undefined; try { const abortOperation = this.runtime.session.abort(); + this.turnAbortOperations.set(key, abortOperation); + void abortOperation.catch(() => { + if (this.turnAbortOperations.get(key) === abortOperation) { + this.turnAbortOperations.delete(key); + } + }); const abortFailure = new Promise((_, reject) => { void abortOperation.catch(reject); }); @@ -263,7 +278,12 @@ export class PiWebRuntime implements WebRuntimeController { : "failed", ...(terminal.outcome === "failed" ? { error: "The active turn failed while cancellation was requested" } - : {}), + : terminal.outcome === "uncertain" + ? { + error: + "Pi settled without a terminal assistant outcome for this cancellation", + } + : {}), }; } catch (error) { return { ...options, state: "failed", error: errorText(error) }; @@ -836,12 +856,6 @@ export class PiWebRuntime implements WebRuntimeController { if (event.type === "message_start" && event.message.role === "user") { if (!this.activePromptTrace) { this.activePromptTrace = this.pendingPromptTraces.shift(); - } else if ( - this.activePromptTrace.userMessageObserved && - this.pendingPromptTraces.length > 0 - ) { - this.settlePromptTrace(this.activePromptTrace); - this.activePromptTrace = this.pendingPromptTraces.shift(); } if (this.activePromptTrace) { this.startPromptTrace(this.activePromptTrace); @@ -893,13 +907,14 @@ export class PiWebRuntime implements WebRuntimeController { }); break; case "agent_settled": - if ( - this.activePromptTrace?.started && - this.pendingPromptTraces.length === 0 - ) { + // Pi emits this only after the whole agent run (including tool loops + // and admitted follow-ups) has reached a terminal state. A + // message_end is only one model response and must not settle a turn. + if (this.activePromptTrace?.started) { this.settlePromptTrace(this.activePromptTrace); - this.activePromptTrace = undefined; } + this.activePromptTrace = undefined; + this.pendingPromptTraces.length = 0; this.emit(event.type, { sessionId: session.sessionManager.getSessionId(), }); @@ -925,12 +940,21 @@ export class PiWebRuntime implements WebRuntimeController { event.message.role === "assistant" && this.activePromptTrace ) { - this.activePromptTrace.outcome = + // Preserve the terminal model result for classification, but defer + // publication until Pi confirms the entire run is settled. + const outcome = event.message.stopReason === "aborted" ? "cancelled" : event.message.stopReason === "error" ? "failed" : "completed"; + // A later queued continuation must not erase proof that the + // provider result targeted by Stop was aborted. The control remains + // owned until agent_settled; this outcome does not claim that every + // queued follow-up in the same Pi execution was cancelled. + if (this.activePromptTrace.outcome !== "cancelled") { + this.activePromptTrace.outcome = outcome; + } } this.emit(event.type, { message: projectMessage(event.message), @@ -977,11 +1001,13 @@ export class PiWebRuntime implements WebRuntimeController { if (!activeTurn) return; const settlement: TurnSettlement = { ...activeTurn, - outcome: trace.outcome ?? "completed", + outcome: + trace.outcome ?? "uncertain", }; const key = this.turnKey(activeTurn); if (this.terminalTurnKeys.has(key)) return; this.terminalTurnKeys.add(key); + this.turnAbortOperations.delete(key); while (this.terminalTurnKeys.size > 64) { const oldest = this.terminalTurnKeys.values().next().value; if (typeof oldest === "string") this.terminalTurnKeys.delete(oldest); @@ -1001,7 +1027,8 @@ export class PiWebRuntime implements WebRuntimeController { const pendingIndex = this.pendingPromptTraces.indexOf(trace); if (pendingIndex !== -1) this.pendingPromptTraces.splice(pendingIndex, 1); if (this.activePromptTrace !== trace) return; - if (trace.started) this.settlePromptTrace(trace); + // A started trace can only be terminally projected by agent_settled. + if (trace.started) return; this.activePromptTrace = this.pendingPromptTraces.shift(); } @@ -1166,5 +1193,6 @@ export class PiWebRuntime implements WebRuntimeController { private resetPromptTraces() { this.activePromptTrace = undefined; this.pendingPromptTraces.length = 0; + this.turnAbortOperations.clear(); } } diff --git a/web/ui/app.js b/web/ui/app.js index 53f020ea..b5f76615 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -19,6 +19,9 @@ const state = { collapsed: readCollapsedWorkspaces(), liveMessages: [], liveRunning: false, + activeTurn: null, + turnCancellationPending: false, + turnTerminalStatus: null, promptAdmissionPending: false, promptAdmissionToken: null, promptAdmissionSequence: 0, @@ -67,6 +70,9 @@ const translations = { enterHint: "Enter to send, Shift+Enter for a new line.", activeOnlyHint: "Only the active Web session accepts messages.", acceptedHint: "Message accepted by OpenPI Web.", + stopTurn: "Stop turn", + stoppingTurn: "Stopping current turn...", + stoppedTurn: "Current turn stopped.", modelRunning: "Working...", modelPreparing: "Preparing task...", modelRetrying: "Retrying model request...", @@ -103,6 +109,9 @@ const translations = { enterHint: "按 Enter 发送,Shift+Enter 换行。", activeOnlyHint: "只有当前 Web 会话可以接收消息。", acceptedHint: "OpenPI Web 已接收消息。", + stopTurn: "停止当前回合", + stoppingTurn: "正在停止当前回合...", + stoppedTurn: "当前回合已停止。", modelRunning: "正在运行...", modelPreparing: "正在准备任务...", modelRetrying: "模型请求重试中...", @@ -494,6 +503,13 @@ function updateComposer() { !selected && !state.snapshot?.currentSessionId; const canCompose = active || newSessionDraft; + const activeTurn = active + ? state.activeTurn || state.snapshot?.runtime.activeTurn || null + : null; + const canStop = Boolean( + activeTurn && + (state.snapshot?.runtime.status === "running" || state.liveRunning), + ); $("prompt-input").disabled = state.sessionSwitching || (!canCompose && Boolean(state.selectedWorkspace)); $("send-prompt").disabled = @@ -501,6 +517,9 @@ function updateComposer() { !canCompose || !state.selectedWorkspace || state.promptAdmissionPending; + $("send-prompt").hidden = canStop; + $("stop-turn").hidden = !canStop; + $("stop-turn").disabled = state.turnCancellationPending; const modelPicker = $("model-picker"); const modelPickerValue = $("model-picker-value"); const modelMenu = $("model-menu"); @@ -539,11 +558,16 @@ function updateComposer() { : active ? t("promptMessage") : t("promptReadonly"); - $("composer-hint").textContent = canCompose - ? state.snapshot.runtime.status === "running" || state.liveRunning - ? t("queuedHint") - : t("enterHint") - : t("activeOnlyHint"); + $("composer-hint").textContent = + state.turnCancellationPending + ? t("stoppingTurn") + : state.turnTerminalStatus === "cancelled" + ? t("stoppedTurn") + : canCompose + ? state.snapshot.runtime.status === "running" || state.liveRunning + ? t("queuedHint") + : t("enterHint") + : t("activeOnlyHint"); } async function selectModel(value) { @@ -617,6 +641,9 @@ async function refreshSnapshot({ return false; } state.snapshot = snapshot; + if (resetCursor) resetLiveState(); + state.activeTurn = snapshot.runtime.activeTurn || null; + if (snapshot.runtime.status === "running") state.liveRunning = true; if ( state.snapshot.runtime.status !== "running" && !state.promptAdmissionPending @@ -625,7 +652,6 @@ async function refreshSnapshot({ state.livePhase = "idle"; state.liveRetry = null; } - if (resetCursor) resetLiveState(); state.cursor = resetCursor || state.cursor === null ? state.snapshot.cursor : Math.max(state.cursor, state.snapshot.cursor); @@ -750,6 +776,7 @@ async function sendPrompt() { ].slice(-8); state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; + state.turnTerminalStatus = null; renderConversation(); $("composer-hint").classList.remove("error"); try { @@ -783,6 +810,36 @@ async function sendPrompt() { } } +async function cancelActiveTurn() { + const turn = state.activeTurn || state.snapshot?.runtime.activeTurn; + if (!turn || state.turnCancellationPending || state.sessionSwitching) return; + const epoch = state.sessionEpoch; + state.turnCancellationPending = true; + $("composer-hint").classList.remove("error"); + $("composer-hint").textContent = t("stoppingTurn"); + updateComposer(); + try { + const receipt = await api("/api/turns/cancel", { + method: "POST", + body: JSON.stringify(turn), + }); + if (epoch !== state.sessionEpoch) return; + if (receipt.state === "accepted" || receipt.state === "already-settled") { + $("composer-hint").textContent = t("stoppedTurn"); + } + } catch (error) { + if (epoch !== state.sessionEpoch) return; + const message = error.message; + await refreshSnapshot({ epoch }); + if (epoch === state.sessionEpoch) showNotice(message); + } finally { + if (epoch === state.sessionEpoch) { + state.turnCancellationPending = false; + renderConversation(); + } + } +} + function resizePrompt() { const input = $("prompt-input"); const maxHeight = 220; @@ -1080,15 +1137,43 @@ function applyRuntimeEvent(event) { applyPromptAcceptedState(alreadySettled); state.liveRetry = null; renderConversation(); + } else if (event.type === "turn_started") { + state.activeTurn = { + sessionId: event.detail?.sessionId, + commandId: event.detail?.commandId, + epoch: event.detail?.epoch, + }; + state.liveRunning = true; + state.turnTerminalStatus = null; + state.livePhase = "running"; + state.liveRetry = null; + renderConversation(); } else if (event.type === "agent_start") { + if (event.detail?.activeTurn) state.activeTurn = event.detail.activeTurn; state.liveRunning = true; state.livePhase = "running"; state.liveRetry = null; renderConversation(); + } else if (event.type === "turn_settled") { + rememberTerminalPrompt(event.detail?.commandId); + const isActiveTurn = + state.activeTurn?.sessionId === event.detail?.sessionId && + state.activeTurn?.commandId === event.detail?.commandId && + state.activeTurn?.epoch === event.detail?.epoch; + if (isActiveTurn) { + state.activeTurn = null; + state.liveRunning = false; + state.livePhase = "idle"; + state.liveRetry = null; + state.turnTerminalStatus = event.detail?.outcome || null; + } + renderConversation(); } else if (event.type === "agent_settled") { - state.liveRunning = false; - state.livePhase = "idle"; - state.liveRetry = null; + if (!state.activeTurn) { + state.liveRunning = false; + state.livePhase = "idle"; + state.liveRetry = null; + } renderConversation(); } else if (event.type === "prompt_settled") { rememberTerminalPrompt(event.detail?.commandId); @@ -1117,6 +1202,8 @@ function applyRuntimeEvent(event) { [ "agent_start", "agent_settled", + "turn_started", + "turn_settled", "prompt_settled", "message_end", "tool_execution_end", @@ -1180,6 +1267,9 @@ let eventLoopStarted = false; function resetLiveState() { state.liveMessages = []; state.liveRunning = false; + state.activeTurn = null; + state.turnCancellationPending = false; + state.turnTerminalStatus = null; state.livePhase = "idle"; state.liveRetry = null; } @@ -1401,6 +1491,9 @@ $("composer")?.addEventListener("submit", (event) => { if (state.selectedWorkspace) void sendPrompt(); else void chooseWorkspace(); }); +$("stop-turn")?.addEventListener("click", () => { + void cancelActiveTurn(); +}); $("prompt-input")?.addEventListener("input", resizePrompt); $("prompt-input")?.addEventListener("keydown", (event) => { if (event.isComposing || event.keyCode === 229) return; From d1360db430c1aee1bc7b127747c316437b4f3973 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:31:11 +0800 Subject: [PATCH 11/16] fix(web): classify tool-use responses as uncertain --- tests/web/pi-runtime.test.ts | 68 ++++++++++++++++++++++++++++++++++++ web/runtime/pi-runtime.ts | 7 ++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index b7ef155b..def9743b 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -1260,3 +1260,71 @@ test("message_end and queued prompts do not settle a running turn", () => { ], ); }); + +test("toolUse message_end without a terminal result settles as uncertain", () => { + const session = { sessionManager: { getSessionId: () => "session" } }; + const harness = Object.create(PiWebRuntime.prototype) as RuntimeHarness; + harness.runtime = { session }; + harness.pendingPromptTraces = []; + harness.liveMessageSequence = 0; + harness.listeners = new Set(); + harness.nextTurnEpoch = 0; + harness.terminalTurnKeys = new Set(); + harness.turnSettlementWaiters = new Map(); + harness.turnAbortOperations = new Map(); + const events: WebRuntimeEvent[] = []; + harness.listeners.add((event) => events.push(event)); + harness.activePromptTrace = { + commandId: "tool-use", + sessionId: "session", + startedAt: 1, + started: false, + queued: false, + }; + + const projectEvent = ( + PiWebRuntime.prototype as unknown as { + projectEvent(this: RuntimeHarness, session: object, event: object): void; + } + ).projectEvent; + + projectEvent.call(harness, session, { type: "agent_start" }); + projectEvent.call(harness, session, { + type: "message_end", + message: { + role: "assistant", + content: [], + stopReason: "toolUse", + timestamp: 2, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + }, + }); + projectEvent.call(harness, session, { type: "agent_settled" }); + + assert.deepEqual( + events + .filter((event) => event.type === "turn_settled") + .map((event) => event.detail), + [ + { + sessionId: "session", + commandId: "tool-use", + epoch: 1, + outcome: "uncertain", + }, + ], + ); +}); diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 457ab6a3..b1cb8293 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -947,12 +947,15 @@ export class PiWebRuntime implements WebRuntimeController { ? "cancelled" : event.message.stopReason === "error" ? "failed" - : "completed"; + : event.message.stopReason === "stop" || + event.message.stopReason === "length" + ? "completed" + : undefined; // A later queued continuation must not erase proof that the // provider result targeted by Stop was aborted. The control remains // owned until agent_settled; this outcome does not claim that every // queued follow-up in the same Pi execution was cancelled. - if (this.activePromptTrace.outcome !== "cancelled") { + if (outcome && this.activePromptTrace.outcome !== "cancelled") { this.activePromptTrace.outcome = outcome; } } From ebb18dcc68eb348c2dc5a2577ee7a4a780081791 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:21:13 +0800 Subject: [PATCH 12/16] fix(web): derive queue receipts from Pi follow-up updates --- tests/web/app-render.test.ts | 46 +++++++++++++++++++ tests/web/pi-adapter.test.ts | 2 +- tests/web/pi-runtime.test.ts | 74 +++++++++++++++++++++++++++--- tests/web/web-host.test.ts | 87 ++++++++++++++++++++++++++++++------ web/host/web-host.ts | 8 ++-- web/runtime/pi-runtime.ts | 27 ++++------- web/runtime/types.ts | 3 +- web/ui/app.js | 29 +++++++++--- web/ui/styles.css | 1 + 9 files changed, 226 insertions(+), 51 deletions(-) diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index 46848da0..4e490b58 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -919,6 +919,52 @@ test("app.js keeps an active agent running when its prompt receipt settles late" assert.equal(app.state.livePhase, "running"); }); +test("app.js reports an admitted native follow-up queue snapshot", async () => { + const app = await renderApp(); + app.context.fetch = async (url: unknown) => { + if (String(url) === "/api/prompt") { + return response({ + id: "received", + accepted: true, + pendingFollowUps: 2, + }); + } + if (String(url).startsWith("/api/snapshot")) return response(SNAPSHOT); + throw new Error(`unexpected request: ${String(url)}`); + }; + const input = app.elements.get("prompt-input"); + assert.ok(input); + input.value = "queue me"; + + await app.sendPrompt(); + + assert.match( + app.elements.get("composer-hint")?.textContent || "", + /2 follow-up messages were waiting when it was received/, + ); +}); + +test("app.js reports acceptance without a queue count when none are pending", async () => { + const app = await renderApp(); + app.context.fetch = async (url: unknown) => { + if (String(url) === "/api/prompt") { + return response({ id: "received", accepted: true, pendingFollowUps: 0 }); + } + if (String(url).startsWith("/api/snapshot")) return response(SNAPSHOT); + throw new Error(`unexpected request: ${String(url)}`); + }; + const input = app.elements.get("prompt-input"); + assert.ok(input); + input.value = "receive me"; + + await app.sendPrompt(); + + assert.equal( + app.elements.get("composer-hint")?.textContent, + "Message accepted by OpenPI Web.", + ); +}); + test("app.js scopes model selection to its session epoch", async () => { const app = await renderApp(); const model = deferred>(); diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index c17af4a3..f2072112 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -29,7 +29,7 @@ function runtimeFor( sessionDirectory, sessionManager, isIdle: () => true, - sendPrompt: async () => ({ queued: false, queuePosition: 0 }), + sendPrompt: async () => ({ pendingFollowUps: 0 }), newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), listModels: () => [], diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index cb8a3a0c..917ac16c 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -48,13 +48,35 @@ function promptSession(sessionId: string) { options: PromptOptions; run: ReturnType; }> = []; + const listeners = new Set< + (event: { + type: "queue_update"; + steering: string[]; + followUp: string[]; + }) => void + >(); + let followUpMessages: string[] = []; return { isStreaming: false, pendingMessageCount: 0, sessionManager: { getSessionId: () => sessionId }, abort: async (): Promise => undefined, - subscribe() { - return () => undefined; + subscribe( + listener: (event: { + type: "queue_update"; + steering: string[]; + followUp: string[]; + }) => void, + ) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getFollowUpMessages: () => followUpMessages, + emitFollowUpQueue(messages: string[]) { + followUpMessages = messages; + for (const listener of listeners) { + listener({ type: "queue_update", steering: [], followUp: messages }); + } }, prompt(content: string, options: PromptOptions) { const run = deferred(); @@ -262,24 +284,44 @@ test("prompt admission waits for Pi preflight acceptance", async () => { assert.equal(settled, false); session.calls[0].options.preflightResult?.(true); - assert.deepEqual(await admission, { queued: false, queuePosition: 0 }); + assert.deepEqual(await admission, { pendingFollowUps: 0 }); assert.equal(settled, true); session.calls[0].run.resolve(); await Promise.resolve(); }); -test("prompt admission reports the canonical queue position", async () => { +test("prompt admission snapshots Pi follow-up messages", async () => { + const session = promptSession("session-a"); + session.isStreaming = true; + const runtime = promptHarness(session); + const admission = runtime.sendPrompt("queued", { + commandId: "command-queued", + expectedSessionId: "session-a", + }); + await Promise.resolve(); + session.emitFollowUpQueue(["queued"]); + session.calls[0].options.preflightResult?.(true); + assert.deepEqual(await admission, { pendingFollowUps: 1 }); + session.calls[0].run.resolve(); + await Promise.resolve(); +}); + +test("prompt admission snapshots a follow-up queue that shrinks before it grows", async () => { const session = promptSession("session-a"); session.isStreaming = true; + session.emitFollowUpQueue(["already pending"]); const runtime = promptHarness(session); const admission = runtime.sendPrompt("queued", { commandId: "command-queued", expectedSessionId: "session-a", }); await Promise.resolve(); + + session.emitFollowUpQueue([]); + session.emitFollowUpQueue(["queued"]); session.calls[0].options.preflightResult?.(true); - assert.deepEqual(await admission, { queued: true, queuePosition: 1 }); + assert.deepEqual(await admission, { pendingFollowUps: 1 }); session.calls[0].run.resolve(); await Promise.resolve(); }); @@ -300,16 +342,34 @@ test("prompt admission observes streaming after an earlier admission gate", asyn session.calls[0].options.preflightResult?.(true); session.isStreaming = true; - assert.deepEqual(await first, { queued: false, queuePosition: 0 }); + assert.deepEqual(await first, { pendingFollowUps: 0 }); session.calls[0].run.resolve(); await Promise.resolve(); + session.emitFollowUpQueue(["second"]); session.calls[1].options.preflightResult?.(true); - assert.deepEqual(await second, { queued: true, queuePosition: 1 }); + assert.deepEqual(await second, { pendingFollowUps: 1 }); session.calls[1].run.resolve(); await Promise.resolve(); }); +test("handled input snapshots an externally pending follow-up without claiming ownership", async () => { + const session = promptSession("session-a"); + session.isStreaming = true; + const runtime = promptHarness(session); + const admission = runtime.sendPrompt("/handled", { + commandId: "command-handled", + expectedSessionId: "session-a", + }); + await Promise.resolve(); + + session.emitFollowUpQueue(["external delivery"]); + session.calls[0].options.preflightResult?.(true); + assert.deepEqual(await admission, { pendingFollowUps: 1 }); + session.calls[0].run.resolve(); + await Promise.resolve(); +}); + test("prompt preflight rejection is a typed non-admission", async () => { const session = promptSession("session-a"); const runtime = promptHarness(session); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 6eeffb52..a732c021 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -52,7 +52,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn isIdle: () => false, sendPrompt: async (content) => { prompts.push(content); - return { queued: false, queuePosition: 0 }; + return { pendingFollowUps: 0 }; }, newSession: async (workspacePath, options) => { newSessions++; @@ -621,7 +621,7 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", isIdle: () => true, sendPrompt: async () => { prompts++; - return { queued: false, queuePosition: 0 }; + return { pendingFollowUps: 0 }; }, newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), @@ -707,7 +707,7 @@ test("returns accepted only after Pi admits the prompt", async () => { sendPrompt: async () => { promptStarted = true; await promptAdmitted; - return { queued: false, queuePosition: 0 }; + return { pendingFollowUps: 0 }; }, newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), @@ -750,12 +750,10 @@ test("returns accepted only after Pi admits the prompt", async () => { assert.equal(response.status, 202); const responseBody = (await response.json()) as { accepted: boolean; - queued: boolean; - queuePosition: number; + pendingFollowUps: number; }; assert.equal(responseBody.accepted, true); - assert.equal(responseBody.queued, false); - assert.equal(responseBody.queuePosition, 0); + assert.equal(responseBody.pendingFollowUps, 0); } finally { resolvePrompt(); await host.stop(); @@ -767,8 +765,7 @@ test("returns accepted only after Pi admits the prompt", async () => { function testRuntime( cwd: string, sendPrompt: WebRuntimeController["sendPrompt"] = async () => ({ - queued: false, - queuePosition: 0, + pendingFollowUps: 0, }), ) { const sessionManager = SessionManager.inMemory(cwd); @@ -958,7 +955,7 @@ async function readEventRecords(response: Response, count: number) { let buffer = ""; const records: Array<{ id: number; - event: { sequence: number; type: string }; + event: { sequence: number; type: string; detail?: Record }; }> = []; while (records.length < count) { const chunk = await reader.read(); @@ -978,7 +975,11 @@ async function readEventRecords(response: Response, count: number) { if (!id || !data) continue; records.push({ id: Number(id), - event: JSON.parse(data) as { sequence: number; type: string }, + event: JSON.parse(data) as { + sequence: number; + type: string; + detail?: Record; + }, }); } } @@ -1017,6 +1018,66 @@ test("rejects prompt admission with the runtime's typed receipt", async () => { } }); +test("returns and publishes the observed follow-up queue receipt", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-prompt-queue-")); + const runtime = testRuntime(cwd, async () => ({ + pendingFollowUps: 2, + })); + const { host, launched, headers } = await startTestHost(runtime); + try { + const snapshot = (await ( + await fetch(`${launched.origin}/api/snapshot`, { headers }) + ).json()) as { cursor: number }; + const response = await fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: runtime.sessionManager.getSessionId(), + content: "queue me", + }), + }); + assert.equal(response.status, 202); + const receipt = (await response.json()) as { + id: string; + accepted: boolean; + state: string; + pendingFollowUps: number; + cursor: number; + }; + assert.match(receipt.id, /^[0-9a-f-]{36}$/u); + assert.deepEqual( + { ...receipt, id: undefined }, + { + id: undefined, + accepted: true, + state: "accepted", + pendingFollowUps: 2, + cursor: snapshot.cursor + 1, + }, + ); + const events = await readEventRecords( + await fetch(`${launched.origin}/events?cursor=${snapshot.cursor}`, { + headers, + }), + 1, + ); + assert.equal(events[0].event.sequence, snapshot.cursor + 1); + assert.equal(events[0].event.type, "prompt_accepted"); + assert.match(String(events[0].event.detail?.commandId), /^[0-9a-f-]{36}$/u); + assert.deepEqual( + { ...events[0].event.detail, commandId: undefined }, + { + commandId: undefined, + sessionId: runtime.sessionManager.getSessionId(), + pendingFollowUps: 2, + }, + ); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("replays only events after an exact SSE cursor with event ids", async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-sse-")); const { host, launched, headers } = await startTestHost(testRuntime(cwd)); @@ -1352,7 +1413,7 @@ test("stop rejects a late keepalive mutation before it enters the drain", async const runtime = testRuntime(cwd, async () => { promptStarted(); await promptBarrier; - return { queued: false, queuePosition: 0 }; + return { pendingFollowUps: 0 }; }); runtime.dispose = async () => { releasePrompt(); @@ -1537,7 +1598,7 @@ test("stop disposes the runtime before waiting for an in-flight prompt request", const runtime = testRuntime(cwd, async () => { promptStarted(); await pendingPrompt; - return { queued: false, queuePosition: 0 }; + return { pendingFollowUps: 0 }; }); runtime.dispose = async () => { disposeCalls++; diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 40b91c27..5b40dfc2 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -542,7 +542,7 @@ export class WebHost { sessionId: body.sessionId, chars: content.length, }); - let admission: { queued: boolean; queuePosition: number }; + let admission: { pendingFollowUps: number }; try { admission = await this.runtime.sendPrompt(content, { commandId, @@ -567,8 +567,7 @@ export class WebHost { this.publish("prompt_accepted", { commandId, sessionId: body.sessionId, - queuePosition: admission.queuePosition, - queued: admission.queued, + pendingFollowUps: admission.pendingFollowUps, }); traceWeb("prompt_response_sent", { commandId, @@ -579,8 +578,7 @@ export class WebHost { id: commandId, accepted: true, state: "accepted", - queued: admission.queued, - queuePosition: admission.queuePosition, + pendingFollowUps: admission.pendingFollowUps, cursor: this.sequence, }); } diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 0a7ce40a..45eb2516 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -302,20 +302,18 @@ export class PiWebRuntime implements WebRuntimeController { let admitted = false; let agentLifecycleStarted = false; let queuedForAgent = false; - let queued = false; let promptTrace: PromptTrace | undefined; let unsubscribePromptLifecycle: (() => void) | undefined; try { await previousAdmission; this.assertActive(); - queued = session.isStreaming; promptTrace = options?.commandId ? { commandId: options.commandId, sessionId, startedAt, started: false, - queued, + queued: false, } : undefined; if (promptTrace && agentRuntime === this.runtime) { @@ -336,14 +334,15 @@ export class PiWebRuntime implements WebRuntimeController { elapsedMs: elapsed(startedAt), }); } - const pendingMessagesBefore = session.pendingMessageCount; + let followUpMessages = session.getFollowUpMessages().length; unsubscribePromptLifecycle = session.subscribe((event) => { if (event.type === "agent_start") agentLifecycleStarted = true; - if ( - event.type === "queue_update" && - event.steering.length + event.followUp.length > pendingMessagesBefore - ) { - queuedForAgent = true; + if (event.type === "queue_update") { + if (event.followUp.length > followUpMessages) { + queuedForAgent = true; + if (promptTrace) promptTrace.queued = true; + } + followUpMessages = event.followUp.length; } }); await session.prompt(content, { @@ -368,16 +367,8 @@ export class PiWebRuntime implements WebRuntimeController { ); } if (accepted) { - const queuePosition = queued - ? Math.max( - 1, - this.pendingPromptTraces.length + - (this.activePromptTrace ? 1 : 0), - ) - : 0; resolveRequest({ - queued, - queuePosition, + pendingFollowUps: session.getFollowUpMessages().length, }); } else { rejectRequest( diff --git a/web/runtime/types.ts b/web/runtime/types.ts index f90814ea..97559320 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -34,8 +34,7 @@ export interface WebPromptOptions { } export interface WebPromptAdmissionReceipt { - queued: boolean; - queuePosition: number; + pendingFollowUps: number; } export interface WebModelSelectionOptions { diff --git a/web/ui/app.js b/web/ui/app.js index 53f020ea..47b1173b 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -30,6 +30,7 @@ const state = { snapshotGeneration: 0, livePhase: "idle", liveRetry: null, + pendingFollowUpsReceipt: null, query: "", selectedWorkspace: null, language: navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en", @@ -67,6 +68,7 @@ const translations = { enterHint: "Enter to send, Shift+Enter for a new line.", activeOnlyHint: "Only the active Web session accepts messages.", acceptedHint: "Message accepted by OpenPI Web.", + pendingFollowUpsHint: "Message received; {count} follow-up messages were waiting when it was received.", modelRunning: "Working...", modelPreparing: "Preparing task...", modelRetrying: "Retrying model request...", @@ -103,6 +105,7 @@ const translations = { enterHint: "按 Enter 发送,Shift+Enter 换行。", activeOnlyHint: "只有当前 Web 会话可以接收消息。", acceptedHint: "OpenPI Web 已接收消息。", + pendingFollowUpsHint: "消息已接收;接收时有 {count} 条后续消息等待处理。", modelRunning: "正在运行...", modelPreparing: "正在准备任务...", modelRetrying: "模型请求重试中...", @@ -539,11 +542,20 @@ function updateComposer() { : active ? t("promptMessage") : t("promptReadonly"); - $("composer-hint").textContent = canCompose - ? state.snapshot.runtime.status === "running" || state.liveRunning - ? t("queuedHint") - : t("enterHint") - : t("activeOnlyHint"); + const composerHint = $("composer-hint"); + composerHint.classList.toggle("receipt", state.pendingFollowUpsReceipt > 0); + composerHint.textContent = state.pendingFollowUpsReceipt !== null + ? state.pendingFollowUpsReceipt > 0 + ? t("pendingFollowUpsHint").replace( + "{count}", + String(state.pendingFollowUpsReceipt), + ) + : t("acceptedHint") + : canCompose + ? state.snapshot.runtime.status === "running" || state.liveRunning + ? t("queuedHint") + : t("enterHint") + : t("activeOnlyHint"); } async function selectModel(value) { @@ -750,6 +762,7 @@ async function sendPrompt() { ].slice(-8); state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; + state.pendingFollowUpsReceipt = null; renderConversation(); $("composer-hint").classList.remove("error"); try { @@ -760,6 +773,7 @@ async function sendPrompt() { if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return; const alreadySettled = state.terminalPromptIds.has(receipt.id); applyPromptAcceptedState(alreadySettled); + state.pendingFollowUpsReceipt = receipt.pendingFollowUps; $("prompt-input").value = ""; resizePrompt(); $("composer-hint").textContent = t("acceptedHint"); @@ -1078,6 +1092,9 @@ function applyRuntimeEvent(event) { } else if (event.type === "prompt_accepted") { const alreadySettled = state.terminalPromptIds.has(event.detail?.commandId); applyPromptAcceptedState(alreadySettled); + state.pendingFollowUpsReceipt = Number.isInteger(event.detail?.pendingFollowUps) + ? event.detail.pendingFollowUps + : state.pendingFollowUpsReceipt; state.liveRetry = null; renderConversation(); } else if (event.type === "agent_start") { @@ -1086,6 +1103,7 @@ function applyRuntimeEvent(event) { state.liveRetry = null; renderConversation(); } else if (event.type === "agent_settled") { + state.pendingFollowUpsReceipt = null; state.liveRunning = false; state.livePhase = "idle"; state.liveRetry = null; @@ -1182,6 +1200,7 @@ function resetLiveState() { state.liveRunning = false; state.livePhase = "idle"; state.liveRetry = null; + state.pendingFollowUpsReceipt = null; } async function connectEvents() { diff --git a/web/ui/styles.css b/web/ui/styles.css index 845eaff3..02021fdc 100644 --- a/web/ui/styles.css +++ b/web/ui/styles.css @@ -507,6 +507,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .send-button:disabled { background: var(--warm-accent-disabled); color: var(--subtle); } .send-button svg { width: 17px; height: 17px; stroke-width: 2; } .composer-hint { display: none; } +.composer-hint.receipt { display: block; color: var(--subtle); } .composer-hint.error { color: var(--error); } .sidebar-scrim { display: none; } From 86d9a6e666b12d004f2d93e106140b8569e1d4be Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:33:37 +0800 Subject: [PATCH 13/16] docs(web): clarify stop identity lifetime --- docs/development/OPENPI_WEB_DEVELOPMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/OPENPI_WEB_DEVELOPMENT.md b/docs/development/OPENPI_WEB_DEVELOPMENT.md index c8a48eeb..2ea11ab0 100644 --- a/docs/development/OPENPI_WEB_DEVELOPMENT.md +++ b/docs/development/OPENPI_WEB_DEVELOPMENT.md @@ -53,7 +53,7 @@ bun run dev:web -- /absolute/path/to/workspace ## 活动回合取消协议 -Web 的 Stop 请求 Pi 停止当前 agent execution,不等同于停止 Host,也不会清空已经排队的 follow-up。取消请求必须回传当前快照或 `turn_started` 事件给出的 `sessionId`、`commandId` 和 `epoch`;Runtime 在自己的串行 mutation 边界内重新核对三者,再调用 Pi 原生 `AgentSession.abort()`。这不是“一条输入一个回合”的额外队列:Pi 可以在同一次 execution 中继续处理已排队的 follow-up;只有下一次真实 `agent_start` 才会取得新的 Stop identity。 +Web 的 Stop 请求 Pi 停止当前 agent execution,不等同于停止 Host,也不会清空已经排队的 follow-up。取消请求必须回传当前快照或 `turn_started` 事件给出的 `sessionId`、`commandId` 和 `epoch`;Runtime 在自己的串行 mutation 边界内重新核对三者,再调用 Pi 原生 `AgentSession.abort()`。这不是“一条输入一个回合”的额外队列:Pi 可以在同一次 execution 中继续处理已排队的 follow-up;当前 execution 终结后,下一次 execution 的真实 `agent_start` 才会取得新的 Stop identity;同一 execution 内的 retry 或 continue 保留原 identity。 Host 返回 `accepted`、`already-settled`、`stale-session`、`stale-turn` 或 `failed` 的明确收据。浏览器不会因点击按钮而乐观结束运行态;只有 Pi 的完整 execution 发出 `agent_settled`,并且其中有被 Stop 目标对应的 assistant 结果 `stopReason: "aborted"`,才投影为 `turn_settled(outcome: "cancelled")`。这个 outcome 只描述被请求停止的 provider 结果,不概括同一次 execution 中 Pi 随后处理的 follow-up 是否成功。单条 `message_end` 只提供结果证据,不能单独结束 execution;若 Pi settled 时没有终态 assistant 证据,Runtime 投影 `uncertain` 并返回 `failed`,不会猜测取消成功。活动回合身份也包含在快照中,因此刷新和 SSE 重连仍能恢复正确的 Stop 控件。多客户端的旧请求不能取消更新的回合,重复请求则按已终结回合幂等返回。 From 8b0cab6aa343c1c337f64347339ae4cd63a8b93e Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:34:58 +0800 Subject: [PATCH 14/16] fix(web): preserve prompt admission retries --- tests/web/app-render.test.ts | 165 ++++++++++++++++++++++++- tests/web/web-host.test.ts | 226 ++++++++++++++++++++++++++++++++--- web/host/web-host.ts | 222 ++++++++++++++++++++++++---------- web/ui/app.js | 99 ++++++++++++--- 4 files changed, 609 insertions(+), 103 deletions(-) diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index 3f5e1665..fb8d871c 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -1,8 +1,15 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import test from "node:test"; +import test, { afterEach } from "node:test"; import vm from "node:vm"; +const appDisposers = new Set<() => Promise>(); + +afterEach(async () => { + await Promise.all([...appDisposers].map((dispose) => dispose())); + appDisposers.clear(); +}); + /** * Executes web/ui/app.js in a stubbed DOM and feeds it a representative * session snapshot. Source-text regex assertions in web-host.test.ts cannot @@ -291,6 +298,10 @@ async function renderApp( setItem: (key: string, value: string) => stored.set(key, value), }; const replaced: string[] = []; + const windowListeners = new Map< + string, + Array<(event?: Record) => void> + >(); let eventFetches = 0; let snapshotFetches = 0; let readerCancellations = 0; @@ -313,6 +324,7 @@ async function renderApp( eventFetches++; const connectionEvents = eventFetches === 1 ? encodedEvents : []; let index = 0; + let resolveRead: ((value: { done: boolean }) => void) | undefined; return { ok: true, status: 200, @@ -320,6 +332,7 @@ async function renderApp( getReader: () => ({ cancel: async () => { readerCancellations++; + resolveRead?.({ done: true }); }, read: () => index < connectionEvents.length @@ -327,7 +340,9 @@ async function renderApp( done: false, value: connectionEvents[index++], }) - : new Promise(() => {}), + : new Promise((resolve) => { + resolveRead = resolve; + }), }), }, }; @@ -358,6 +373,15 @@ async function renderApp( clearInterval, setTimeout, clearTimeout, + addEventListener( + type: string, + listener: (event?: Record) => void, + ) { + windowListeners.set(type, [ + ...(windowListeners.get(type) ?? []), + listener, + ]); + }, }; context.globalThis = context; vm.createContext(context as vm.Context); @@ -375,6 +399,17 @@ async function renderApp( ); vm.runInContext(source, context as vm.Context, { filename: "app.js" }); await new Promise((resolve) => setTimeout(resolve, 200)); + const dispatchWindowEvent = ( + type: string, + event?: Record, + ) => { + for (const listener of windowListeners.get(type) ?? []) listener(event); + }; + const dispose = async () => { + dispatchWindowEvent("pagehide", { persisted: false }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + appDisposers.add(dispose); return { elements, stored, @@ -424,6 +459,15 @@ async function renderApp( resetCursor?: boolean; epoch?: number; }) => Promise, + dispose, + suspendForPageCache: async () => { + dispatchWindowEvent("pagehide", { persisted: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }, + resumeFromPageCache: async () => { + dispatchWindowEvent("pageshow", { persisted: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }, }; } @@ -485,6 +529,14 @@ test("app.js uses quiet-stream heartbeats for bounded snapshot recovery", async assert.equal(app.state.cursor, SNAPSHOT.cursor); }); +test("app.js keeps its one SSE loop across a back-forward cache restore", async () => { + const app = await renderApp(); + assert.equal(app.eventFetches(), 1); + await app.suspendForPageCache(); + await app.resumeFromPageCache(); + assert.equal(app.eventFetches(), 1); +}); + test("app.js bounds API waits and explains duplicate prompt admission", async () => { const app = await renderApp(); app.context.fetch = async ( @@ -518,6 +570,100 @@ test("app.js bounds API waits and explains duplicate prompt admission", async () ); }); +test("app.js retries a timed-out admission with the exact same command", async () => { + const app = await renderApp(); + const input = app.elements.get("prompt-input"); + assert.ok(input); + const requests: Array> = []; + ( + app.context.window as { + setTimeout( + callback: () => void, + delay: number, + ): ReturnType; + } + ).setTimeout = (callback: () => void, delay: number) => + setTimeout(callback, delay === 30_000 ? 1 : delay); + app.context.fetch = ( + url: unknown, + options?: { + body?: string; + signal?: AbortSignal; + }, + ) => { + if (String(url) === "/api/prompt") { + requests.push(JSON.parse(options?.body || "{}")); + if (requests.length === 1) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + } + return Promise.resolve( + response({ id: requests[0]?.commandId, accepted: true }), + ); + } + if (String(url).startsWith("/api/snapshot")) + return Promise.resolve(response(SNAPSHOT)); + throw new Error(`unexpected request: ${String(url)}`); + }; + + input.value = "admit this once"; + await app.sendPrompt(); + assert.equal(app.state.promptAdmissionPending, false); + assert.ok(app.state.promptAdmission); + + await app.sendPrompt(); + assert.equal(requests.length, 2); + assert.deepEqual(requests[0], { + sessionId: "s1", + content: "admit this once", + commandId: requests[0]?.commandId, + retry: false, + }); + assert.deepEqual(requests[1], { + ...requests[0], + retry: true, + }); + assert.equal(app.state.promptAdmission, null); +}); + +test("app.js preserves an uncertain transport admission without resetting an active turn", async () => { + const app = await renderApp(); + const input = app.elements.get("prompt-input"); + assert.ok(input); + const requests: Array> = []; + app.context.fetch = (url: unknown, options?: { body?: string }) => { + if (String(url) === "/api/prompt") { + requests.push(JSON.parse(options?.body || "{}")); + if (requests.length === 1) + return Promise.reject(new TypeError("network dropped")); + return Promise.resolve( + response({ id: requests[0]?.commandId, accepted: true }), + ); + } + if (String(url).startsWith("/api/snapshot")) + return Promise.resolve(response(SNAPSHOT)); + throw new Error(`unexpected request: ${String(url)}`); + }; + app.state.liveRunning = true; + app.state.livePhase = "running"; + input.value = "do not duplicate this"; + + await app.sendPrompt(); + assert.equal(app.state.livePhase, "running"); + assert.ok(app.state.promptAdmission); + + await app.sendPrompt(); + assert.equal(requests.length, 2); + assert.equal(requests[0]?.commandId, requests[1]?.commandId); + assert.equal(requests[0]?.retry, false); + assert.equal(requests[1]?.retry, true); +}); + test("app.js invalidates snapshots for cross-tab session metadata events", async () => { const app = await renderApp({ eventRecords: [ @@ -1045,7 +1191,12 @@ test("app.js accepts an unbound snapshot and preserves a chosen workspace throug activated.selectedSession.cwd = chosenPath; let currentSnapshot: SnapshotFixture = chosen; let sessionCreations = 0; - const prompts: Array<{ sessionId: string; content: string }> = []; + const prompts: Array<{ + sessionId: string; + content: string; + commandId?: string; + retry?: boolean; + }> = []; app.context.fetch = async (url: unknown, options?: { body?: string }) => { if (String(url) === "/api/workspaces/select") { return response({ cancelled: false, path: chosenPath }); @@ -1084,7 +1235,13 @@ test("app.js accepts an unbound snapshot and preserves a chosen workspace throug await new Promise((resolve) => setTimeout(resolve, 0)); } assert.equal(sessionCreations, 1); - assert.deepEqual(prompts, [{ sessionId: "s1", content: "first task" }]); + assert.equal(prompts.length, 1); + assert.deepEqual(prompts[0], { + sessionId: "s1", + content: "first task", + commandId: prompts[0]?.commandId, + retry: false, + }); assert.equal(app.state.selectedWorkspace, chosenPath); assert.equal(app.state.selectedPath, "/tmp/s1.jsonl"); assert.equal(input.value, ""); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 1a9f579f..693a53ab 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -916,12 +916,15 @@ test("keeps unexpected Web Host failures classified as server errors", async () } }); -test("quiet SSE clients receive heartbeats without advancing the event cursor", async () => { +test("quiet SSE clients receive heartbeats without advancing the event cursor", { + timeout: 2_000, +}, async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-heartbeat-")); const host = new WebHost({ runtime: testRuntime(cwd), sseHeartbeatMs: 10, }); + let request: ReturnType | undefined; try { await host.start(); const launched = new URL(host.url); @@ -931,26 +934,40 @@ test("quiet SSE clients receive heartbeats without advancing the event cursor", const before = (await ( await fetch(`${launched.origin}/api/snapshot`, { headers }) ).json()) as { cursor: number }; - const response = await fetch( - `${launched.origin}/events?cursor=${before.cursor}`, - { headers }, - ); - assert.equal(response.status, 200); - assert.ok(response.body); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let received = ""; - while (!received.includes(": heartbeat\\n\\n")) { - const chunk = await reader.read(); - assert.equal(chunk.done, false); - received += decoder.decode(chunk.value, { stream: true }); - } + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("timed out waiting for an SSE heartbeat")), + 1_000, + ); + request = httpRequest( + { + hostname: launched.hostname, + port: Number(launched.port), + path: `/events?cursor=${before.cursor}`, + headers, + }, + (response) => { + assert.equal(response.statusCode, 200); + response.setEncoding("utf8"); + let received = ""; + response.on("data", (chunk: string) => { + received += chunk; + if (!received.includes(": heartbeat\n\n")) return; + clearTimeout(timeout); + resolve(); + }); + response.once("error", reject); + }, + ); + request.once("error", reject); + request.end(); + }); const after = (await ( await fetch(`${launched.origin}/api/snapshot`, { headers }) ).json()) as { cursor: number }; assert.equal(after.cursor, before.cursor); - await reader.cancel(); } finally { + request?.destroy(); await host.stop(); await rm(cwd, { recursive: true, force: true }); } @@ -1020,7 +1037,9 @@ test("rejects prompt admission with the runtime's typed receipt", async () => { "PROMPT_REJECTED", 422, ); + let sendCalls = 0; const runtime = testRuntime(cwd, async () => { + sendCalls++; throw rejection; }); const { host, launched, headers } = await startTestHost(runtime); @@ -1031,6 +1050,8 @@ test("rejects prompt admission with the runtime's typed receipt", async () => { body: JSON.stringify({ sessionId: runtime.sessionManager.getSessionId(), content: "reject me", + commandId: "rejected-admission", + retry: false, }), }); assert.equal(response.status, 422); @@ -1038,7 +1059,180 @@ test("rejects prompt admission with the runtime's typed receipt", async () => { code: "PROMPT_REJECTED", error: "Pi rejected this prompt", }); + const replay = await fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: runtime.sessionManager.getSessionId(), + content: "reject me", + commandId: "rejected-admission", + retry: true, + }), + }); + assert.equal(replay.status, 422); + assert.deepEqual(await replay.json(), { + code: "PROMPT_REJECTED", + error: "Pi rejected this prompt", + }); + assert.equal(sendCalls, 1); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("replays one prompt admission after a browser timeout", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-prompt-retry-")); + let sendCalls = 0; + let releaseAdmission!: () => void; + const admitted = new Promise((resolve) => { + releaseAdmission = resolve; + }); + const runtime = testRuntime(cwd, async () => { + sendCalls++; + await admitted; + }); + const { host, launched, headers } = await startTestHost(runtime); + const commandId = "browser-timeout-retry"; + const prompt = { + sessionId: runtime.sessionManager.getSessionId(), + content: "send this exactly once", + commandId, + }; + try { + const abort = new AbortController(); + const timedOut = fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ ...prompt, retry: false }), + signal: abort.signal, + }); + while (sendCalls === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + abort.abort(); + await assert.rejects(timedOut, /abort/u); + // The host can accept after the client loses its receipt. The retry must + // replay that completed admission rather than dispatch it again. + releaseAdmission(); + while ( + ( + (await ( + await fetch(`${launched.origin}/api/snapshot`, { headers }) + ).json()) as { cursor: number } + ).cursor < 2 + ) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + + const replay = fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ ...prompt, retry: true }), + }); + const response = await replay; + assert.equal(response.status, 202); + assert.deepEqual(await response.json(), { + id: commandId, + accepted: true, + state: "accepted", + cursor: 2, + }); + assert.equal(sendCalls, 1); + + const conflict = await fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + ...prompt, + content: "different body", + retry: true, + }), + }); + assert.equal(conflict.status, 409); + assert.deepEqual(await conflict.json(), { + code: "COMMAND_CONFLICT", + error: "commandId is already bound to a different prompt", + }); + + const unknown = await fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + ...prompt, + commandId: "after-host-restart", + retry: true, + }), + }); + assert.equal(unknown.status, 409); + assert.deepEqual(await unknown.json(), { + code: "COMMAND_ADMISSION_UNKNOWN", + error: + "previous prompt admission is unknown; refresh canonical state before sending a new request", + }); + assert.equal(sendCalls, 1); + } finally { + releaseAdmission?.(); + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("fails closed instead of evicting pending prompt admissions", { + timeout: 10_000, +}, async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-prompt-capacity-")); + let sendCalls = 0; + let releaseAdmissions!: () => void; + const held = new Promise((resolve) => { + releaseAdmissions = resolve; + }); + const runtime = testRuntime(cwd, async () => { + sendCalls++; + await held; + }); + const { host, launched, headers } = await startTestHost(runtime); + try { + const requests = Array.from({ length: 128 }, (_, index) => + fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: runtime.sessionManager.getSessionId(), + content: `pending ${index}`, + commandId: `pending-${index}`, + retry: false, + }), + }), + ); + while (sendCalls !== 128) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const overflow = await fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: runtime.sessionManager.getSessionId(), + content: "must not replace a pending admission", + commandId: "overflow", + retry: false, + }), + }); + assert.equal(overflow.status, 503); + assert.deepEqual(await overflow.json(), { + code: "PROMPT_ADMISSION_CAPACITY", + error: + "prompt admission capacity is full; wait for a pending admission to settle", + }); + assert.equal(sendCalls, 128); + releaseAdmissions(); + assert.ok( + (await Promise.all(requests)).every( + (response) => response.status === 202, + ), + ); } finally { + releaseAdmissions?.(); await host.stop(); await rm(cwd, { recursive: true, force: true }); } diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 26830990..85acb954 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -39,8 +39,21 @@ const MAX_SSE_REPLAY_BYTES = MAX_SSE_BUFFER_BYTES; const DEFAULT_SSE_HEARTBEAT_MS = 15_000; const SERVER_CLOSE_DRAIN_MS = 500; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; +const MAX_PROMPT_ADMISSIONS = 128; const execFileAsync = promisify(execFile); +type PromptAdmissionResponse = { + readonly status: number; + readonly body: Record; +}; + +type PromptAdmission = { + readonly sessionId: string; + readonly content: string; + readonly completion: Promise; + result?: PromptAdmissionResponse; +}; + type WebRequestErrorCode = | "INVALID_REQUEST_BODY" | "REQUEST_BODY_TOO_LARGE"; @@ -103,11 +116,7 @@ export class WebHost { private readonly leaseSensitiveMessages = new Set(); private readonly promptAdmissions = new Map< string, - { - readonly sessionId: string; - readonly content: string; - readonly promise: Promise; - } + PromptAdmission >(); private stopping = false; private stopPromise?: Promise; @@ -551,6 +560,42 @@ export class WebHost { error: "commandId must be at most 128 characters", }); } + if (body.retry !== undefined && typeof body.retry !== "boolean") { + return this.json(response, 400, { + error: "retry must be a boolean when provided", + }); + } + if (typeof body.sessionId !== "string") { + return this.json(response, 400, { + error: "sessionId is required", + }); + } + const existing = this.promptAdmissions.get(commandId); + if (existing) { + if ( + existing.sessionId !== body.sessionId || + existing.content !== content + ) { + return this.json(response, 409, { + code: "COMMAND_CONFLICT", + error: "commandId is already bound to a different prompt", + }); + } + const result = await existing.completion; + traceWeb("prompt_admission_replayed", { + commandId, + sessionId: body.sessionId, + status: result.status, + elapsedMs: elapsed(requestStarted), + }); + return this.json(response, result.status, result.body); + } + if (body.retry === true) { + return this.json(response, 409, { + code: "COMMAND_ADMISSION_UNKNOWN", + error: "previous prompt admission is unknown; refresh canonical state before sending a new request", + }); + } if (this.runtime.workspaceSelected !== true) { return this.json(response, 409, { code: "WORKSPACE_REQUIRED", @@ -558,7 +603,6 @@ export class WebHost { }); } if ( - typeof body.sessionId !== "string" || body.sessionId !== this.runtime.sessionManager.getSessionId() ) { return this.json(response, 409, { @@ -566,68 +610,19 @@ export class WebHost { error: "Only the active Web session accepts messages", }); } - traceWeb("prompt_received", { - commandId, - sessionId: body.sessionId, - chars: content.length, - }); - let acceptedFresh = true; - try { - const existing = this.promptAdmissions.get(commandId); - if (existing) { - acceptedFresh = false; - if ( - existing.sessionId !== body.sessionId || - existing.content !== content - ) { - return this.json(response, 409, { - code: "COMMAND_CONFLICT", - error: "commandId is already bound to a different prompt", - }); - } - await existing.promise; - } else { - const promise = this.runtime.sendPrompt(content, { - commandId, - expectedSessionId: body.sessionId, - }); - this.promptAdmissions.set(commandId, { - sessionId: body.sessionId, - content, - promise, - }); - await promise; - } - traceWeb("prompt_admission_finished", { - commandId, - elapsedMs: elapsed(requestStarted), - }); - } catch (error) { - const failure = this.runtimeRequestFailure(error); - traceWeb("prompt_admission_failed", { - commandId, - elapsedMs: elapsed(requestStarted), - error: failure.error, - }); - return this.json(response, failure.status, { - code: failure.code, - error: failure.error, + if (!this.makePromptAdmissionSpace()) { + return this.json(response, 503, { + code: "PROMPT_ADMISSION_CAPACITY", + error: "prompt admission capacity is full; wait for a pending admission to settle", }); } - if (acceptedFresh) { - this.publish("prompt_accepted", { commandId, sessionId: body.sessionId }); - } - traceWeb("prompt_response_sent", { + const admission = this.beginPromptAdmission( commandId, - sessionId: body.sessionId, - elapsedMs: elapsed(requestStarted), - }); - return this.json(response, 202, { - id: commandId, - accepted: true, - state: "accepted", - cursor: this.sequence, - }); + body.sessionId, + content, + ); + const result = await admission.completion; + return this.json(response, result.status, result.body); } if (request.method !== "GET") { return this.json(response, 405, { error: "method not allowed" }); @@ -728,6 +723,103 @@ export class WebHost { } } + private makePromptAdmissionSpace() { + while (this.promptAdmissions.size >= MAX_PROMPT_ADMISSIONS) { + const settled = [...this.promptAdmissions.entries()].find( + ([, admission]) => admission.result !== undefined, + ); + if (!settled) return false; + this.promptAdmissions.delete(settled[0]); + } + return true; + } + + private beginPromptAdmission( + commandId: string, + sessionId: string, + content: string, + ) { + let settle!: (result: PromptAdmissionResponse) => void; + const admission: PromptAdmission = { + sessionId, + content, + completion: new Promise((resolve) => { + settle = resolve; + }), + }; + // Store before dispatch: a client retry can only replay this record. + this.promptAdmissions.set(commandId, admission); + try { + traceWeb("prompt_received", { + commandId, + sessionId, + chars: content.length, + }); + } catch {} + void Promise.resolve() + .then(() => + this.runtime.sendPrompt(content, { + commandId, + expectedSessionId: sessionId, + }), + ) + .then( + () => { + const result: PromptAdmissionResponse = { + status: 202, + body: { + id: commandId, + accepted: true, + state: "accepted", + cursor: this.sequence, + }, + }; + try { + this.publish("prompt_accepted", { commandId, sessionId }); + result.body.cursor = this.sequence; + } catch {} + return result; + }, + (error) => { + const failure = this.runtimeRequestFailure(error); + return { + status: failure.status, + body: { code: failure.code, error: failure.error }, + }; + }, + ) + .then((result: PromptAdmissionResponse) => { + admission.result = result; + settle(result); + try { + traceWeb( + result.status === 202 + ? "prompt_admission_finished" + : "prompt_admission_failed", + { + commandId, + sessionId, + status: result.status, + ...(typeof result.body.error === "string" + ? { error: result.body.error } + : {}), + }, + ); + } catch {} + }) + .catch((error) => { + if (admission.result) return; + const failure = this.runtimeRequestFailure(error); + const result: PromptAdmissionResponse = { + status: failure.status, + body: { code: failure.code, error: failure.error }, + }; + admission.result = result; + settle(result); + }); + return admission; + } + private async readJson(request: IncomingMessage) { const chunks: Buffer[] = []; let bytes = 0; diff --git a/web/ui/app.js b/web/ui/app.js index 88463620..aa549920 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -22,6 +22,7 @@ const state = { promptAdmissionPending: false, promptAdmissionToken: null, promptAdmissionSequence: 0, + promptAdmission: null, terminalPromptIds: new Set(), sessionEpoch: 0, sessionSwitching: false, @@ -69,7 +70,7 @@ const translations = { activeOnlyHint: "Only the active Web session accepts messages.", acceptedHint: "Message accepted by OpenPI Web.", admissionPendingHint: "OpenPI is still accepting the previous message.", - admissionTimeout: "Prompt admission timed out. Your draft was restored; try again.", + admissionTimeout: "Prompt admission timed out. Retrying will check the same message.", requestTimeout: "The Web request timed out.", reconnectingHint: "Live updates were interrupted. Reconnecting and checking canonical state...", modelRunning: "Working...", @@ -109,7 +110,7 @@ const translations = { activeOnlyHint: "只有当前 Web 会话可以接收消息。", acceptedHint: "OpenPI Web 已接收消息。", admissionPendingHint: "OpenPI 仍在接收上一条消息,请稍候。", - admissionTimeout: "消息接收超时,草稿已恢复,请重试。", + admissionTimeout: "消息接收超时;重试会核对同一条消息。", requestTimeout: "Web 请求已超时。", reconnectingHint: "实时更新已中断,正在重连并核对权威状态……", modelRunning: "正在运行...", @@ -245,11 +246,20 @@ async function api(path, options = {}) { }, }); const body = await response.json().catch(() => ({})); - if (!response.ok) - throw new Error(body.error || `Request failed (${response.status})`); + if (!response.ok) { + const failure = new Error(body.error || `Request failed (${response.status})`); + failure.name = "WebApiResponseError"; + if (typeof body.code === "string") failure.code = body.code; + failure.status = response.status; + throw failure; + } return body; } catch (error) { - if (timedOut) throw new Error(timeoutMessage); + if (timedOut) { + const timeout = new Error(timeoutMessage); + timeout.name = "WebRequestTimeout"; + throw timeout; + } throw error; } finally { window.clearTimeout(timer); @@ -676,7 +686,8 @@ async function refreshSnapshot({ state.snapshot = snapshot; if ( state.snapshot.runtime.status !== "running" && - !state.promptAdmissionPending + !state.promptAdmissionPending && + !state.promptAdmission ) { state.liveRunning = false; state.livePhase = "idle"; @@ -722,6 +733,7 @@ async function selectSession(path) { state.selectedPath = path; state.promptAdmissionPending = false; state.promptAdmissionToken = null; + state.promptAdmission = null; resetLiveState(); document.body.classList.remove("sidebar-open"); renderWorkspaces(); @@ -802,13 +814,25 @@ async function sendPrompt() { if (!sessionId || state.sessionSwitching || state.promptAdmissionPending) return; const epoch = state.sessionEpoch; const admissionToken = ++state.promptAdmissionSequence; - const commandId = globalThis.crypto?.randomUUID?.() || - `web-prompt-${Date.now()}-${admissionToken}`; - const optimisticKey = `optimistic-${commandId}`; - state.liveMessages = [ - ...state.liveMessages, - { key: optimisticKey, message: { role: "user", content } }, - ].slice(-8); + const retrying = + state.promptAdmission?.sessionId === sessionId && + state.promptAdmission?.content === content; + const commandId = retrying + ? state.promptAdmission.commandId + : globalThis.crypto?.randomUUID?.() || + `web-prompt-${Date.now()}-${admissionToken}`; + const optimisticKey = retrying + ? state.promptAdmission.optimisticKey + : `optimistic-${commandId}`; + if (!retrying) { + state.liveMessages = [ + ...state.liveMessages, + { key: optimisticKey, message: { role: "user", content } }, + ].slice(-8); + } + // Keep this attempt before dispatch. A timeout may be a lost receipt, not a + // failed admission, so the next submit must replay this exact request. + state.promptAdmission = { sessionId, content, commandId, optimisticKey }; state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; clearComposerFeedback(); @@ -816,22 +840,45 @@ async function sendPrompt() { try { const receipt = await api("/api/prompt", { method: "POST", - body: JSON.stringify({ sessionId, content, commandId }), + body: JSON.stringify({ sessionId, content, commandId, retry: retrying }), timeoutMs: PROMPT_ADMISSION_TIMEOUT_MS, timeoutMessage: t("admissionTimeout"), }); if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return; const alreadySettled = state.terminalPromptIds.has(receipt.id); applyPromptAcceptedState(alreadySettled); + if (state.promptAdmission?.commandId === commandId) { + state.promptAdmission = null; + } $("prompt-input").value = ""; resizePrompt(); setComposerFeedback(t("acceptedHint")); scheduleSnapshotRefresh(120); } catch (error) { if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return; - state.liveRunning = false; - state.livePhase = "idle"; - state.liveRetry = null; + const knownRejection = + error?.name === "WebApiResponseError" && + [ + "WORKSPACE_REQUIRED", + "SESSION_CONFLICT", + "PROMPT_REJECTED", + "COMMAND_CONFLICT", + ].includes(error?.code); + if (!knownRejection) { + if (state.livePhase !== "running") { + state.liveRunning = true; + state.livePhase = "preparing"; + } + state.liveRetry = null; + setComposerFeedback( + error?.name === "WebRequestTimeout" ? t("admissionTimeout") : error.message, + "connection", + ); + return; + } + if (state.promptAdmission?.commandId === commandId) { + state.promptAdmission = null; + } state.liveMessages = state.liveMessages.filter( (entry) => entry.key !== optimisticKey, ); @@ -926,6 +973,7 @@ async function createSession(workspacePath) { state.selectedPath = null; state.promptAdmissionPending = false; state.promptAdmissionToken = null; + state.promptAdmission = null; resetLiveState(); document.body.classList.remove("sidebar-open"); renderWorkspaces(); @@ -1238,6 +1286,17 @@ function rememberCompletedActivation(commandId) { } let eventLoopStarted = false; +let eventLoopStopped = false; +let activeEventReader = null; + +window.addEventListener("pagehide", (event) => { + // A bfcache entry resumes this same document and its event loop. Do not + // create a second SSE connection on pageshow. + if (event.persisted) return; + eventLoopStopped = true; + void activeEventReader?.cancel().catch(() => undefined); +}); + function resetLiveState() { state.liveMessages = []; state.liveRunning = false; @@ -1249,7 +1308,7 @@ async function connectEvents() { if (eventLoopStarted) return; eventLoopStarted = true; let reconnectDelay = 500; - while (true) { + while (!eventLoopStopped) { let reader = null; let recoveryAttempted = false; try { @@ -1274,6 +1333,7 @@ async function connectEvents() { clearComposerFeedback("connection"); reconnectDelay = 500; reader = response.body.getReader(); + activeEventReader = reader; const decoder = new TextDecoder(); let buffer = ""; let heartbeatCount = 0; @@ -1305,7 +1365,9 @@ async function connectEvents() { } } catch { await reader?.cancel().catch(() => undefined); + if (activeEventReader === reader) activeEventReader = null; reader = null; + if (eventLoopStopped) break; $("connection-state").textContent = "Reconnecting"; $("connection-state").classList.add("reconnecting"); const recovered = recoveryAttempted @@ -1317,6 +1379,7 @@ async function connectEvents() { reconnectDelay = Math.min(reconnectDelay * 2, 5_000); } finally { await reader?.cancel().catch(() => undefined); + if (activeEventReader === reader) activeEventReader = null; } } } From 00e519990da3582ac60fb7167f95f5f7683c59c9 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:36:32 +0800 Subject: [PATCH 15/16] fix(web): retry after admission capacity rejection --- tests/web/app-render.test.ts | 38 ++++++++++++++++++++++++++++++++++++ web/ui/app.js | 1 + 2 files changed, 39 insertions(+) diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index fb8d871c..e3000992 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -664,6 +664,44 @@ test("app.js preserves an uncertain transport admission without resetting an act assert.equal(requests[1]?.retry, true); }); +test("app.js starts a new attempt after admission capacity rejects before dispatch", async () => { + const app = await renderApp(); + const input = app.elements.get("prompt-input"); + assert.ok(input); + const requests: Array> = []; + app.context.fetch = (url: unknown, options?: { body?: string }) => { + if (String(url) === "/api/prompt") { + requests.push(JSON.parse(options?.body || "{}")); + if (requests.length === 1) { + return Promise.resolve({ + ok: false, + status: 503, + json: async () => ({ + code: "PROMPT_ADMISSION_CAPACITY", + error: "prompt admission capacity is full", + }), + }); + } + return Promise.resolve( + response({ id: requests[1]?.commandId, accepted: true }), + ); + } + if (String(url).startsWith("/api/snapshot")) + return Promise.resolve(response(SNAPSHOT)); + throw new Error(`unexpected request: ${String(url)}`); + }; + input.value = "try after capacity opens"; + + await app.sendPrompt(); + assert.equal(app.state.promptAdmission, null); + await app.sendPrompt(); + + assert.equal(requests.length, 2); + assert.notEqual(requests[0]?.commandId, requests[1]?.commandId); + assert.equal(requests[0]?.retry, false); + assert.equal(requests[1]?.retry, false); +}); + test("app.js invalidates snapshots for cross-tab session metadata events", async () => { const app = await renderApp({ eventRecords: [ diff --git a/web/ui/app.js b/web/ui/app.js index aa549920..c7ab7508 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -863,6 +863,7 @@ async function sendPrompt() { "SESSION_CONFLICT", "PROMPT_REJECTED", "COMMAND_CONFLICT", + "PROMPT_ADMISSION_CAPACITY", ].includes(error?.code); if (!knownRejection) { if (state.livePhase !== "running") { From f3c06e00c0e1bef39d15c77a690dfd66b364df44 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 01:37:26 +0800 Subject: [PATCH 16/16] fix(web): return prompt admission snapshot --- web/runtime/pi-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index 655869dc..55a8ebc4 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -581,7 +581,7 @@ export class PiWebRuntime implements WebRuntimeController { () => this.promptOperations.delete(operation), () => this.promptOperations.delete(operation), ); - await requestAdmission; + return await requestAdmission; } newSession(workspacePath: string, options?: WebSessionCreationOptions) {