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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2355,6 +2356,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"runs.completed": "已完成",
"runs.failed": "需检查",
"runs.interrupted": "已中断",
"feedback.steeringStorageUnavailable": "调整尚未发送:浏览器存储无法保存重试信息。草稿仍保留在本页,请恢复存储后重试。",
"runs.queued": "已安排",
"runs.ready": "可继续",
"runs.resumeFailed": "恢复失败",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function MessageActivity({ message, onInterruptTurn, onSteerTurn, onCance
onInterruptTurn?: (turnId: string) => Promise<void>;
onSteerTurn?: (turnId: string, text: string, ingressId: string) => Promise<void>;
}) {
const { locale } = useWorkspaceI18n();
const { locale, t } = useWorkspaceI18n();
const zh = locale === "zh-CN";
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ function restoreRequests(): Map<string, SteeringRequest> {
}
}

function persistRequests() {
function persistRequests(): boolean {
try {
window.sessionStorage.setItem(storageKey, JSON.stringify([...requestCache()]));
const serialized = JSON.stringify([...requestCache()]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Qualify the current durable request rather than rewriting the whole shared cache. A can already have an exact persisted sessionId/turnId/text/id, while a failed B draft remains only in the shared Map. If storage cannot grow to A+B, retrying A returns false here and both callers refuse dispatch although A’s safety prerequisite is met. Reproduced in the actual helper across Goal/Agent keys and in the packaged inline/composer UI (zero POST, stored A unchanged; storage recovery sends A with the original ingress ID). Check the persisted target tuple and permit an exact already-durable retry; keep new/mismatched/read-failed identities blocked. Add this case alongside the existing never-persisted/drop tests.

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;
}
}

Expand All @@ -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) {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading