From 09e1eb914038cc9fae17965dd986b98f2763a569 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:01:21 +0800 Subject: [PATCH 1/2] fix(app): recover version-bound team feedback after navigation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 2 +- .../smoke/team-feedback-http-fixture.py | 62 ++++++++ .../smoke/team-feedback-recovery-smoke.mjs | 141 ++++++++++++++++++ .../personal-workspace/goal-team-evidence.tsx | 68 +++++++-- 4 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 apps/presentation/dashboard/smoke/team-feedback-http-fixture.py create mode 100644 apps/presentation/dashboard/smoke/team-feedback-recovery-smoke.mjs diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 8711bce25d..0853033af3 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -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 && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-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", "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/smoke/team-feedback-http-fixture.py b/apps/presentation/dashboard/smoke/team-feedback-http-fixture.py new file mode 100644 index 0000000000..ba0ac4ca0f --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-feedback-http-fixture.py @@ -0,0 +1,62 @@ +"""Disposable production inbox/store; the UI fixture supplies team evidence only.""" +from __future__ import annotations + +import json +from pathlib import Path +import sys +import tempfile +import threading + +sys.path.insert(0, str(Path(__file__).resolve().parents[4])) + +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_store import ChatSessionStore + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="loopx-team-feedback-") as directory: + root = Path(directory) + registry = root / "registry.json" + registry.write_text(json.dumps({"schema_version": "0.1", "goals": [ + {"id": "research", "repo": str(root), "status": "active"}, + ]}), encoding="utf-8") + store = ChatSessionStore(root / "runtime") + runtime = ChatRuntimeController(store=store, codex_bin="missing-codex", registry_path=registry) + store.create_session(goal_id="research", agent_id="codex", channel_id="goal.research", + upstream_thread_id="fixture", session_id="feedback", + adapter_kind="codex_app_server", upstream_mode="chat") + # Establish local admission facts without dispatching a model or worker. + accepted = store.accept_managed_turn("feedback", client_turn_id="fixture-turn", + message="Review the report", loopx_execution=True) + store.update_session("feedback", active_turn_id=accepted.turn["turn_id"], + loopx_mode={"enabled": True, "paused": False}) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.registry_path = registry + server.runtime_root = root / "runtime" + server.chat_store = store + server.runtime_controller = runtime + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}"}), flush=True) + try: + for command in sys.stdin: + if command.strip() == "pause": + store.update_session("feedback", loopx_mode={"enabled": True, "paused": True}) + print(json.dumps({"paused": True}), flush=True) + elif command.strip() == "inspect": + print(json.dumps({"ingress": store.loopx_ingress("feedback"), + "messages": store.messages("feedback"), + "turn_count": sum(1 for _ in store.sessions_root.glob("*/turns/*.json"))}), flush=True) + else: + raise ValueError("Unknown fixture command") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + runtime.close() + + +if __name__ == "__main__": + main() diff --git a/apps/presentation/dashboard/smoke/team-feedback-recovery-smoke.mjs b/apps/presentation/dashboard/smoke/team-feedback-recovery-smoke.mjs new file mode 100644 index 0000000000..da48680980 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-feedback-recovery-smoke.mjs @@ -0,0 +1,141 @@ +// Packaged evidence UI + production inbox HTTP/store; no model or active Goal writes. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {createInterface} from "node:readline"; +import {mkdir} from "node:fs/promises"; +import {resolve} from "node:path"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +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-feedback-http-fixture.py"], + {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]}); +const exited = once(fixture, "exit"); +const lines = createInterface({input: fixture.stdout}); +const iterator = lines[Symbol.asyncIterator](); +let stderr = ""; +fixture.stderr.on("data", chunk => {stderr += String(chunk);}); +async function next() { + const line = await iterator.next(); + assert.equal(line.done, false, stderr); + return JSON.parse(line.value); +} +async function command(value) {fixture.stdin.write(`${value}\n`); return next();} +let server, browser, workspace; +try { + const {origin} = await next(); + 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}); + await page.getByText("LoopX · 正在推进", {exact: true}).waitFor(); + const posts = []; + let loseAcknowledgement = true; + await page.route("**/api/chat/sessions/*/loopx", async route => { + const body = route.request().method() === "POST" ? route.request().postDataJSON() : {}; + if (body.operation !== "message") return route.fallback(); + posts.push(body); + const response = await route.fetch({url: `${origin}/api/chat/sessions/feedback/loopx`}); + if (loseAcknowledgement) { + assert.equal(response.status(), 200, await response.text()); + loseAcknowledgement = false; + return route.fulfill({status: 503, json: {error: "Synthetic acknowledgement loss after commit"}}); + } + return route.fulfill({response}); + }); + const open = () => page.getByRole("button", {name: "团队执行情况", exact: true}).click(); + const dialog = page.getByRole("dialog", {name: "团队执行情况"}); + const evidence = page.getByRole("region", {name: "执行证据", exact: true}); + const feedback = evidence.getByLabel("向协调员反馈此执行", {exact: true}); + const select = () => dialog.getByRole("button", {name: "查看证据与反馈", exact: true}).first().click(); + await open(); await select(); + await feedback.fill("Use the revised disclosure, and keep the source comparison."); + await evidence.getByRole("button", {name: "发送反馈", exact: true}).click(); + await evidence.getByRole("alert").filter({hasText: "未确认投递"}).waitFor(); + const first = posts[0]; + assert.match(first.message, /accepted-analysis/); + assert.match(first.message, /sha256:/); + const committed = await command("inspect"); + assert.equal(committed.ingress.length, 1, "The failed response follows an actual durable inbox commit"); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).click(); + await evidence.getByLabel("向协调员反馈此执行", {exact: true}).waitFor(); + assert.equal(await feedback.inputValue(), "", "Feedback is scoped to its originating operation"); + await dialog.getByRole("button", {name: "返回上一份证据", exact: true}).click(); + await feedback.waitFor(); + assert.equal(await feedback.inputValue(), "Use the revised disclosure, and keep the source comparison.", + "Returning must retain an uncertain submission rather than mint another feedback identity"); + await page.reload(); + await page.getByText("LoopX · 正在推进", {exact: true}).waitFor(); + const unavailableEvidence = route => route.request().method() === "POST" + && route.request().postDataJSON()?.operation === "read" + && route.request().postDataJSON()?.operation_id === "accepted-analysis" + ? route.fulfill({status: 409, json: {error: "Synthetic output version withdrawn"}}) : route.fallback(); + await page.route("**/api/chat/sessions/*/loopx", unavailableEvidence); + await open(); await select(); + await evidence.getByRole("alert").filter({hasText: "已清除上次证据"}).waitFor(); + await evidence.getByRole("button", {name: "重试同一条反馈", exact: true}).waitFor(); + assert.equal(posts.length, 1, "Reload and evidence navigation cannot send feedback"); + await mkdir(outputDir, {recursive: true}); + await evidence.getByRole("button", {name: "重试同一条反馈", exact: true}).scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-feedback-unconfirmed-desktop.png"), animations: "disabled"}); + await evidence.getByRole("button", {name: "重试同一条反馈", exact: true}).focus(); + await page.keyboard.press("Enter"); + await evidence.getByText("已进入协调员收件箱,等待读取", {exact: true}).waitFor(); + assert.deepEqual(posts[1], first, "Retry preserves the exact id, body and observed artifact versions"); + const recovered = await command("inspect"); + assert.equal(recovered.ingress.length, 1); + assert.equal(recovered.messages.filter(row => row.origin === "loopx_inbox").length, 1); + assert.equal(recovered.turn_count, committed.turn_count, "Feedback never dispatches another model Turn"); + assert.equal(await evidence.getByText("已进入协调员收件箱,等待读取", {exact: true}).count(), 1, + "An inbox receipt is presented as awaiting read, never as applied feedback"); + await page.unroute("**/api/chat/sessions/*/loopx", unavailableEvidence); + await evidence.getByRole("button", {name: "重新读取证据", exact: true}).click(); + await evidence.getByLabel("证据内容: report.json").waitFor(); + await mkdir(outputDir, {recursive: true}); + await feedback.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-feedback-recovered-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 evidence.getByText("已进入协调员收件箱,等待读取", {exact: true}).scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "team-feedback-recovered-mobile.png"), animations: "disabled"}); + await evidence.getByRole("button", {name: "撰写新的反馈", exact: true}).click(); + await feedback.fill("A separate explicitly requested correction."); + await command("pause"); + await evidence.getByRole("button", {name: "发送反馈", exact: true}).click(); + await evidence.getByRole("alert").filter({hasText: "未确认投递"}).waitFor(); + assert.match(posts.at(-1).message, /A separate explicitly requested correction/); + assert.notEqual(posts.at(-1).operation_id, first.operation_id); + const refused = await command("inspect"); + assert.equal(refused.ingress.length, 1, "Real typed admission rejects feedback to a paused coordinator"); + assert.equal(refused.turn_count, committed.turn_count); + await page.evaluate(sessionId => sessionStorage.setItem(`loopx-team-feedback:${JSON.stringify([sessionId, "original-analysis"])}`, "malformed"), configured.sessionId); + await evidence.getByRole("button", {name: "核验关联执行", exact: true}).click(); + await evidence.getByRole("button", {name: "阅读原始产物", exact: true}).click(); + await evidence.getByRole("alert").filter({hasText: "无法恢复上次反馈"}).waitFor(); + assert.ok(await evidence.getByRole("button", {name: "发送反馈", exact: true}).isDisabled()); + assert.equal(posts.length, 3, "Malformed local recovery cannot silently create a replacement submission"); + console.log("team-feedback-recovery: passed (packaged UI, production inbox/store, lost receipt, navigation/reload, exact retry, scoped input, paused admission, mobile/keyboard)"); +} catch (error) { + if (workspace) console.error({body: (await workspace.page.locator("body").innerText()).slice(-3000), errors: workspace.errors}); + throw error; +} finally { + await workspace?.close(); await browser?.close(); server?.kill("SIGTERM"); + fixture.stdin.end(); if (fixture.exitCode === null) await exited; lines.close(); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx index c6081c0ee9..1f1b928182 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -5,6 +5,24 @@ import {TeamArtifactReport} from "./team-artifact-content"; import {useEffect, useRef, useState} from "react"; import {delegationStateLabel, readLoopXTeamWork, sendLoopXMessage, type DelegationReadback, type LoopXModeSnapshot} from "../../data/chat"; +type FeedbackAttempt = {id: string; text: string; message: string}; + +// Tab-local presentation recovery only. The original inbox owns admission and +// deduplication; restoring an attempt never sends it or certifies application. +function restoreFeedback(key: string): {attempt: FeedbackAttempt | null; error: boolean} { + try { + const saved = sessionStorage.getItem(key); + if (saved === null) return {attempt: null, error: false}; + const value = JSON.parse(saved); + if (!value || typeof value.id !== "string" || !/^[0-9a-f-]{36}$/i.test(value.id) + || typeof value.text !== "string" || !value.text.trim() || value.text.length > 12000 + || typeof value.message !== "string" || !value.message.trim() || value.message.length > 6000) { + return {attempt: null, error: true}; + } + return {attempt: {id: value.id, text: value.text, message: value.message}, error: false}; + } catch {return {attempt: null, error: true};} +} + /** Owner-only artifact readback. Text is evidence, never rendered as executable markup. */ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingress, onInspect}: { sessionId: string; operationId: string; zh: boolean; canMessage: boolean; @@ -14,11 +32,14 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres const [observedAt, setObservedAt] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [message, setMessage] = useState(""); + const feedbackKey = `loopx-team-feedback:${JSON.stringify([sessionId, operationId])}`; + const [restored] = useState(() => restoreFeedback(feedbackKey)); + const [message, setMessage] = useState(restored.attempt?.message ?? ""); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(""); + const [resetError, setResetError] = useState(""); const [receipt, setReceipt] = useState<{id: string; status: string} | null>(null); - const submission = useRef<{id: string; text: string} | null>(null); + const submission = useRef(restored.attempt); const generation = useRef(0); useEffect(() => { void read(); @@ -37,22 +58,41 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres } finally {if (current === generation.current) setBusy(false);} } async function send() { - if ((!result || !message.trim()) && !submission.current) return; + if (sending || restored.error || ((!result || !message.trim()) && !submission.current)) return; // A retry after transport failure must carry the original identity and exact evidence. - const attempt = submission.current ?? {id: crypto.randomUUID(), text: [ + const attempt = submission.current ?? {id: crypto.randomUUID(), message: message.trim(), text: [ `${zh ? "针对团队执行" : "Regarding team execution"}: ${operationId}`, ...(result?.artifacts ?? []).map(artifact => `${artifact.ref} · sha256:${artifact.sha256}`), message.trim(), ].join("\n")}; - submission.current = attempt; setSending(true); setSendError(""); try { + if (attempt.text.length > 12000) throw new Error(zh ? "反馈与版本标识过长,请缩短反馈。" : "Feedback and version references are too long; shorten the feedback."); + // Save and read back before the effect. If storage is unavailable, no + // inbox write occurs; losing an uncertain id must never create a new one. + const saved = JSON.stringify(attempt); + sessionStorage.setItem(feedbackKey, saved); + if (sessionStorage.getItem(feedbackKey) !== saved) throw new Error(zh ? "无法保留反馈以便恢复。" : "Cannot retain feedback for recovery."); + submission.current = attempt; const value = await sendLoopXMessage(sessionId, attempt.text, "inbox", attempt.id); + if (value.ok !== true || value.delivery_mode !== "inbox" || typeof value.status !== "string") { + throw new Error(zh ? "反馈回执无法核验。" : "Feedback receipt cannot be verified."); + } setReceipt({id: attempt.id, status: value.status}); } catch (failure) {setSendError(failure instanceof Error ? failure.message : String(failure));} finally {setSending(false);} } - const status = receipt ? ingress.find(row => row.client_ingress_id === receipt.id)?.status ?? receipt.status : ""; + const observedReceipt = submission.current && ingress.find(row => row.client_ingress_id === submission.current!.id && row.mode === "loopx_inbox"); + const status = observedReceipt?.status ?? receipt?.status ?? ""; + const confirmed = status === "pending" || status === "delivered"; + function newFeedback() { + if (!confirmed || sending) return; + try { + sessionStorage.removeItem(feedbackKey); + if (sessionStorage.getItem(feedbackKey) !== null) throw new Error("Feedback recovery state was not cleared"); + submission.current = null; setReceipt(null); setMessage(""); setSendError(""); setResetError(""); + } catch (failure) {setResetError(failure instanceof Error ? failure.message : String(failure));} + } const receiptLabel = status === "pending" ? (zh ? "已进入协调员收件箱,等待读取" : "In the coordinator inbox; awaiting read") : status === "delivered" ? (zh ? "已交给协调员;尚无应用回执" : "Delivered to coordinator; application not confirmed") : (zh ? "投递状态待核实" : "Delivery requires reconciliation"); @@ -105,16 +145,24 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres {operationId}{result.request_id}{result.todo_id} ) :

{zh ? "本次读取没有可展示的已验收产物。" : "No accepted artifact is available in this readback."}

} + : null} + {result || submission.current || restored.error ? <>
{event.preventDefault(); void send();}}>