diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index d6b9d31312..ca8997b630 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -38,9 +38,9 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 9e2b4148db..9bd84134eb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -947,6 +947,7 @@ const en = { "runs.completed": "Completed", "runs.failed": "Needs review", "runs.interrupted": "Interrupted", + "feedback.steeringStorageUnavailable": "Adjustment not sent: browser storage could not retain its retry information. The draft is kept on this page; restore storage and retry.", "runs.queued": "Scheduled", "runs.ready": "Ready", "runs.resumeFailed": "Recovery failed", @@ -2355,6 +2356,7 @@ const zhCN: Record = { "runs.completed": "已完成", "runs.failed": "需检查", "runs.interrupted": "已中断", + "feedback.steeringStorageUnavailable": "调整尚未发送:浏览器存储无法保存重试信息。草稿仍保留在本页,请恢复存储后重试。", "runs.queued": "已安排", "runs.ready": "可继续", "runs.resumeFailed": "恢复失败", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx b/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx index eb31cb7f95..7794ffb8aa 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/message-activity.tsx @@ -13,7 +13,7 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance onInterruptTurn?: (turnId: string) => Promise; onSteerTurn?: (turnId: string, text: string, ingressId: string) => Promise; }) { - const { locale } = useWorkspaceI18n(); + const { locale, t } = useWorkspaceI18n(); const zh = locale === "zh-CN"; const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); @@ -69,7 +69,10 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance // Retain the operation identity after a lost response; retry cannot deliver twice. if (request.current?.text.trim() !== text) updateDraft(draft); const sent = request.current!; - if (cacheKey) retainSteeringRequest(cacheKey, sent); + if (!cacheKey || !retainSteeringRequest(cacheKey, sent)) { + setSteerError(t("feedback.steeringStorageUnavailable")); + return; + } setSteering(true); setSteerError(null); try { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 8bf5f2c5fa..680c9c198c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1863,7 +1863,10 @@ export function PersonalWorkspacePage({ return; } const request = retry ?? { sessionId: conversationSessionId, turnId: steeringTurnId!, text: message, id: crypto.randomUUID() }; - retainSteeringRequest(composerDraftKey, request); + if (!retainSteeringRequest(composerDraftKey, request)) { + setActionFeedback(t("feedback.steeringStorageUnavailable")); + return; + } setSteering(true); setActionFeedback(null); setImageAttachmentError(null); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.test.mjs new file mode 100644 index 0000000000..e821967c9d --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const key = JSON.stringify(["inline", "session", "turn"]); +const request = {sessionId: "session", turnId: "turn", text: "Keep the original constraint", id: "original-request"}; + +test("steering identity survives reload only after exact persistent readback", async () => { + let stored = null; + globalThis.window = {sessionStorage: {getItem: () => stored, setItem: (_, value) => {stored = value;}}}; + const first = await import("./steering-recovery.ts?write"); + assert.equal(first.retainSteeringRequest(key, request), true); + const reloaded = await import("./steering-recovery.ts?read"); + assert.deepEqual(reloaded.readSteeringRequest(key), request); + reloaded.retireSteeringRequest(key, "another-request"); + assert.deepEqual(reloaded.readSteeringRequest(key), request, "A late response cannot retire another request"); + reloaded.retireSteeringRequest(key, request.id); + assert.equal(reloaded.readSteeringRequest(key), undefined); +}); + +test("unavailable storage preserves the page draft but refuses dispatch qualification", async () => { + let available = false, stored = null; + globalThis.window = {sessionStorage: {getItem: () => stored, setItem: (_, value) => { + if (!available) throw new Error("synthetic storage failure"); + stored = value; + }}}; + const recovery = await import("./steering-recovery.ts?unavailable"); + assert.equal(recovery.retainSteeringRequest(key, request), false); + assert.deepEqual(recovery.readSteeringRequest(key), request, "The unsent draft is still editable in this page"); + available = true; + assert.equal(recovery.retainSteeringRequest(key, request), true); + const reloaded = await import("./steering-recovery.ts?recovered"); + assert.deepEqual(reloaded.readSteeringRequest(key), request, "Repair does not mint a new operation identity"); +}); + +test("a silently dropped browser write is not a durable retry identity", async () => { + globalThis.window = {sessionStorage: {getItem: () => null, setItem() {}}}; + const recovery = await import("./steering-recovery.ts?dropped"); + assert.equal(recovery.retainSteeringRequest(key, request), false); + assert.deepEqual(recovery.readSteeringRequest(key), request); +}); + + +test("a durable request retries independently of another conversation's unsaved draft", async () => { + let stored = null, writable = true; + globalThis.window = {sessionStorage: {getItem: () => stored, setItem: (_, value) => { + if (!writable) throw new Error("QuotaExceededError"); + stored = value; + }}}; + const recovery = await import("./steering-recovery.ts?independent"); + assert.equal(recovery.retainSteeringRequest(key, request), true); + writable = false; + const otherKey = JSON.stringify(["composer", "other-goal", "other-agent"]); + const other = {...request, sessionId: "other-session", turnId: "other-turn", id: "other-request"}; + assert.equal(recovery.retainSteeringRequest(otherKey, other), false); + assert.equal(recovery.retainSteeringRequest(key, request), true); + assert.deepEqual(JSON.parse(stored), [[key, request]]); + for (const field of ["sessionId", "turnId", "text", "id"]) { + assert.equal(recovery.retainSteeringRequest(key, {...request, [field]: "changed"}), false, field); + } + assert.equal(recovery.retainSteeringRequest("another-key", request), false); + globalThis.window.sessionStorage.getItem = () => {throw new Error("read unavailable");}; + assert.equal(recovery.retainSteeringRequest(key, request), false, "page cache is not persistent evidence"); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.ts b/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.ts index 646006c265..051f3e4291 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/steering-recovery.ts @@ -28,12 +28,15 @@ function restoreRequests(): Map { } } -function persistRequests() { +function persistRequests(): boolean { try { - window.sessionStorage.setItem(storageKey, JSON.stringify([...requestCache()])); + const serialized = JSON.stringify([...requestCache()]); + window.sessionStorage.setItem(storageKey, serialized); + return window.sessionStorage.getItem(storageKey) === serialized; } catch { - // Unavailable browser storage retains drafts/retries in this shared page cache. - // No provider action or delivery conclusion follows from this cache. + // Keep the editable page draft, but callers must not dispatch without a + // durable retry identity that survives a lost response and a page reload. + return false; } } @@ -47,9 +50,16 @@ export function readSteeringRequest(key: string) { return requestCache().get(key); } -export function retainSteeringRequest(key: string, request: SteeringRequest) { +export function retainSteeringRequest(key: string, request: SteeringRequest): boolean { requestCache().set(key, request); - persistRequests(); + if (persistRequests()) return true; + // Another conversation's unsaved draft may prevent rewriting the whole cache. + // Only the current request's exact durable identity qualifies its retry. + const durable = restoreRequests().get(key); + return durable?.sessionId === request.sessionId + && durable.turnId === request.turnId + && durable.text === request.text + && durable.id === request.id; } export function retireSteeringRequest(key: string, id: string) { diff --git a/docs/assets/personal-workspace/steering-storage-before.png b/docs/assets/personal-workspace/steering-storage-before.png new file mode 100644 index 0000000000..82569fd5b2 Binary files /dev/null and b/docs/assets/personal-workspace/steering-storage-before.png differ diff --git a/docs/assets/personal-workspace/steering-storage-unavailable.png b/docs/assets/personal-workspace/steering-storage-unavailable.png new file mode 100644 index 0000000000..19e5eb9e50 Binary files /dev/null and b/docs/assets/personal-workspace/steering-storage-unavailable.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 5c435d8630..b7816286fc 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -101,9 +101,9 @@ presentation, not read or write authority. The steward and Goal conversations share the same frontend activity view across task types. The reply shows the step in progress, such as "Reading notes.md". With the Codex executor, expand **Work steps** to follow each thought, read, search, command, tool call and file edit; a step's start and end share one row, failures show their exit code, and clicking a row reveals the full command, the changed files or the thinking text. Thinking text appears only when the model exposes it to the host (some models report only how long they thought). Command output, tool arguments and results, and file diffs are never shown; project paths read as relative paths, other local paths and credential-like values are hidden. A completed step means it ended, not that a check passed. Steps live only in the owner-only local session replay; the next LoopX Chat start after a Turn is more than 24 hours old clears them. Other executors keep **Recent activity** with the latest six observations; missing activity stays an honest waiting state. **Interrupt turn** targets that reply, preserves already visible text, and keeps the conversation available for continuation. A rejected interruption leaves the live reply visible; completion wins a race with interruption. This control does not stop the Goal. Attached hosts that do not expose interruption report that limitation. -运行中可点击「调整本轮」,向原任务追加指令。当前支持原生 Codex 执行器;只有收到匹配的执行器回执后才显示已接收,这不代表调整后的任务已经完成。不支持的执行器、过期回合或无法确认的回执会保留草稿,不自动变成新任务。送达状态未知时,重试沿用同一请求编号,防止重复投递;执行器明确拒绝且确认未送达时,条件恢复后可用原文安全地重新发起。回合结束后,未发送的草稿仍可复制到输入框。草稿仅保存在当前页面,刷新前请自行保存。 +运行中可点击「调整本轮」,向原任务追加指令。当前支持原生 Codex 执行器;只有收到匹配的执行器回执后才显示已接收,这不代表调整后的任务已经完成。不支持的执行器、过期回合或无法确认的回执会保留草稿,不自动变成新任务。送达状态未知时,重试沿用同一请求编号,防止重复投递;执行器明确拒绝且确认未送达时,条件恢复后可用原文安全地重新发起。回合结束后,未发送的草稿仍可复制到输入框。「调整本轮」和普通会话输入框中的追加指令,在发送前必须将重试信息保存到当前标签页的浏览器存储并读回;刷新后恢复原请求身份,不自动发送。存储不可用时会提示尚未发送,草稿仅保留在当前页面;恢复存储后再重试。 -Use **Adjust turn** to add instructions to the running task. Native Codex executors currently support this control. A matching executor receipt confirms acceptance, not completion. Unsupported executors, expired turns and unconfirmed receipts retain the draft without starting another task. An unresolved delivery keeps the request identity on retry to prevent duplicates; a confirmed pre-delivery rejection allows the unchanged draft to start a new request after recovery. After the turn ends, copy an unsent draft to the composer. Drafts are page-local; save them before refreshing. +Use **Adjust turn** to add instructions to the running task. Native Codex executors currently support this control. A matching executor receipt confirms acceptance, not completion. Unsupported executors, expired turns and unconfirmed receipts retain the draft without starting another task. An unresolved delivery keeps the request identity on retry to prevent duplicates; a confirmed pre-delivery rejection allows the unchanged draft to start a new request after recovery. After the turn ends, copy an unsent draft to the composer. Both **Adjust turn** and an ordinary conversation's in-turn composer save and read back retry information in tab-local browser storage before dispatch. Reload restores the original identity without sending. If storage is unavailable, the adjustment is not sent and its draft stays in the current page; restore storage before retrying. For API callers, `POST /api/chat/sessions/{session_id}/turns/{turn_id}/steer` accepts `message` (1–12000 characters) and a stable `client_ingress_id`. diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index 021782fda7..cefe88a4fe 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -203,10 +203,33 @@ export const conversationActivityScenario = { await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "任务", exact: true }).click(); await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); assert.equal(await page.getByLabel("追加给本轮的指令").inputValue(), "先检查最新证据。", "blocked storage preserves drafts within this page"); + const dispatchedBeforeStorageFailure = adjustments.length; + await page.getByRole("button", { name: "发送调整", exact: true }).click(); + await page.getByText("调整尚未发送:浏览器存储无法保存重试信息。草稿仍保留在本页,请恢复存储后重试。", {exact: true}).waitFor(); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch inline steering"); + const goalComposer = page.getByRole("textbox", { name: /发送消息|Send message/ }); + await goalComposer.fill("输入框中的独立调整。"); + await goalComposer.press("Enter"); + await page.locator(".personal-action-feedback").filter({hasText: "调整尚未发送"}).waitFor(); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "The composer uses the same pre-dispatch persistence guard"); + assert.equal(await goalComposer.inputValue(), "输入框中的独立调整。", "The refused composer draft stays editable"); + await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"}); + await goalComposer.fill(""); + await page.evaluate(() => { delete window.sessionStorage.setItem; }); + await page.getByLabel("追加给本轮的指令").fill("先检查最新证据,再继续。"); + const durableInline = await page.evaluate(() => JSON.parse(sessionStorage.getItem("loopx-pw-composer-steering")) + .find(([, request]) => request.text === "先检查最新证据,再继续。")[1]); + await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("QuotaExceededError"); }; }); + await goalComposer.fill("另一个尚未保存的调整。"); + await goalComposer.press("Enter"); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "A new composer identity still requires persistence"); await page.getByRole("button", { name: "发送调整", exact: true }).click(); await page.locator(".personal-message") .filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) }) .getByRole("status").filter({ hasText: "执行器已接收本轮追加指令。" }).waitFor(); + assert.equal(adjustments.at(-1).client_ingress_id, durableInline.id, "Unsaved composer draft cannot block the durable inline identity"); + assert.equal(await goalComposer.inputValue(), "另一个尚未保存的调整。"); + await page.evaluate(() => { delete window.sessionStorage.setItem; }); await page.getByRole("button", { name: "中断本轮", exact: true }).click(); await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor(); assert.deepEqual(api.interrupts.at(-1), { sessionId: goalTurn.sessionId, turnId: goalTurn.turnId });