Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8de8dda
fix(acceptance): preserve work bindings after completion checkpoints
loopx-agent Oct 4, 2026
891170d
fix(chat): retain the reading path through related evidence
loopx-agent Oct 4, 2026
d3b6d71
fix(chat): disclose missing independent verification evidence
loopx-agent Oct 4, 2026
80e320e
fix(chat): reconcile current executions when returning from evidence
loopx-agent Oct 4, 2026
98dc463
docs(chat): show current evidence returns and acceptance gaps
loopx-agent Oct 4, 2026
c195b6e
fix(chat): preserve current correction when adoption is unavailable
loopx-agent Oct 4, 2026
7c6e2eb
docs(chat): show bounded correction recovery and its evidence gaps
loopx-agent Oct 4, 2026
4c05704
feat(chat): expose the current validation basis with accepted evidence
loopx-agent Oct 4, 2026
defa21c
docs(chat): distinguish validation basis from independent review
loopx-agent Oct 4, 2026
bffc35b
fix(coordination): make isolated code file overlaps advisory
loopx-agent Oct 4, 2026
98312bd
docs(coordination): distinguish code integration from shared write fe…
loopx-agent Oct 4, 2026
004dbc9
test(refresh): keep write preview within the history-only contract
loopx-agent Oct 4, 2026
8dc96f7
fix(workspace): restore task inspector pointer focus
loopx-agent Oct 4, 2026
a187eb8
fix(frontend): retain full task requirements in details
loopx-agent Oct 4, 2026
ecc64a1
test(frontend): run evidence return checks in packaged suite
loopx-agent Oct 4, 2026
93a55c9
fix(workspace): read full task requests from their authority on demand
loopx-agent Oct 5, 2026
05ba74e
test(workspace): cover complete task reads and drawer recovery
loopx-agent Oct 5, 2026
b7763c1
fix(workspace): integrate current origin and scope protections
loopx-agent Oct 5, 2026
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
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"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: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",
"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",
"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
176 changes: 176 additions & 0 deletions apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// A reader can follow version evidence and return without rediscovering the work.
// Uses the packaged UI and existing synthetic API fixture; no live Goal/model writes.
import assert from "node:assert/strict";
import {mkdir} from "node:fs/promises";
import {resolve} from "node:path";
import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs";

process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1";
const {outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs");
const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs");
let server, browser, workspace;
try {
server = await startServer();
const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`;
await waitForHttp(url);
browser = await launchBrowser(loadPlaywright().chromium);
workspace = await openWorkspacePage({newPage: options => browser.newPage({locale: "zh-CN", ...options})}, url);
const {page, api} = workspace;
await page.locator(".personal-goal-link", {hasText: "Product Release"}).click();
await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "对话", exact: true}).click();
await page.getByRole("button", {name: "开启 LoopX 模式", exact: true}).click();
await page.getByLabel("已注册的协调身份").selectOption("lead");
await page.getByLabel("协调员总 token 额度").fill("100000");
await page.getByRole("button", {name: "保存设置", exact: true}).click();
const configured = api.loopxModeRequests.findLast(row => row.operation === "configure");
const mode = page.__loopxRuntime.loopxModes.get(configured.sessionId);
Object.assign(mode, {enabled: true, paused: false, active_turn_id: "fixture-loopx-turn",
native: {status: "active", tokenBudget: 100000}, fixtureCorrectionEpisode: true, fixtureAdoptionState: "current"});
await page.getByText("LoopX · 正在推进", {exact: true}).waitFor();
await page.getByRole("button", {name: "团队执行情况", exact: true}).click();
const dialog = page.getByRole("dialog", {name: "团队执行情况"});
const openEvidence = dialog.getByRole("button", {name: "查看证据与反馈", exact: true});
await openEvidence.first().click();
const evidence = dialog.getByRole("region", {name: "执行证据"});
const original = () => evidence.getByLabel("证据内容: report.json");
await original().waitFor();
// Older readbacks remain readable without invented provenance.
await evidence.getByText("本次验收依据", {exact: true}).click();
await evidence.getByText("此运行时未提供验收依据标识。", {exact: true}).waitFor();
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
const verificationGap = evidence.getByText("当前读回未提供独立验收者与指定版本回执。", {exact: true});
await verificationGap.waitFor({timeout: 3000});
await evidence.getByText("后续结果 · 当前验收与采用记录有效", {exact: false}).waitFor();
await mkdir(outputDir, {recursive: true});
await verificationGap.scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, "team-verifier-gap-desktop.png"), animations: "disabled"});
await page.setViewportSize({width: 390, height: 844});
await page.emulateMedia({reducedMotion: "reduce"});
assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
await verificationGap.scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"});
await page.setViewportSize({width: 1512, height: 980});
// Losing an optional adoption cannot erase freshly verified correction evidence.
const unavailableDownstream = route => route.request().postDataJSON()?.operation === "read"
&& route.request().postDataJSON()?.operation_id === "accepted-synthesis"
? route.fulfill({status: 503, json: {error: "downstream observation unavailable"}}) : route.fallback();
await page.route("**/api/chat/sessions/*/loopx", unavailableDownstream);
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
const adoptionGap = evidence.getByText("采用证据无法核验", {exact: false});
await adoptionGap.waitFor({timeout: 3000});
await verificationGap.waitFor();
assert.equal(await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).count(), 1);
assert.equal(await evidence.getByRole("button", {name: "阅读回应与证据", exact: true}).count(), 1);
assert.equal(await evidence.getByRole("button", {name: "阅读后续结果", exact: true}).count(), 0);
await page.screenshot({path: resolve(outputDir, "team-adoption-unavailable-desktop.png"), animations: "disabled"});
await page.setViewportSize({width: 390, height: 844});
assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
await adoptionGap.scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, "team-adoption-unavailable-mobile.png"), animations: "disabled"});
await page.setViewportSize({width: 1512, height: 980});
await page.unroute("**/api/chat/sessions/*/loopx", unavailableDownstream);
// Revoke the refreshed owner receipt after this reader's earlier current observation.
mode.fixtureAdoptionState = "unavailable";
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
await adoptionGap.waitFor({timeout: 3000});
assert.equal(await evidence.getByRole("button", {name: "阅读后续结果", exact: true}).count(), 0);
await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click();
await original().waitFor();
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
await adoptionGap.waitFor({timeout: 3000});
mode.fixtureAdoptionState = "current";
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
await evidence.getByRole("button", {name: "阅读后续结果", exact: true}).waitFor();
const unavailableCore = route => route.request().postDataJSON()?.operation === "read"
&& route.request().postDataJSON()?.operation_id === "review-objection"
? route.fulfill({status: 409, json: {error: "review version revoked"}}) : route.fallback();
await page.route("**/api/chat/sessions/*/loopx", unavailableCore);
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
await evidence.getByRole("alert").filter({hasText: "关联执行或版本已变化"}).waitFor();
assert.equal(await verificationGap.count(), 0, "A lost core revision still clears the trace");
assert.equal(await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).count(), 0);
await page.unroute("**/api/chat/sessions/*/loopx", unavailableCore);
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
await evidence.getByRole("button", {name: "阅读回应与证据", exact: true}).click();
await evidence.getByLabel("证据内容: objection.json").waitFor();
await evidence.getByRole("button", {name: "original-analysis", exact: true}).click();
await original().waitFor();
assert.match(await original().textContent(), /cash_flow.*90/);
const back = dialog.getByRole("button", {name: "返回上一份证据", exact: true});
await back.waitFor({timeout: 3000});
await page.keyboard.press("Enter");
await evidence.getByLabel("证据内容: objection.json").waitFor();
assert.ok(await back.evaluate(el => el === document.activeElement), "Returning retains keyboard navigation in evidence");
const readsBefore = api.loopxModeRequests.filter(row => row.operation === "read").length;
await back.click();
await original().waitFor();
assert.match(await original().textContent(), /cash_flow.*75/);
assert.equal(api.loopxModeRequests.filter(row => row.operation === "read").length, readsBefore + 1,
"Return rechecks the original operation instead of replaying cached acceptance");
assert.equal(await back.count(), 0, "Root evidence returns to the execution list");
await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click();
await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).click();
await original().waitFor();
await mkdir(outputDir, {recursive: true});
await page.screenshot({path: resolve(outputDir, "team-evidence-return-desktop.png"), animations: "disabled"});
await page.setViewportSize({width: 390, height: 844});
await page.emulateMedia({reducedMotion: "reduce"});
assert.ok(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
await page.screenshot({path: resolve(outputDir, "team-evidence-return-mobile.png"), animations: "disabled"});
const failedReturn = async route => {
const body = route.request().method() === "POST" ? route.request().postDataJSON() : {};
if (body.operation === "read" && body.operation_id === "accepted-analysis") {
return route.fulfill({status: 409, json: {error: "original acceptance revoked"}});
}
if (body.operation === "operations") {
return route.fulfill({json: {items: [{record_id: "a".repeat(64), operation_id: "accepted-analysis",
status: "unavailable", recovery_required: null}], has_more: false, next_cursor: null, page_readback_complete: false}});
}
return route.fallback();
};
await page.route("**/api/chat/sessions/*/loopx", failedReturn);
await back.click();
await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor();
assert.equal(await original().count(), 0, "Returning after revocation cannot restore the old report");
assert.equal(await verificationGap.count(), 0, "Unavailable evidence cannot retain a prior correction trace");
await dialog.getByRole("button", {name: "返回执行列表", exact: true}).click();
await dialog.locator('.goal-team-record[data-state="unavailable"]').waitFor({timeout: 3000});
assert.equal(await dialog.locator('.goal-team-pulse [data-bucket="accepted"] strong').textContent(), "0",
"Returning to the list must withdraw accepted counts when current evidence is unavailable");
assert.ok(await openEvidence.first().evaluate(el => el === document.activeElement));
await page.unroute("**/api/chat/sessions/*/loopx", failedReturn);
const refresh = dialog.getByRole("button", {name: "重新核验", exact: true});
await refresh.click();
await dialog.locator('.goal-team-record[data-state="accepted"]').waitFor();
await openEvidence.first().click();
await original().waitFor();
const failedList = async route => route.request().postDataJSON()?.operation === "operations"
? route.fulfill({status: 503, json: {error: "delegation inventory unavailable"}}) : route.fallback();
await page.route("**/api/chat/sessions/*/loopx", failedList);
await dialog.getByRole("button", {name: "返回执行列表", exact: true}).click();
await dialog.getByRole("alert").filter({hasText: "delegation inventory unavailable"}).waitFor();
assert.equal(await dialog.locator(".goal-team-pulse").count(), 0, "Failed readback cannot retain cached counts");
assert.equal(await openEvidence.count(), 0, "Failed readback cannot retain cached execution records");
assert.ok(await refresh.evaluate(el => el === document.activeElement), "Unavailable list returns focus to recovery");
await page.unroute("**/api/chat/sessions/*/loopx", failedList);
await refresh.click();
await dialog.locator('.goal-team-record[data-state="accepted"]').waitFor();
await dialog.getByRole("button", {name: "下一页", exact: true}).click();
await dialog.locator('.goal-team-record[data-state="recovery_required"]').waitFor();
await openEvidence.first().click();
await evidence.waitFor();
const listReadsBefore = api.loopxModeRequests.filter(row => row.operation === "operations").length;
await dialog.getByRole("button", {name: "返回执行列表", exact: true}).click();
await dialog.locator('.goal-team-record[data-state="recovery_required"]').waitFor();
const listReads = api.loopxModeRequests.filter(row => row.operation === "operations");
assert.equal(listReads.length, listReadsBefore + 1, "List return performs one read, without polling");
assert.equal(listReads.at(-1).cursor, "b".repeat(64), "Return retains the original page instead of jumping to the first page");
assert.ok(await openEvidence.first().evaluate(el => el === document.activeElement));
assert.equal(api.turnRequests.length, 0);
assert.equal(api.loopxModeRequests.filter(row => row.operation === "message").length, 0);
console.log("team-evidence-return: passed (packaged navigation, downstream loss/revocation/restoration, core loss, keyboard return, fresh evidence/list, pagination, mobile and no execution)");
} finally {
await workspace?.close();
await browser?.close();
server?.kill("SIGTERM");
}
12 changes: 12 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,16 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
return parsedPayload as T;
}

export async function readTodoRequest(goalId: string, todoId: string, signal: AbortSignal) {
const query = new URLSearchParams({goal_id: goalId, todo_id: todoId});
const result = z.object({
ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(),
status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(),
}).parse(await requestJson<unknown>(`/api/chat/todo/detail?${query}`, {signal}));
if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed");
return result;
}

export async function fetchChatStatus() {
return chatStatusSchema.parse(await requestJson<unknown>("/status.json"));
}
Expand Down Expand Up @@ -1133,6 +1143,8 @@ export type DelegationReadback = {
operation_id: string; request_id: string; agent_id: string; todo_id: string;
status: string; worker_active: boolean; recovery_required: boolean;
artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string;
validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string;
check_count: number; pinned_file_count: number};
dependencies?: DelegationDependency[]; adoptions?: DelegationAdoption[];
};
export function readLoopXTeamWork(sessionId: string, operationId: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type { LarkGoalConnection } from "../../data/chat";
import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n";
import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue } from "./personal-workspace-model";
import { TeamPlanResult } from "./team-plan-result";
import { TaskRequest } from "./task-request";
import { parseTodoResumeCondition } from "./todo-resume-condition";
import { MarkdownText } from "./markdown";
import { formatMonitorDate } from "./monitor-readback";
Expand Down Expand Up @@ -654,7 +655,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.priority ? <span>{selection.item.priority}</span> : null}
<span>{selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")}</span>
</div>
<h3>{selection.item.text}</h3>
<TaskRequest todo={selection.item} local={!readOnly} />
</section>
<section aria-label={t("drawer.taskInfo")} className="personal-task-inspector-fields">
<h4>{t("drawer.taskInfo")}</h4>
Expand Down
Loading
Loading