\` 后,先给出简洁有序计划,把计划按顺序保存为 LoopX 待办,再读回当前要做什么、哪里需要我判断、下一步安全动作是什么。
LoopX 内部机制术语默认放在高级或调试说明里,只有决策需要时再展开。`,
};
@@ -76,7 +74,7 @@ const content = {
secondPrefix: "Keep ",
secondAccent: "progress visible",
},
- body: "Use the agent you already trust. LoopX turns one natural-language goal into durable work, gates, and evidence behind the scenes, so the next session can continue without making you learn LoopX internals first.",
+ body: "Use the agent you already trust. LoopX turns one natural-language goal into durable work, needed decisions, and verifiable results behind the scenes, so the next session can continue without making you learn LoopX internals first.",
copy: "Get started",
mac: {
download: "Download for Mac",
@@ -87,7 +85,7 @@ const content = {
setup: {
eyebrow: "Goal-first setup",
title: "Start from the outcome.",
- body: "Give your current agent one setup prompt, then send the goal. Shell remains available when you want the manual path.",
+ body: "Give your current agent one setup prompt, then send the goal. Prefer the shell? Install LoopX manually.",
agentTitle: "Guided agent start",
agentBody: "Ask Codex, Claude Code, OpenCode, TraeX, Pi, or another shell-capable agent to connect this project and keep the machinery behind the scenes.",
shellTitle: "Manual shell setup",
@@ -98,24 +96,24 @@ const content = {
},
demo: "See in action",
panel: {
- label: "ONE GOAL · ACROSS SESSIONS",
+ label: "ONE OUTCOME · ACROSS SESSIONS",
title: "Ship the next release",
status: "On track",
- running: "Agent work",
- researcher: "Research and implementation",
- gate: "Human gate",
- gateBody: "Merge needs your approval",
- waiting: "Waiting",
- evidence: "Evidence",
- evidenceBody: "Validation and handoff written",
- complete: "Complete",
+ running: "Work in progress",
+ researcher: "Research and implementation underway",
+ gate: "Your decision",
+ gateBody: "Approve the release when ready",
+ waiting: "Needs you",
+ evidence: "Verified result",
+ evidenceBody: "Checks and handoff written",
+ complete: "Saved",
next: "Next safe action",
- nextBody: "Keep independent tasks moving while review waits",
+ nextBody: "Continue work that does not depend on approval",
},
proof: [
["No vocabulary tax", "Start from the outcome; open LoopX details only when they matter."],
["Durable work", "The next session resumes from governed state, not memory."],
- ["Reviewable evidence", "Every transition leaves a result another person can verify."],
+ ["Verifiable results", "Every transition leaves a result another person can check."],
],
sectionEyebrow: "One goal-first lifecycle",
sectionTitle: "Useful work can start before the machinery is familiar.",
@@ -123,18 +121,18 @@ const content = {
steps: [
["01", "One goal", "A natural-language outcome"],
["02", "Plan", "Visible work to do"],
- ["03", "Gate", "A concrete decision"],
- ["04", "Evidence", "A validated result"],
+ ["03", "Decision", "A concrete choice"],
+ ["04", "Proof", "A validated result"],
["05", "Resume", "Recoverable progress"],
],
capabilities: {
- eyebrow: "Default simple, kernel intact",
+ eyebrow: "Simple by default, durable underneath",
title: "A natural entry backed by durable state.",
body: "Codex, Claude Code, OpenCode, TraeX, Pi, and custom runners can share one durable lifecycle. First contact starts with one goal; advanced surfaces expose ownership, scheduling, recovery, and protocols when needed.",
items: [
- ["01", "Goal-first entry", "Start from a natural-language outcome; LoopX records the durable Goal and Todo structure as work becomes concrete."],
+ ["01", "Goal-first entry", "Start from a natural-language outcome; LoopX records durable work as the plan becomes concrete."],
["02", "Cross-runtime continuity", "Change the executor or host without reconstructing the objective from chat history."],
- ["03", "Gate-aware progress", "A blocked P0 stays visible while independent P1 and P2 lanes continue safely."],
+ ["03", "Decision-aware progress", "A blocked decision stays visible while independent safe work can continue."],
["04", "Reviewable outcomes", "Every run writes validation, ownership, review, and handoff evidence into the next loop."],
],
},
@@ -195,7 +193,7 @@ const content = {
secondPrefix: "让进展",
secondAccent: "持续可见",
},
- body: "继续使用你已经信任的 Agent。LoopX 在背后把一个自然语言目标变成持久工作、决策门和证据,让下一轮会话可以接着推进,而不要求你先学会 LoopX 内部机制。",
+ body: "继续使用你已经信任的 Agent。LoopX 在背后把一个自然语言目标变成持久工作、需要你判断的决策和可验证结果,让下一轮会话可以接着推进,而不要求你先学会 LoopX 内部机制。",
copy: "开始使用",
mac: {
download: "下载 Mac App",
@@ -217,19 +215,19 @@ const content = {
},
demo: "查看实战",
panel: {
- label: "一个目标 · 跨越多轮会话",
+ label: "一个结果 · 跨越多轮会话",
title: "交付下一个版本",
status: "进展正常",
- running: "Agent 执行",
- researcher: "研究与实现",
- gate: "Human Gate",
- gateBody: "合并前,等待你的批准",
- waiting: "等待中",
- evidence: "Evidence",
- evidenceBody: "验证与交接已写回",
- complete: "已完成",
+ running: "正在推进",
+ researcher: "研究与实现正在进行",
+ gate: "需要你判断",
+ gateBody: "版本准备好后由你批准",
+ waiting: "等你决定",
+ evidence: "已验证结果",
+ evidenceBody: "检查与交接已写回",
+ complete: "已保存",
next: "下一步安全动作",
- nextBody: "审批等待中,继续推进不依赖审批的任务",
+ nextBody: "继续推进不依赖审批的工作",
},
proof: [
["无术语门槛", "先描述结果;LoopX 细节只在需要决策或调试时展开。"],
@@ -242,8 +240,8 @@ const content = {
steps: [
["01", "一个目标", "自然语言结果"],
["02", "计划", "可见的待办"],
- ["03", "Gate", "一个具体判断"],
- ["04", "Evidence", "一个验证结果"],
+ ["03", "判断", "一个具体选择"],
+ ["04", "证明", "一个验证结果"],
["05", "恢复", "可继续的进展"],
],
capabilities: {
@@ -251,9 +249,9 @@ const content = {
title: "自然入口背后,是持久状态。",
body: "Codex、Claude Code、OpenCode、TraeX、Pi 与自定义 Runner 可以共享同一套持久生命周期。第一次接触从一个目标开始;需要时,高级界面再展开 ownership、调度、恢复与协议。",
items: [
- ["01", "目标优先入口", "从自然语言结果开始;随着工作具体化,LoopX 写入持久 Goal 与 Todo 结构。"],
+ ["01", "目标优先入口", "从自然语言结果开始;随着计划具体化,LoopX 记录可持续推进的工作。"],
["02", "跨运行时连续性", "更换执行器或 Host 时,无需从聊天记录重建长期目标。"],
- ["03", "Gate-aware 进展", "P0 等待决策时保持可见,独立的 P1、P2 线路可以安全继续。"],
+ ["03", "决策可见的进展", "需要你判断的事项保持可见,不依赖它的安全工作可以继续。"],
["04", "可审查结果", "每轮运行都把验证、归属、审查和交接证据写入下一轮闭环。"],
],
},
@@ -350,7 +348,7 @@ function ControlPlane({ language }: { language: Language }) {
- {language === "en" ? "ACTIVE GOAL" : "当前目标"}
+ {language === "en" ? "CURRENT GOAL" : "当前目标"}
{panel.title}
{panel.status}
@@ -365,7 +363,7 @@ function ControlPlane({ language }: { language: Language }) {
- P0 · {panel.running}
+ {language === "en" ? "NOW" : "当前"} · {panel.running}
{panel.researcher}
@@ -378,7 +376,7 @@ function ControlPlane({ language }: { language: Language }) {
- GATE · {panel.gate}
+ {language === "en" ? "DECISION" : "判断"} · {panel.gate}
{panel.gateBody}
{panel.waiting}
@@ -458,10 +456,10 @@ const terminalCases = {
},
lines: [
["GOAL", "Ship the focused fix and preserve reusable knowledge.", "交付聚焦修复,并保留可复用知识。", "ACTIVE", "active"],
- ["P0", "Focused PR delivery · bounded diff and validation", "聚焦式 PR 交付 · 有边界的改动与验证", "RUNNING", "running"],
- ["GATE", "Reviewer authority · approve the focused change", "审查权限 · 批准聚焦改动", "WAITING", "waiting"],
- ["P1", "Repository knowledge and terminal resume remain available", "仓库知识与终端恢复保持可用", "READY", "ready"],
- ["EVIDENCE", "Merge/open result linked to the next reusable discovery", "合并 / 开放结果连接到下一轮可复用发现", "WRITTEN", "written"],
+ ["NOW", "Focused PR delivery · bounded diff and validation", "聚焦式 PR 交付 · 有边界的改动与验证", "RUNNING", "running"],
+ ["DECISION", "Reviewer authority · approve the focused change", "审查权限 · 批准聚焦改动", "WAITING", "waiting"],
+ ["SUPPORT", "Repository knowledge and terminal resume remain available", "仓库知识与终端恢复保持可用", "READY", "ready"],
+ ["PROOF", "Merge/open result linked to the next reusable discovery", "合并 / 开放结果连接到下一轮可复用发现", "WRITTEN", "written"],
["NEXT", "Review the focused diff, then continue the capability lane.", "先审查聚焦改动,再继续能力沉淀线路。", "SAFE", "safe"],
],
},
@@ -473,15 +471,15 @@ const terminalCases = {
command: "$ loopx status --goal-id auto-ml",
image: mlEvidenceUrl,
imageTitle: {
- en: "Evidence branches remain visible until promotion or stop.",
- zh: "证据分支保持可见,直到晋级或停止。",
+ en: "Result branches remain visible until promotion or stop.",
+ zh: "结果分支保持可见,直到晋级或停止。",
},
lines: [
["BASELINE", "Matched candidate and evaluation contract locked.", "匹配候选项与评估契约已锁定。", "MATCHED", "active"],
["BRANCHES", "Positive · Negative · Invalid lineage · Running replicate", "正向 · 负向 · 无效谱系 · 运行中复现", "VISIBLE", "ready"],
- ["P1", "Replicate capacity stays bounded while evidence accumulates", "证据持续积累,同时保持复现容量有界", "RUNNING", "running"],
- ["EVIDENCE", "Negative results and invalid lineages remain traceable", "负向结果和无效谱系持续可追溯", "WRITTEN", "written"],
- ["P0 GATE", "Promote or stop after the running replicate completes", "运行中复现完成后,决定晋级或停止", "WAITING", "waiting"],
+ ["SUPPORT", "Replicate capacity stays bounded while evidence accumulates", "证据持续积累,同时保持复现容量有界", "RUNNING", "running"],
+ ["PROOF", "Negative results and invalid lineages remain traceable", "负向结果和无效谱系持续可追溯", "WRITTEN", "written"],
+ ["DECISION", "Promote or stop after the running replicate completes", "运行中复现完成后,决定晋级或停止", "WAITING", "waiting"],
["NEXT", "Compare replicate evidence, then record the decision.", "比较复现证据,再记录最终决策。", "SAFE", "safe"],
],
},
diff --git a/apps/presentation/site/src/page-metadata.ts b/apps/presentation/site/src/page-metadata.ts
index 24b109ba44..e0e2b28d7d 100644
--- a/apps/presentation/site/src/page-metadata.ts
+++ b/apps/presentation/site/src/page-metadata.ts
@@ -27,11 +27,11 @@ export const pageMetadata = {
path: "",
en: {
title: "LoopX — Start long-running agent work with one goal",
- description: "Tell Codex, Claude Code or another agent one natural-language goal. LoopX keeps the work, gates, evidence and recovery durable across sessions.",
+ description: "Tell Codex, Claude Code or another agent one natural-language goal. LoopX keeps the work, decisions, verification and recovery durable across sessions.",
},
zh: {
title: "LoopX — 从一个目标开始长程 Agent 工作",
- description: "给 Codex、Claude Code 等 Agent 一个自然语言目标。LoopX 跨会话保留工作、决策门、证据与恢复状态。",
+ description: "给 Codex、Claude Code 等 Agent 一个自然语言目标。LoopX 跨会话保留工作、判断、验证结果与恢复状态。",
},
},
sweMarathon: {
diff --git a/apps/presentation/site/src/styles.css b/apps/presentation/site/src/styles.css
index c492cfb874..d083f3650e 100644
--- a/apps/presentation/site/src/styles.css
+++ b/apps/presentation/site/src/styles.css
@@ -260,6 +260,7 @@ button {
.hero-copy {
position: relative;
z-index: 2;
+ min-width: 0;
max-width: 590px;
}
@@ -298,6 +299,8 @@ body[data-language="zh"] .hero h1 {
.hero-title-line {
display: block;
+ max-width: 100%;
+ overflow-wrap: break-word;
}
.hero-accent {
@@ -1805,10 +1808,14 @@ body.dialog-open {
}
.site-header {
+ grid-template-columns: minmax(0, 1fr) auto;
height: 64px;
}
.header-download {
+ justify-content: center;
+ width: 40px;
+ flex: 0 0 40px;
padding: 0 12px;
}
@@ -1817,7 +1824,7 @@ body.dialog-open {
}
.header-download-compact {
- display: inline;
+ display: none;
}
.hero {
@@ -1825,6 +1832,18 @@ body.dialog-open {
padding: 48px 0 56px;
}
+ .hero-copy,
+ .hero-system {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ }
+
+ .hero-copy > *,
+ .hero-system > * {
+ max-width: 100%;
+ }
+
.hero h1 {
font-size: clamp(29px, 7.5vw, 42px);
}
diff --git a/examples/frontstage-share-bundle-smoke.mjs b/examples/frontstage-share-bundle-smoke.mjs
index 7a3644bd1d..e9304c0c33 100644
--- a/examples/frontstage-share-bundle-smoke.mjs
+++ b/examples/frontstage-share-bundle-smoke.mjs
@@ -333,13 +333,14 @@ for (const promptContract of [
"no-clone installer",
"loopx doctor",
"loopx connect/bootstrap",
- "project connection status, current user gate, top agent todo",
- "next safe action, and available commands",
- "/loopx ",
+ "one natural-language goal",
"/loopx ",
"concise ordered plan",
- "P0/P1/P2 todos",
+ "save that plan as ordered LoopX Todos",
+ "read back the current work, any decision that needs me, and the next safe action",
+ "Keep LoopX internal terms behind advanced or debugging explanations",
"把当前项目接入 LoopX",
+ "把计划按顺序保存为 LoopX 待办",
"下一步安全动作是什么",
]) {
if (!homepageSource.includes(promptContract)) {
diff --git a/examples/personal-workspace-browser/composer-session-admission.mjs b/examples/personal-workspace-browser/composer-session-admission.mjs
index c7ad6a0f8f..43413d1cfd 100644
--- a/examples/personal-workspace-browser/composer-session-admission.mjs
+++ b/examples/personal-workspace-browser/composer-session-admission.mjs
@@ -199,9 +199,28 @@ export const composerSessionAdmissionScenario = {
if (await composerInput.inputValue() !== nextManagerDraft) throw new Error("Returning lost the unsent request's text");
await openGoalChat();
if (new Set(adjustments.slice(0, 3).map(row => row.client_ingress_id)).size !== 1) throw new Error("Uncertain retries minted new ingress identities");
- await composerInput.fill(instruction);
+ for (let attempt = 0; attempt < 20 && await composerInput.inputValue() !== instruction; attempt += 1) {
+ await composerInput.fill(instruction);
+ await page.waitForTimeout(50);
+ }
+ if (await composerInput.inputValue() !== instruction) {
+ const statuses = await page.getByRole("status").allTextContents().catch(() => []);
+ throw new Error(`Confirmed non-delivery retry could not stage its draft: statuses=${JSON.stringify(statuses)} draft=${JSON.stringify(await composerInput.inputValue())}`);
+ }
+ if (await sendButton.isDisabled()) throw new Error("Confirmed non-delivery retry was blocked before it reached steering");
await composerInput.press("Meta+Enter");
- await page.getByRole("status").filter({ hasText: "本次未送达" }).waitFor();
+ for (let attempt = 0; attempt < 100 && adjustments.length < 4; attempt += 1) await page.waitForTimeout(50);
+ if (adjustments.length < 4) {
+ const statuses = await page.getByRole("status").allTextContents().catch(() => []);
+ throw new Error(`Confirmed non-delivery retry did not reach steering: adjustments=${JSON.stringify(adjustments)} statuses=${JSON.stringify(statuses)} draft=${JSON.stringify(await composerInput.inputValue())}`);
+ }
+ try {
+ await page.getByRole("status").filter({ hasText: "本次未送达" }).waitFor();
+ } catch (error) {
+ const statuses = await page.getByRole("status").allTextContents().catch(() => []);
+ const feedbacks = await page.locator(".personal-action-feedback").allTextContents().catch(() => []);
+ throw new Error(`Confirmed non-delivery feedback did not render: adjustments=${JSON.stringify(adjustments)} statuses=${JSON.stringify(statuses)} feedbacks=${JSON.stringify(feedbacks)} draft=${JSON.stringify(await composerInput.inputValue())}`, { cause: error });
+ }
await composerInput.press("Meta+Enter");
await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor();
if (adjustments[3].client_ingress_id === adjustments[4].client_ingress_id) throw new Error("Confirmed non-delivery could not retry after recovery");
diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs
index bf5b85bc7e..7a2794865d 100644
--- a/examples/personal-workspace-browser/conversation-activity.mjs
+++ b/examples/personal-workspace-browser/conversation-activity.mjs
@@ -24,7 +24,27 @@ export const conversationActivityScenario = {
response.on("close", () => { if (streams.get(request.url) === response) streams.delete(request.url); });
});
await new Promise(resolveListen => server.listen(0, "127.0.0.1", resolveListen));
- const context = await openWorkspacePage(browser, url, { collectCoverage });
+ const context = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: async (_api, page) => {
+ await page.addInitScript(() => {
+ window.__loopxSetSessionStorageFailure = message => {
+ if (!window.__loopxNativeSetItem) window.__loopxNativeSetItem = Storage.prototype.setItem;
+ const failForSessionStorage = function setItem(key, value) {
+ if (this === window.sessionStorage) throw new Error(message);
+ return window.__loopxNativeSetItem.call(this, key, value);
+ };
+ Storage.prototype.setItem = failForSessionStorage;
+ Object.defineProperty(window.sessionStorage, "setItem", {
+ configurable: true,
+ value: () => { throw new Error(message); },
+ });
+ };
+ window.__loopxRestoreSessionStorage = () => {
+ delete window.sessionStorage.setItem;
+ if (window.__loopxNativeSetItem) Storage.prototype.setItem = window.__loopxNativeSetItem;
+ delete window.__loopxNativeSetItem;
+ };
+ });
+ }});
const { page, api } = context;
try {
for (const pattern of ["**/events/**", "**/api/chat/sessions/*/turns/*/events"]) {
@@ -103,6 +123,38 @@ export const conversationActivityScenario = {
const composer = page.getByLabel("向 LoopX 发送消息");
const submit = page.getByRole("button", { name: "发送", exact: true });
const feedback = page.locator(".personal-action-feedback");
+ const makeStorageUnavailable = reason => page.evaluate(message => {
+ window.__loopxSetSessionStorageFailure(message);
+ }, reason);
+ const restoreStorage = () => page.evaluate(() => {
+ window.__loopxRestoreSessionStorage();
+ });
+ const assertStorageUnavailable = async reason => {
+ const message = await page.evaluate(() => {
+ try {
+ window.sessionStorage.setItem("loopx-storage-probe", "1");
+ return "";
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+ });
+ assert.equal(message, reason);
+ };
+ const waitForFeedback = async (text, detail) => {
+ await feedback.filter({ hasText: text }).waitFor({timeout: 3000}).catch(async error => {
+ const storage = await page.evaluate(() => {
+ let probe = "";
+ try {
+ window.sessionStorage.setItem("loopx-storage-probe-after", "1");
+ probe = "ok";
+ } catch (cause) {
+ probe = cause instanceof Error ? cause.message : String(cause);
+ }
+ return { probe, steering: window.sessionStorage.getItem("loopx-pw-composer-steering") };
+ });
+ throw new Error(`${detail}: ${error.message}; feedback=${JSON.stringify(await feedback.allTextContents())}; adjustments=${adjustments.length}; turn_requests=${api.turnRequests.length}; send_disabled=${await submit.isDisabled()}; composer_value=${JSON.stringify(await composer.inputValue())}; active=${await page.evaluate(() => document.activeElement?.id ?? document.activeElement?.tagName)}; storage=${JSON.stringify(storage)}`);
+ });
+ };
await pending.getByRole("button", { name: "调整本轮", exact: true }).click();
assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Adjust focuses the shared composer");
assert.equal(await page.locator("textarea:visible").count(), 1, "Only one editable conversation input is visible");
@@ -157,6 +209,40 @@ export const conversationActivityScenario = {
const correctionIndex = conversation.findIndex(row => row.user && row.text.includes("先核对依赖"));
assert.ok(originalIndex >= 0 && originalIndex < workIndex && workIndex < correctionIndex,
"The request, ongoing work and accepted correction remain together in conversation order");
+ await pending.getByRole("button", { name: "调整本轮", exact: true }).click();
+ await makeStorageUnavailable("storage unavailable");
+ await assertStorageUnavailable("storage unavailable");
+ await composer.fill("先检查最新证据。");
+ await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click();
+ await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
+ assert.equal(await composer.inputValue(), "先检查最新证据。", "Blocked storage preserves the page draft across manager views");
+ const dispatchedBeforeStorageFailure = adjustments.length;
+ const turnsBeforeStorageFailure = api.turnRequests.length;
+ await composer.focus();
+ await page.keyboard.press("Meta+Enter");
+ await waitForFeedback("调整尚未发送", "Storage failure did not report a retained steering draft");
+ assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch steering");
+ assert.equal(api.turnRequests.length, turnsBeforeStorageFailure, "Storage failure cannot queue a new conversation Turn");
+ assert.equal(await composer.inputValue(), "先检查最新证据。");
+ await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"});
+ await restoreStorage();
+ await composer.fill("先检查最新证据,再继续。");
+ await composer.focus();
+ await page.keyboard.press("Meta+Enter");
+ await feedback.filter({ hasText: "原回合指令未确认" }).waitFor();
+ const durableRequest = adjustments.at(-1);
+ await makeStorageUnavailable("QuotaExceededError");
+ await assertStorageUnavailable("QuotaExceededError");
+ await composer.fill("另一个尚未保存的调整。");
+ await submit.click();
+ await feedback.filter({ hasText: "上条投递尚未确认" }).waitFor();
+ assert.equal(adjustments.length, dispatchedBeforeStorageFailure + 1, "A changed draft cannot replace an uncertain request");
+ await composer.fill("先检查最新证据,再继续。");
+ await submit.click();
+ await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor();
+ assert.equal(adjustments.at(-1).client_ingress_id, durableRequest.client_ingress_id, "A storage failure cannot replace an existing durable retry identity");
+ await restoreStorage();
+ const adjustmentsAfterStorageFailure = adjustments.length;
await pending.locator("summary").filter({ hasText: "最近活动" }).click();
assert.deepEqual((await pending.locator(".personal-message-activity li").allTextContents()).slice(-3), ["Agent 正在执行命令", "Agent 正在检索", "Agent 正在执行命令"]);
await page.screenshot({ path: resolve(outputDir, "conversation-activity-desktop.png"), animations: "disabled" });
@@ -239,7 +325,7 @@ export const conversationActivityScenario = {
await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click();
await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
assert.equal(await composer.inputValue(), "保留我的未发送草稿。", "finished turn retains the unsent draft across unmount");
- assert.equal(adjustments.length, 4, "Completion never dispatches the retained draft");
+ assert.equal(adjustments.length, adjustmentsAfterStorageFailure, "Completion never dispatches the retained draft");
await page.unroute("**/interrupt");
// The same interaction is present in Goal Chat, through the same timeline.
@@ -319,7 +405,7 @@ export const conversationActivityScenario = {
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
await page.waitForFunction(() => document.querySelector("#personal-conversation-composer")?.value === "旧版保留的原回合草稿。");
- assert.equal(adjustments.length, 4, "Migrating an old draft never dispatches it");
+ assert.equal(adjustments.length, adjustmentsAfterStorageFailure, "Migrating an old draft never dispatches it");
const migrated = await page.evaluate(() => JSON.parse(sessionStorage.getItem("loopx-pw-composer-steering"))
.filter(([, request]) => request.id === "legacy-exact-ingress"));
assert.equal(migrated.length, 1, "One retry owner remains after durable migration");
@@ -329,32 +415,6 @@ export const conversationActivityScenario = {
await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor();
assert.equal(adjustments.at(-1).client_ingress_id, "legacy-exact-ingress");
await composer.fill("");
- await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("storage unavailable"); }; });
- await composer.fill("先检查最新证据。");
- 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 composer.inputValue(), "先检查最新证据。", "Blocked storage preserves the page draft across views");
- const dispatchedBeforeStorageFailure = adjustments.length;
- await composer.press("Meta+Enter");
- await feedback.filter({ hasText: "调整尚未发送" }).waitFor();
- assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch steering");
- assert.equal(await composer.inputValue(), "先检查最新证据。");
- await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"});
- await page.evaluate(() => { delete window.sessionStorage.setItem; });
- await composer.fill("先检查最新证据,再继续。");
- await composer.press("Meta+Enter");
- await feedback.filter({ hasText: "原回合指令未确认" }).waitFor();
- const durableRequest = adjustments.at(-1);
- await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("QuotaExceededError"); }; });
- await composer.fill("另一个尚未保存的调整。");
- await submit.click();
- await feedback.filter({ hasText: "上条投递尚未确认" }).waitFor();
- assert.equal(adjustments.length, dispatchedBeforeStorageFailure + 1, "A changed draft cannot replace an uncertain request");
- await composer.fill("先检查最新证据,再继续。");
- await submit.click();
- await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor();
- assert.equal(adjustments.at(-1).client_ingress_id, durableRequest.client_ingress_id, "A storage failure cannot replace an existing durable retry identity");
- await page.evaluate(() => { delete window.sessionStorage.setItem; });
await page.getByRole("button", { name: "中断本轮", exact: true }).click();
await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor();
await page.locator(".personal-chat-activity").waitFor({state: "hidden"});
diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs
index 046b031a2b..1b84b840e8 100644
--- a/examples/personal-workspace-browser/fixture.mjs
+++ b/examples/personal-workspace-browser/fixture.mjs
@@ -1466,7 +1466,11 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu
const answer = (typeof scriptedAnswer === "object" ? scriptedAnswer?.message : scriptedAnswer)
|| "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。";
await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(turnMessages.get(turnId) ?? "") ? 5000 : 1200));
- await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 });
+ try {
+ await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 });
+ } catch (error) {
+ if (!/Route is already handled/u.test(String(error?.message ?? error))) throw error;
+ }
return;
}
if (url.pathname === "/api/chat/goals/contexts") {
diff --git a/examples/personal-workspace-browser/goal-hierarchy.mjs b/examples/personal-workspace-browser/goal-hierarchy.mjs
index ff211a1234..b8fca40394 100644
--- a/examples/personal-workspace-browser/goal-hierarchy.mjs
+++ b/examples/personal-workspace-browser/goal-hierarchy.mjs
@@ -143,6 +143,21 @@ export const goalHierarchyScenario = {
"opening a scoped workspace reads its recursive subtree without opening unrelated Goal state");
assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research");
assertResearchFocus();
+ const scopedCalibration = page.url();
+ const unscopedGraphBookmark = new URL(scopedCalibration);
+ unscopedGraphBookmark.searchParams.delete("research_goal");
+ unscopedGraphBookmark.searchParams.delete("research_analysis_goal");
+ await page.goto(unscopedGraphBookmark.href, {waitUntil: "networkidle"});
+ await row("graph").locator(".personal-goal-link").click();
+ await page.waitForURL(target => target.searchParams.get("goalId") === "graph");
+ const unscopedQuery = new URL(page.url()).searchParams;
+ assert.equal(unscopedQuery.get("research_lens"), null,
+ "switching Goals clears an unscoped research lens instead of applying it to the destination Goal");
+ assert.equal(unscopedQuery.get("research_node"), null,
+ "switching Goals clears unscoped research node bookmarks together with the lens");
+ await page.goto(scopedCalibration, {waitUntil: "networkidle"});
+ await row("calibration").locator('[aria-current="page"]').waitFor();
+ assertResearchFocus();
await assertTreeLayout();
await page.screenshot({path: resolve(outputDir, "goal-hierarchy-focused.png"), animations: "disabled"});
await page.getByRole("button", {name: "调整 Goal 顺序", exact: true}).click();
diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs
index 6eb99eaf65..7923aa7192 100644
--- a/examples/personal-workspace-browser/research-history.mjs
+++ b/examples/personal-workspace-browser/research-history.mjs
@@ -795,7 +795,7 @@ export const researchHistoryScenario = {
assert.equal(await analysis.getByRole('tab', {name: /下一步实验/}).getAttribute('aria-selected'), 'true');
await synthesisPage.setViewportSize({width: 1280, height: 900});
const packBeforeHistory = new URL(synthesisPage.url()).searchParams.get('research_pack');
- await analysis.getByRole('button', {name: 'Goal 研究历程', exact: true}).click();
+ await analysis.getByRole('button', {name: '研究历程', exact: true}).click();
assert.equal(await analysis.count(), 0);
assert.equal(new URL(synthesisPage.url()).searchParams.get('research_pack'), packBeforeHistory);
await synthesisPage.waitForFunction(() => document.activeElement?.classList.contains('research-history-heading'));
@@ -807,12 +807,13 @@ export const researchHistoryScenario = {
await synthesisPage.reload({waitUntil: 'domcontentloaded'});
await synthesisPage.setViewportSize({width: 390, height: 844});
await synthesisPage.getByRole('button', {name: 'Read deeper', exact: true}).click();
- const english = synthesisPage.locator('.research-analysis');
+ const english = synthesisPage.getByRole('region', {name: 'Trajectory analysis', exact: true});
+ await english.waitFor();
assert.match(await english.locator('.analysis-report-meta').innerText(), /3 research episodes.*Purposive sample/);
await english.getByRole('button', {name: 'Analysis scope', exact: true}).focus();
await synthesisPage.getByRole('tooltip').getByText('Agent analysis · uncalibrated', {exact: true}).waitFor();
await synthesisPage.keyboard.press('Escape');
- assert(await english.getByRole('button', {name: 'Goal research history', exact: true}).isVisible());
+ assert(await english.getByRole('button', {name: 'Research history', exact: true}).isVisible());
const englishAccount = english.locator('.research-report-account').first();
assert(await englishAccount.getByRole('heading', {name: 'Observation and evidence', exact: true}).isVisible());
assert(await englishAccount.getByRole('heading', {name: 'Interpretation', exact: true}).isVisible());
diff --git a/examples/personal-workspace-browser/research-turn-costs.mjs b/examples/personal-workspace-browser/research-turn-costs.mjs
index 23582e2d0a..49b13cebec 100644
--- a/examples/personal-workspace-browser/research-turn-costs.mjs
+++ b/examples/personal-workspace-browser/research-turn-costs.mjs
@@ -61,7 +61,10 @@ Path(sys.argv[1]).write_text(json.dumps(dict(goal_id=goal,display_name='Syntheti
assert.match(await items.first().innerText(), /2,000/);
await costs.getByRole('combobox').selectOption('elapsed');
assert.match(await items.first().innerText(), /第 7 轮.*未完成/s);
- await items.first().getByRole('button').click();
+ const turnCostEvidence = items.first().getByRole('button');
+ const turnCostEvidenceHandle = await turnCostEvidence.elementHandle();
+ assert(turnCostEvidenceHandle);
+ await turnCostEvidence.click();
const source = page.getByRole('region', {name: '引用原文', exact: true});
await source.locator('pre').waitFor();
assert.match(await source.innerText(), /turn-7/);
@@ -69,7 +72,7 @@ Path(sys.argv[1]).write_text(json.dumps(dict(goal_id=goal,display_name='Syntheti
assert.match(await source.innerText(), /token_count/);
assert.ok(!(await source.innerText()).includes('SENSITIVE_TEXT_NOT_FOR_BROWSER'));
await source.focus(); await page.keyboard.press('Escape');
- assert.equal(await items.first().getByRole('button').evaluate(element => element === document.activeElement), true);
+ await page.waitForFunction(element => element === document.activeElement, turnCostEvidenceHandle);
await costs.getByRole('combobox').selectOption('time');
await costs.getByRole('button', {name: '下一页', exact: true}).click();
assert.equal(await items.count(), 3);
diff --git a/examples/personal-workspace-browser/research-worker-readback.mjs b/examples/personal-workspace-browser/research-worker-readback.mjs
index 5d75523b95..c5c8f08fb1 100644
--- a/examples/personal-workspace-browser/research-worker-readback.mjs
+++ b/examples/personal-workspace-browser/research-worker-readback.mjs
@@ -36,7 +36,8 @@ export async function startAuthority(mode) {
lines.once("line", line => { clearTimeout(timeout); accept(JSON.parse(line)); });
});
assert.ok(Number.isSafeInteger(value.port) && value.port > 0);
- return {...value, url: `http://127.0.0.1:${value.port}`, close};
+ return {...value, url: `http://127.0.0.1:${value.port}`, root,
+ diagnostic: () => diagnostic, exited: () => ({exitCode: child.exitCode, signalCode: child.signalCode}), close};
} catch (error) { await close(); throw error; }
}
@@ -76,7 +77,12 @@ export const researchWorkerReadbackScenario = {
writes += 1;
writePaths.push(path);
}
- const response = await fetchWorkspaceBackend(route, authority.url);
+ let response;
+ try {
+ response = await fetchWorkspaceBackend(route, authority.url);
+ } catch (error) {
+ throw new Error(`Worker backend proxy failed: root=${authority.root} child=${JSON.stringify(authority.exited())} stderr=${authority.diagnostic()}`, {cause: error});
+ }
if (emptySession) sessionPreparations.push({status: response.status(), payload: await response.json()});
if (response.status() >= 400) failedReads.push({path: new URL(route.request().url()).pathname,
status: response.status(), body: (await response.text()).slice(0, 800)});
diff --git a/examples/personal-workspace-browser/scenario-context.mjs b/examples/personal-workspace-browser/scenario-context.mjs
index 93562ede9c..0f49f75046 100644
--- a/examples/personal-workspace-browser/scenario-context.mjs
+++ b/examples/personal-workspace-browser/scenario-context.mjs
@@ -2,7 +2,7 @@ import { installApi } from "./fixture.mjs";
// A disposable backend has a different port from the browser fixture. Rebase
// same-origin requests with it; foreign origins must still reach the real guard.
-export function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) {
+export async function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) {
const request = route.request();
const source = new URL(request.url());
const target = new URL(backendUrl);
@@ -10,7 +10,17 @@ export function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) {
target.search = source.search;
const headers = { ...request.headers(), host: target.host };
if (headers.origin === source.origin) headers.origin = target.origin;
- return route.fetch({ url: target.href, headers });
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await route.fetch({ url: target.href, headers });
+ } catch (error) {
+ const retryable = ["GET", "HEAD"].includes(request.method())
+ && /\b(?:ECONNRESET|ECONNREFUSED)\b/u.test(String(error?.message ?? error))
+ && attempt < 2;
+ if (!retryable) throw error;
+ await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)));
+ }
+ }
}
export async function openWorkspacePage(