diff --git a/apps/presentation/dashboard/smoke/assets/team-source-chain/before.png b/apps/presentation/dashboard/smoke/assets/team-source-chain/before.png new file mode 100644 index 0000000000..c7da1c7fff Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/team-source-chain/before.png differ diff --git a/apps/presentation/dashboard/smoke/assets/team-source-chain/withdrawn-desktop.png b/apps/presentation/dashboard/smoke/assets/team-source-chain/withdrawn-desktop.png new file mode 100644 index 0000000000..6ff5fedd50 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/team-source-chain/withdrawn-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/team-source-chain/withdrawn-mobile.png b/apps/presentation/dashboard/smoke/assets/team-source-chain/withdrawn-mobile.png new file mode 100644 index 0000000000..e6fe75c692 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/team-source-chain/withdrawn-mobile.png differ diff --git a/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py b/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py index 9f33bff49b..6adf541b67 100644 --- a/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py +++ b/apps/presentation/dashboard/smoke/team-adoption-http-fixture.py @@ -46,6 +46,19 @@ def main() -> None: "ref": "accepted-input.json", "description": "Accepted input", "sha256": artifact["sha256"], "delegation": {"operation_id": "analysis-1", "ref": artifact["ref"], "relation": "uses"}}]}) assert wait(runner, "synthesis-1")["status"] == "accepted" + source_chain = "--source-chain" in sys.argv + if source_chain: + downstream = runner.read("synthesis-1")["artifacts"][0] + config["bindings"].append({**config["bindings"][0], "id": "further", "agent_id": "analyst", + "todo_id": "todo_analyst-corrected", "workspace": str(root / "analyst/corrected"), + "output_refs": ["output.json", "report.md"]}) + runner.config.write_text(json.dumps(config)) + (root / "analyst/corrected/report.md").write_text("# Further result\n\nUses the exact accepted synthesis.\n") + (root / "analyst/corrected/accepted-input.json").write_text(downstream["text"]) + runner.start("further", "further-1", {**brief(), "inputs": [{ + "ref": "accepted-input.json", "description": "Accepted synthesis", "sha256": downstream["sha256"], + "delegation": {"operation_id": "synthesis-1", "ref": downstream["ref"], "relation": "uses"}}]}) + assert wait(runner, "further-1")["status"] == "accepted" repo = root / "project" executor = repo / ".loopx/config/delegations.json" executor.parent.mkdir(parents=True) @@ -79,12 +92,13 @@ def main() -> None: input_file.write_text(artifact["text"]) print(json.dumps({"restored": True}), flush=True) elif operation == "inspect": - observed = runner.read("analysis-1") + observed = runner.read("synthesis-1" if source_chain else "analysis-1") session = store.load_session("adoption") print(json.dumps({"result": observed, "session_unchanged": session == initial_session, "coordinator_paused": session["loopx_mode"]["paused"] is True, "host_invocations": [(root / workspace / "host-invocations").read_text() - for workspace in ("analyst/initial", "reviewer/corrected")]}), flush=True) + for workspace in (("analyst/initial", "reviewer/corrected", "analyst/corrected") + if source_chain else ("analyst/initial", "reviewer/corrected"))]}), flush=True) else: raise ValueError("Unknown fixture command") finally: diff --git a/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs b/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs index 267cfbc097..d25c525381 100644 --- a/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-adoption-recovery-smoke.mjs @@ -12,7 +12,9 @@ import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/d process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; const {repoRoot, outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs"); const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs"); -const fixture = spawn(resolveTestPython({repoRoot}), ["-u", "apps/presentation/dashboard/smoke/team-adoption-http-fixture.py"], +const sourceChain = process.argv.includes("--source-chain"); +const fixture = spawn(resolveTestPython({repoRoot}), ["-u", "apps/presentation/dashboard/smoke/team-adoption-http-fixture.py", + ...(sourceChain ? ["--source-chain"] : [])], {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]}); const exited = once(fixture, "exit"); const lines = createInterface({input: fixture.stdout}); @@ -57,6 +59,48 @@ try { }); Object.assign(mode, {enabled: true, paused: true, active_turn_id: null, native: {status: "paused", tokenBudget: 100000}}); const results = page.getByRole("region", {name: "团队成果", exact: true}); + if (sourceChain) { + // The displayed source is itself a consumer. Its domain output remains + // accepted when its input is withdrawn; current_use must still fence UI use. + await results.getByRole("button", {name: "reviewer · report.md", exact: true}).click(); + await results.locator(".goal-team-result-reader > details > summary").click(); + const adoption = results.locator(".goal-team-adoption"); + await adoption.locator("summary").click(); + await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click(); + await adoption.getByLabel("后续结果", {exact: true}).waitFor(); + await results.getByLabel("当前报告", {exact: true}).waitFor(); + const before = await command("inspect"); + assert.equal(before.result.current_use.state, "current"); + await command("withdraw"); + const unavailable = await command("inspect"); + assert.equal(unavailable.result.status, "accepted", "Historical acceptance stays intact"); + assert.equal(unavailable.result.current_use.state, "unavailable"); + assert.deepEqual(unavailable.result.artifacts, before.result.artifacts, "Same output bytes, different use eligibility"); + await adoption.getByRole("button", {name: "阅读后续结果", exact: true}).click(); + await page.waitForFunction(() => !document.querySelector(".goal-team-adoption") + || document.querySelector(".goal-team-adoption").getAttribute("aria-busy") === "false"); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "team-source-chain-real-observation.png"), animations: "disabled"}); + await results.getByRole("alert").filter({hasText: "产物或验收已变化"}).waitFor(); + assert.equal(await results.getByLabel("当前报告", {exact: true}).count(), 0); + assert.equal(writes.length, 0, "A refused source read cannot offer or submit adoption"); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "team-source-chain-real-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); await page.emulateMedia({reducedMotion: "reduce"}); + assert.ok(await results.evaluate(el => el.scrollWidth <= el.clientWidth)); + await page.screenshot({path: resolve(outputDir, "team-source-chain-real-mobile.png"), animations: "disabled"}); + await command("restore"); + await results.getByRole("button", {name: "刷新成果", exact: true}).focus(); + await page.keyboard.press("Enter"); + await results.getByLabel("当前报告", {exact: true}).waitFor(); + const recovered = await command("inspect"); + assert.equal(recovered.result.current_use.state, "current"); + assert.ok(recovered.session_unchanged); + assert.deepEqual(recovered.host_invocations, ["1", "1", "1"]); + assert.equal(writes.length, 0); + assert.equal(api.turnRequests.length, 0); + console.log("team-source-chain: passed (packaged real HTTP/SQLite, historical output retained, source input withdrawal/refusal, keyboard recovery, zero adoption/dispatch)"); + } else { await results.getByRole("button", {name: "analyst · output.json", exact: true}).click(); await results.locator(".goal-team-result-reader > details > summary").click(); const adoption = results.locator(".goal-team-adoption"); @@ -104,6 +148,7 @@ try { assert.equal(writes.length, 1, "Readback recovery never records a new adoption"); assert.equal(api.turnRequests.length, 0); console.log("team-adoption-recovery: passed (packaged report, real HTTP/SQLite/checks/receipts, explicit decision, lost ACK, exact use withdrawal/recovery, no dispatch, mobile/keyboard)"); + } } catch (error) { if (workspace) console.error({body: (await workspace.page.locator("body").innerText()).slice(-3000), errors: workspace.errors}); throw error; diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs index 79deb7af3d..49447a3e05 100644 --- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -133,6 +133,53 @@ try { await verificationGap.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "team-verifier-gap-mobile.png"), animations: "disabled"}); await page.setViewportSize({width: 1512, height: 980}); + // A completed source can retain its output bytes while its dependency + // chain becomes unavailable. Reading an adoption candidate must withdraw + // the earlier correction trace and must never offer a confirmation. + const adoption = evidence.locator(".goal-team-adoption"); + await evidence.getByText("版本与采用关系详情", {exact: true}).click(); + await adoption.getByText("确认采用于后续结果", {exact: true}).click(); + const consumerInventory = route => route.request().postDataJSON()?.operation === "operations" + ? route.fulfill({json: {items: [{record_id: "b".repeat(64), operation_id: "accepted-synthesis", + agent_id: "synthesizer", status: "accepted", recovery_required: false}], + has_more: false, next_cursor: null, page_readback_complete: true}}) : route.fallback(); + await page.route("**/api/chat/sessions/*/loopx", consumerInventory); + await adoption.getByRole("button", {name: "查找使用此版本的后续结果", exact: true}).click(); + await adoption.getByLabel("后续结果", {exact: true}).waitFor(); + const invalidSource = route => route.request().postDataJSON()?.operation === "read" + && route.request().postDataJSON()?.operation_id === "accepted-analysis" + ? route.fulfill({json: {...legacyReadback, current_use: {state: "unavailable", reason: "input_unavailable"}}}) + : route.fallback(); + await page.route("**/api/chat/sessions/*/loopx", invalidSource); + const adoptionWritesBefore = api.loopxModeRequests.filter(row => row.operation === "adopt").length; + await adoption.getByRole("button", {name: "阅读后续结果", exact: true}).click(); + await page.waitForFunction(() => !document.querySelector(".goal-team-adoption") + || document.querySelector(".goal-team-adoption").getAttribute("aria-busy") === "false"); + await page.screenshot({path: resolve(outputDir, "team-source-chain-observation.png"), animations: "disabled"}); + await evidence.getByRole("alert").filter({hasText: "关联执行或版本已变化"}).waitFor({timeout: 3000}); + assert.equal(await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).count(), 0, + "An unavailable source chain withdraws the earlier correction witness"); + assert.equal(await adoption.getByRole("button", {name: "确认采用于此结果", exact: true}).count(), 0); + assert.equal(await original().count(), 0); + assert.equal(api.loopxModeRequests.filter(row => row.operation === "adopt").length, adoptionWritesBefore); + await page.screenshot({path: resolve(outputDir, "team-source-chain-unavailable-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-source-chain-unavailable-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 980}); + await page.unroute("**/api/chat/sessions/*/loopx", invalidSource); + await page.unroute("**/api/chat/sessions/*/loopx", consumerInventory); + await recheck.focus(); + await page.keyboard.press("Enter"); + await original().waitFor(); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await verificationGap.waitFor(); + // Restore the same comparison explicitly after withdrawal. + await evidence.getByRole("button", {name: /^修订依据/}).click(); + await evidence.getByLabel("对照产物", {exact: false}).selectOption({label: "report.md"}); + await evidence.getByRole("button", {name: "查看原文差异", exact: true}).click(); + await selectedOutput().waitFor(); // 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" diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx index d8661a6071..4b77a46ac6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-adoption.tsx @@ -3,7 +3,7 @@ import {adoptLoopXTeamWork, fetchLoopXTeamWork, readLoopXTeamWork, type Delegati import {isMarkdownArtifact, TeamArtifactReport} from "./team-artifact-content"; const accepted = (row: DelegationReadback) => row.status === "accepted" - && !row.error && !row.recovery_required && Boolean(row.artifacts?.length); + && row.current_use?.state !== "unavailable" && !row.error && !row.recovery_required && Boolean(row.artifacts?.length); const sameResult = (expected: DelegationReadback, observed: DelegationReadback) => accepted(observed) && expected.operation_id === observed.operation_id && expected.request_id === observed.request_id && expected.agent_id === observed.agent_id && expected.todo_id === observed.todo_id