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 1/4] 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 2/4] 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 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 3/4] 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 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 4/4] 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