From 82c1057a9bf07bf27e53ff5901132ab88a5499f3 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 04:29:50 +0800 Subject: [PATCH 1/8] fix(chat): state the missing registry in the workspace directory view A fresh install's first-Goal home promises to keep the status source's own warning in its collapsed diagnostics, and the fixture scenario proved it. Against the real service it never showed: the page reads the compact workspace-directory view on open, that view carried no source facts, and the page synthesized an empty contract from it. The reason the workspace is empty (no registry yet) was invisible to a first-time owner. The directory view now states the source facts a discovery read can give without opening runtime state: a contract whose warning names the missing registry in the full status's own words, redacted with the same protected roots as every status response ("registry file does not exist: [project]"). Discovery stays metadata-only and tolerates a server without runtime wiring. The page passes the field through and keeps the previous empty contract for an older service; the browser fixture's directory response carries the same field. Server tests cover the missing and present registry and assert that no local path leaves the service. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../src/data/workspace-progressive-status.ts | 12 ++++++- .../personal-workspace-browser/fixture.mjs | 3 ++ loopx/chat_status_api.py | 35 +++++++++++++++++++ tests/test_chat_server_cors.py | 28 +++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index 18c4a58ee1..9576f4c331 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -1,6 +1,14 @@ import { z } from "zod"; import { parseStatusPayload, type StatusPayload } from "./status"; +/** The source facts a discovery read can state without opening runtime state; older services omit it. */ +const directoryContractSchema = z.object({ + ok: z.boolean(), + summary: z.object({ errors: z.number(), warnings: z.number(), checks: z.number() }), + errors: z.array(z.string()), + warnings: z.array(z.string()), + checks: z.array(z.string()).optional().default([]), +}); const directorySchema = z.object({ ok: z.literal(true), schema_version: z.literal("loopx_workspace_directory_v1"), @@ -11,6 +19,7 @@ const directorySchema = z.object({ activation_state: z.enum(["active", "stopped"]), registry_member: z.literal(true), })), + contract: directoryContractSchema.optional(), }); export type WorkspaceDirectory = z.infer; export type WorkspaceLoadError = "timeout" | "network" | "service" | "access" | "revision" | "scope" | "invalid"; @@ -87,7 +96,8 @@ export function directoryStatusPayload(directory: WorkspaceDirectory): StatusPay return parseStatusPayload({ ok: true, registry: "", runtime_root: "", goal_count: directory.goals.length, run_count: 0, local_dashboard_api: {}, - contract: { ok: true, summary: { errors: 0, warnings: 0, checks: 0 }, errors: [], warnings: [] }, + // A fresh install's directory names its missing registry; the first-Goal home shows that reason. + contract: directory.contract ?? { ok: true, summary: { errors: 0, warnings: 0, checks: 0 }, errors: [], warnings: [] }, attention_queue: { available: false, item_count: 0, needs_user_or_controller: 0, needs_codex: 0, watching_external_evidence: 0, items: [] }, run_history: { available: false, goal_count: directory.goals.length, run_count: 0, diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 117aa6a8b8..059138f574 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -498,6 +498,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu hostThreadActivity: {}, /** Scenario hook applied to every status payload after the shared fixture rules. */ transformStatus: null, + directoryContract: null, answerForMessage: null, loopxModeRequests: [], get larkConnections() { return runtime.larkConnections; }, @@ -523,6 +524,8 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu goals: [...state.goalActivationStates.entries()].map(([id, activation_state]) => ({ activation_state, display_name: registeredNames.get(id) ?? id, id, registry_member: true, })), + // The real service states its source facts here too (a missing registry is a warning). + contract: state.directoryContract ?? { ok: true, summary: { errors: 0, warnings: 0, checks: 0 }, errors: [], warnings: [], checks: [] }, }, status: 200 }); return; } diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 9f7dbb3276..8a1fa13a28 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -89,6 +89,27 @@ def _discovery_registry(registry_path: Path) -> dict[str, Any]: return {} +def _discovery_contract(registry_path: Path) -> dict[str, Any]: + """The source facts a discovery read can state without opening runtime state. + + A fresh install has no registry until the first Goal is created. The full + status reports that as a warning (allow_missing_registry); the directory + states the same fact in the same words, so the first-Goal home can show why + the workspace is empty. The caller redacts paths like every status response. + """ + + warnings: list[str] = [] + if not registry_path.exists(): + warnings.append(f"registry file does not exist: {registry_path}") + return { + "ok": True, + "summary": {"errors": 0, "warnings": len(warnings), "checks": 0}, + "errors": [], + "warnings": warnings, + "checks": [], + } + + def _status_access_denied(error: BaseException) -> bool: """Recognize permission causes without reclassifying unrelated I/O failures.""" current: BaseException | None = error @@ -185,6 +206,20 @@ def _status(self, *, delivery_review: bool = False) -> None: registry, selected_goal_id=goal_id ) if views == ["workspace-directory"]: + directory["contract"] = _discovery_contract(self.server.registry_path) + # Discovery stays metadata-only: redact with the roots this + # server knows, without requiring the full runtime wiring. + runtime_root = getattr(self.server, "runtime_root", None) + directory = json.loads( + redact_local_paths( + json.dumps(directory, ensure_ascii=False), + protected_paths=[ + self.server.registry_path, + *(getattr(self.server, "scan_roots", None) or []), + *([runtime_root] if runtime_root else []), + ], + ) + ) self._send_json(directory) return projection = collect_status( diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 9a13d17088..034f7c9344 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -500,6 +500,14 @@ def test_workspace_directory_reports_a_missing_registry_as_an_empty_workspace(tm assert payload["ok"] is True assert payload["schema_version"] == "loopx_workspace_directory_v1" assert payload["goals"] == [] + # The discovery view states the source fact in the full status's own + # words, with the registry path redacted, so the first-Goal home can + # show why the workspace is empty without a local path. + assert payload["contract"]["ok"] is True + assert payload["contract"]["warnings"] == ["registry file does not exist: [project]"] + assert payload["contract"]["summary"] == {"errors": 0, "warnings": 1, "checks": 0} + assert payload["contract"]["errors"] == [] + assert str(tmp_path) not in json.dumps(payload) finally: server.shutdown() thread.join(timeout=5) @@ -681,3 +689,23 @@ def fail(*_args, **_kwargs): server.shutdown() thread.join(timeout=5) server.server_close() + + +def test_workspace_directory_states_no_warning_for_a_present_registry(tmp_path) -> None: + registry_path = tmp_path / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + registry_path.write_text(json.dumps({"goals": []}), encoding="utf-8") + server, thread = _start_server() + server.registry_path = registry_path + try: + response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?view=workspace-directory") + payload = json.loads(response.read().decode("utf-8")) + assert response.status == 200 + assert payload["goals"] == [] + assert payload["contract"]["warnings"] == [] + assert payload["contract"]["summary"]["warnings"] == 0 + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + From 68a204480f2857fdd37562f37925865c695d2750 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 04:29:50 +0800 Subject: [PATCH 2/8] test(dashboard): prove the first-Goal home against a real fresh install Add first-goal-home-live: a disposable real `loopx chat` on a fresh runtime with no registry (the state an installed LoopX opens for the first time). The real discovery view answers zero Goals with its own missing-registry warning and no local path; the packaged page opens to the first-Goal home, keeps that warning in the collapsed diagnostics, lets an example fill the composer without creating a Turn, and opens the typed Goal form whose working-directory choices come from the real project catalog. The scenario runs against the packaged bundle only, like the other live scenarios that need the real service to serve the page. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- examples/personal-workspace-browser-smoke.mjs | 2 + .../first-goal-home-live.mjs | 97 +++++++++++++++++++ .../fresh-install-backend.py | 32 ++++++ 3 files changed, 131 insertions(+) create mode 100644 examples/personal-workspace-browser/first-goal-home-live.mjs create mode 100644 examples/personal-workspace-browser/fresh-install-backend.py diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 80d6a4f4f9..cf9400de8a 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -8,6 +8,7 @@ import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal- import {sinceYouLookedScenario} from "./personal-workspace-browser/since-you-looked.mjs"; import {sinceYouLookedLiveScenario} from "./personal-workspace-browser/since-you-looked-live.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; +import {firstGoalHomeLiveScenario} from "./personal-workspace-browser/first-goal-home-live.mjs"; import {goalSuggestionsScenario} from "./personal-workspace-browser/goal-suggestions.mjs"; import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-suggestions-live.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; @@ -117,6 +118,7 @@ scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(sinceYouLookedScenario); scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(firstGoalHomeLiveScenario); scenarioCatalog.push(goalSuggestionsScenario); scenarioCatalog.push(goalSuggestionsLiveScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; diff --git a/examples/personal-workspace-browser/first-goal-home-live.mjs b/examples/personal-workspace-browser/first-goal-home-live.mjs new file mode 100644 index 0000000000..eeb7decaf0 --- /dev/null +++ b/examples/personal-workspace-browser/first-goal-home-live.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, packaged, repoRoot } from "./fixture.mjs"; +import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; + +// End to end against the real service: a disposable `loopx chat` on a fresh +// runtime with no registry at all, as an installed LoopX opens for the first +// time. The page reads the real discovery view, which states its source facts: +// zero Goals and the service's own missing-registry warning. The home must +// open to the first-Goal state, not a health banner, keep that warning in the +// collapsed diagnostics without a local path, let an example fill the composer +// without sending, and open the typed Goal form fed by the real project catalog. + +export const firstGoalHomeLiveScenario = { + id: "first-goal-home-live", + async run({ browser, collectCoverage, url }) { + // The real service serves the packaged bundle; the development lane has none to serve. + if (!packaged) return { coverageEntries: [], note: "live first-Goal home runs against the packaged bundle only" }; + const root = await mkdtemp(join(tmpdir(), "loopx-first-goal-live-")); + const python = resolveTestPython({ repoRoot }); + const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/fresh-install-backend.py"), root], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + let diagnostics = "", session; + child.stderr.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-4000); }); + try { + const ready = await Promise.race([once(lines, "line").then(([line]) => JSON.parse(line)), closed.then(() => { throw new Error(`fresh-install backend exited: ${diagnostics}`); })]); + const backend = `http://127.0.0.1:${ready.port}`; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { if ((await fetch(`${backend}/status.json?view=workspace-directory`)).ok) break; } catch { /* not listening yet */ } + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + const directory = await (await fetch(`${backend}/status.json?view=workspace-directory`)).json(); + assert.equal(directory.ok, true); + assert.deepEqual(directory.goals, []); + assert.ok(directory.contract?.warnings.some((row) => /^registry file does not exist/.test(row)), `the real discovery view names the missing registry: ${JSON.stringify(directory.contract)}`); + assert.ok(!JSON.stringify(directory.contract).includes(root), "no local path leaves the service"); + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { + // The document URL also ends in "status.json" (its statusUrl query), so match paths, not a glob. + const proxied = new Set(["/status.json", "/api/chat/capabilities", "/api/chat/projects"]); + await page.route((target) => proxied.has(target.pathname), async (route) => { + try { const response = await fetchWorkspaceBackend(route, backend); await route.fulfill({ response }); } + catch { await route.abort().catch(() => {}); } + }); + } }); + const { page } = session; + const turnRequests = []; + page.on("request", (request) => { if (request.method() === "POST" && /\/api\/chat\/sessions\/[^/]+\/turns/.test(request.url())) turnRequests.push(request.url()); }); + const firstGoal = page.getByTestId("personal-first-goal"); + await firstGoal.waitFor({ state: "visible", timeout: 20_000 }); + assert.equal(await page.locator(".personal-system-health-banner").count(), 0, "The real missing registry is not shown as a health concern"); + assert.match(await firstGoal.locator("h2").innerText(), /还没有 Goal/); + const details = firstGoal.locator("details.personal-first-goal-diagnostics"); + await details.waitFor({ state: "visible", timeout: 10_000 }); + assert.equal(await details.getAttribute("open"), null, "Diagnostics stay collapsed"); + // The disclosure stays closed, so read its lines, not the visible text. + const reasons = await details.locator("li").allTextContents(); + assert.ok(reasons.some((row) => /registry file does not exist/.test(row)), `the diagnostics carry the service's own warning: ${reasons.join(" | ")}`); + assert.ok(reasons.every((row) => !row.includes(root)), "the diagnostics show no local path"); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live.png"), animations: "disabled" }); + + // An example only fills the composer; nothing is sent to the real service. + const composer = page.locator(".personal-channel-composer textarea"); + const example = firstGoal.locator(".personal-first-goal-examples button").first(); + const exampleText = (await example.innerText()).trim(); + await example.click(); + assert.equal((await composer.inputValue()).trim(), exampleText); + assert.equal(turnRequests.length, 0, `an example sends nothing: ${turnRequests.join(", ")}`); + await page.keyboard.press("Escape"); + + // Create a Goal opens the typed form; its working directories come from the real project catalog. + await firstGoal.locator(".personal-first-goal-create").click(); + const form = page.locator("dialog.personal-action-form"); + await form.waitFor({ state: "visible", timeout: 10_000 }); + const projects = await (await fetch(`${backend}/api/chat/projects`)).json(); + assert.ok(projects.projects.length >= 1, "the real service registers the scan root as a project"); + await form.getByLabel("工作目录").locator("option", { hasText: projects.projects[0].title }).waitFor({ state: "attached", timeout: 10_000 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-create.png"), animations: "disabled" }); + await page.keyboard.press("Escape"); + await form.waitFor({ state: "hidden", timeout: 10_000 }); + return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning, no local path, real project catalog in the Goal form" }; + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.close().catch(() => {}); + throw error; + } finally { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); const kill = setTimeout(() => child.kill("SIGKILL"), 5000); try { await closed; } finally { clearTimeout(kill); } } + } + }, +}; diff --git a/examples/personal-workspace-browser/fresh-install-backend.py b/examples/personal-workspace-browser/fresh-install-backend.py new file mode 100644 index 0000000000..7464032fdd --- /dev/null +++ b/examples/personal-workspace-browser/fresh-install-backend.py @@ -0,0 +1,32 @@ +"""Real LoopX Chat service on a fresh runtime: no registry, Goal, model or credential exists. + +The scan root is an empty project directory, exactly the state an installed +LoopX opens for the first time; nothing here is synthetic except the browser. +""" +import json +import os +import signal +import socket +import sys +from pathlib import Path + +from loopx.chat_server import default_chat_assets_dir, serve_chat + +root = Path(sys.argv[1]).resolve() +(root / "project").mkdir(parents=True, exist_ok=True) +os.environ["LOOPX_USAGE_PING"] = "0" +os.environ["DO_NOT_TRACK"] = "1" +with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] +print(json.dumps({"port": port, "project": str(root / "project")}), flush=True) +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +serve_chat( + registry_path=root / "project" / ".loopx" / "registry.json", + runtime_root_override=str(root / "runtime"), + scan_roots=[root / "project"], + host="127.0.0.1", + port=port, + assets_dir=default_chat_assets_dir(), + open_browser=False, +) From 72b7d3118dbb5fa8014f19ed44500d0b7bf3f387 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 05:14:48 +0800 Subject: [PATCH 3/8] fix(actions): let a fresh install review and create its first Goal The first-Goal home invites the owner to create a Goal, and on a real fresh install the service refused the preview: "the active LoopX registry is unavailable". The proposal fingerprint read the registry file's bytes and treated a missing file like an unreadable one, although the registry loaders already read absence as an empty registry and project bootstrap writes the file on the first creation. Without a terminal there was no way past this. The fingerprint now records absence as a stable fact ("registry-absent"), so preview and apply agree while no registry exists and the first creation goes through; a registry that appears between preview and apply still reads as a change and asks for a new review, and an unreadable registry still refuses. Covered by a unit test on the action service and by the real-service first-Goal home scenario, which now creates the first Goal from the page. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- loopx/chat_actions.py | 10 ++++++++ tests/test_chat_goal_workspace_selection.py | 27 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 6b97a3bf34..f74f93b6ed 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -295,8 +295,18 @@ def _goal(self, goal_id: str) -> dict[str, Any]: return goal def _registry_fingerprint(self) -> str: + """Identity of the registry a proposal was previewed against. + + A fresh install has no registry until its first Goal is created. That + absence is a stable fact to fingerprint, so the first creation can be + reviewed and confirmed from the page; a registry that appears between + preview and apply still reads as a change and asks for a new review. + """ + try: content = self.registry_path.read_bytes() + except FileNotFoundError: + return "registry-absent" except OSError as exc: raise ValueError("the active LoopX registry is unavailable") from exc return hashlib.sha256(content).hexdigest() diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index b7b8fec54d..dc90dbcb3f 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -240,3 +240,30 @@ def request(path, body=None): server.shutdown() server.server_close() thread.join(timeout=5) + + +def test_fresh_install_previews_its_first_goal_before_any_registry_exists(tmp_path): + # An installed LoopX has no registry until the first Goal is created, and + # the first-Goal home invites exactly that creation. The proposal must be + # reviewable then: the absent registry is a stable fact to fingerprint, a + # registry that appears afterwards reads as a change, and an unreadable + # registry still refuses. + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx" / "registry.json" + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), + registry_path=registry, workspace_roots=[project]) + + proposal = preview(service, "current", key="first") + + assert proposal["action_kind"] == "goal.create" + assert proposal["workspace_binding"]["label"] == "project" + assert proposal["expected_state_fingerprint"] == "registry-absent" + assert service._registry_fingerprint() == "registry-absent", "still absent at apply time: the review stands" + registry.parent.mkdir() + registry.write_text(json.dumps({"schema_version": 1, "goals": []})) + assert service._registry_fingerprint() != "registry-absent", "a registry that appeared in between is a change" + service.registry_path = registry.parent # a directory, not a file: unreadable, not absent + with pytest.raises(ValueError, match="registry is unavailable"): + service._registry_fingerprint() + From 14f6e249a089d3e868e22c830ca3a9785bf4ac22 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 05:14:48 +0800 Subject: [PATCH 4/8] test(dashboard): create the first Goal from the fresh-install page The live first-Goal home scenario now completes the journey against the real service: fill the typed form, review the real proposal, confirm, read back "applied", then observe the registry written on disk, the real discovery view listing exactly that Goal with no warning left, and the home showing the Goal row instead of the first-Goal state. The action endpoints are proxied to the real service alongside status, capabilities and projects. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../first-goal-home-live.mjs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/examples/personal-workspace-browser/first-goal-home-live.mjs b/examples/personal-workspace-browser/first-goal-home-live.mjs index eeb7decaf0..e37a898054 100644 --- a/examples/personal-workspace-browser/first-goal-home-live.mjs +++ b/examples/personal-workspace-browser/first-goal-home-live.mjs @@ -44,7 +44,7 @@ export const firstGoalHomeLiveScenario = { session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { // The document URL also ends in "status.json" (its statusUrl query), so match paths, not a glob. const proxied = new Set(["/status.json", "/api/chat/capabilities", "/api/chat/projects"]); - await page.route((target) => proxied.has(target.pathname), async (route) => { + await page.route((target) => proxied.has(target.pathname) || target.pathname.startsWith("/api/actions"), async (route) => { try { const response = await fetchWorkspaceBackend(route, backend); await route.fulfill({ response }); } catch { await route.abort().catch(() => {}); } }); @@ -82,9 +82,38 @@ export const firstGoalHomeLiveScenario = { assert.ok(projects.projects.length >= 1, "the real service registers the scan root as a project"); await form.getByLabel("工作目录").locator("option", { hasText: projects.projects[0].title }).waitFor({ state: "attached", timeout: 10_000 }); await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-create.png"), animations: "disabled" }); - await page.keyboard.press("Escape"); - await form.waitFor({ state: "hidden", timeout: 10_000 }); - return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning, no local path, real project catalog in the Goal form" }; + + // The first Goal is created from this page through the real action + // owner: review, confirm, readback. No terminal, no pre-existing registry. + const title = "Track the weekly release checklist"; + await form.getByLabel("目标").fill(title); + await form.getByLabel("完成标准").fill("A checklist exists and is reviewed every week"); + await form.getByLabel("工作目录").selectOption({ label: projects.projects[0].title }); + await form.locator("button[type=submit]").click(); + const drawer = page.locator(".personal-context-drawer"); + await drawer.waitFor({ state: "visible", timeout: 20_000 }); + await drawer.getByText("新 Goal · 待你确认", { exact: true }).waitFor({ state: "visible", timeout: 20_000 }); + assert.equal(await drawer.getByText("提案状态刷新失败", { exact: false }).count(), 0, "the real proposal snapshot reads back"); + await drawer.locator(".personal-primary-action").click(); + const applyButton = drawer.getByRole("button", { name: "确认并应用", exact: true }); + if (await applyButton.count()) await applyButton.click(); + await drawer.getByText("新 Goal · 已应用", { exact: true }).waitFor({ state: "visible", timeout: 30_000 }); + await drawer.getByText("操作已完成", { exact: false }).first().waitFor({ state: "visible", timeout: 10_000 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-applied.png"), animations: "disabled" }); + + // The real service now has a registry with exactly this Goal, and the + // home shows it instead of the first-Goal state. + const { existsSync } = await import("node:fs"); + assert.ok(existsSync(join(ready.project, ".loopx", "registry.json")), "the first creation writes the registry"); + const after = await (await fetch(`${backend}/status.json?view=workspace-directory`)).json(); + assert.deepEqual(after.goals.map((goal) => goal.display_name), [title], "the real discovery view lists the created Goal"); + assert.deepEqual(after.contract.warnings, [], "no missing-registry warning remains"); + await page.locator(".personal-drawer-close").click(); + await page.getByRole("button", { name: "刷新状态", exact: true }).click(); + await page.locator(".personal-goal-row", { hasText: title }).first().waitFor({ state: "visible", timeout: 20_000 }); + await firstGoal.waitFor({ state: "hidden", timeout: 20_000 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-home.png"), animations: "disabled" }); + return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning, first Goal created from the page through the real action owner, registry written, directory lists it" }; } catch (error) { if (diagnostics) process.stderr.write(diagnostics); await session?.close().catch(() => {}); From 01ca68d573a8105c1a77bffb19e7ee5011cb3626 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 05:49:30 +0800 Subject: [PATCH 5/8] test(dashboard): give the fresh-install service a stub Agent and assert its honest outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed the first-Goal journey where this machine passed it: the real preview needs an eligible Agent, and a real Codex CLI on the local PATH supplied one while CI has none. A scenario must not depend on the machine's CLIs, and must never let the real service run a model. The fresh-install backend now ships a stub Codex executable that exits at once and points the service at stub paths for every Agent, with API keys removed from its environment. With that Agent the real service registers the Goal and its first task, reports the first Turn it could not start ("Goal 初始化尚未完成") and offers the original operation to retry; the scenario asserts exactly that, the registry on disk, the discovery view listing the Goal with no warning left, and the home showing the Goal row. A false "applied" would be the defect. The proposal readback is awaited rather than sampled the instant the card appears. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../first-goal-home-live.mjs | 16 ++++++++++++---- .../fresh-install-backend.py | 13 ++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/first-goal-home-live.mjs b/examples/personal-workspace-browser/first-goal-home-live.mjs index e37a898054..56bdeceddf 100644 --- a/examples/personal-workspace-browser/first-goal-home-live.mjs +++ b/examples/personal-workspace-browser/first-goal-home-live.mjs @@ -16,6 +16,10 @@ import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs // open to the first-Goal state, not a health banner, keep that warning in the // collapsed diagnostics without a local path, let an example fill the composer // without sending, and open the typed Goal form fed by the real project catalog. +// Confirming the first Goal goes through the real action owner. The only Agent +// is a stub that exits at once, so no model ever runs: the service registers +// the Goal and its first task, reports the first Turn it could not start, and +// offers the original operation to retry; a false success would be the defect. export const firstGoalHomeLiveScenario = { id: "first-goal-home-live", @@ -93,12 +97,16 @@ export const firstGoalHomeLiveScenario = { const drawer = page.locator(".personal-context-drawer"); await drawer.waitFor({ state: "visible", timeout: 20_000 }); await drawer.getByText("新 Goal · 待你确认", { exact: true }).waitFor({ state: "visible", timeout: 20_000 }); - assert.equal(await drawer.getByText("提案状态刷新失败", { exact: false }).count(), 0, "the real proposal snapshot reads back"); + // The snapshot readback completes shortly after the card appears; a stale-card notice must not remain. + await drawer.getByText("提案状态刷新失败", { exact: false }).waitFor({ state: "hidden", timeout: 15_000 }); await drawer.locator(".personal-primary-action").click(); const applyButton = drawer.getByRole("button", { name: "确认并应用", exact: true }); if (await applyButton.count()) await applyButton.click(); - await drawer.getByText("新 Goal · 已应用", { exact: true }).waitFor({ state: "visible", timeout: 30_000 }); - await drawer.getByText("操作已完成", { exact: false }).first().waitFor({ state: "visible", timeout: 10_000 }); + await drawer.getByText("新 Goal · 出错", { exact: true }).waitFor({ state: "visible", timeout: 60_000 }); + const outcome = (await drawer.innerText()).replace(/\s+/g, " "); + assert.match(outcome, /Goal 初始化尚未完成/, `the service names what is not finished: ${outcome.slice(0, 300)}`); + assert.match(outcome, /Goal ID: goal-[0-9a-f-]+ · 已应用/, "the Goal itself was registered before the first Turn failed"); + assert.ok(await drawer.getByRole("button", { name: "重试原操作", exact: true }).count(), "the original operation can be retried"); await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-applied.png"), animations: "disabled" }); // The real service now has a registry with exactly this Goal, and the @@ -113,7 +121,7 @@ export const firstGoalHomeLiveScenario = { await page.locator(".personal-goal-row", { hasText: title }).first().waitFor({ state: "visible", timeout: 20_000 }); await firstGoal.waitFor({ state: "hidden", timeout: 20_000 }); await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-home.png"), animations: "disabled" }); - return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning, first Goal created from the page through the real action owner, registry written, directory lists it" }; + return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning; first Goal registered from the page through the real action owner with the stub Agent's failed first Turn reported honestly; registry written, directory lists it" }; } catch (error) { if (diagnostics) process.stderr.write(diagnostics); await session?.close().catch(() => {}); diff --git a/examples/personal-workspace-browser/fresh-install-backend.py b/examples/personal-workspace-browser/fresh-install-backend.py index 7464032fdd..5e3652c342 100644 --- a/examples/personal-workspace-browser/fresh-install-backend.py +++ b/examples/personal-workspace-browser/fresh-install-backend.py @@ -1,7 +1,9 @@ """Real LoopX Chat service on a fresh runtime: no registry, Goal, model or credential exists. The scan root is an empty project directory, exactly the state an installed -LoopX opens for the first time; nothing here is synthetic except the browser. +LoopX opens for the first time. The only Agent executable is a stub that +exits at once, so the service sees an installed Codex CLI without any model +ever running; the machine's real CLIs and API keys are never consulted. """ import json import os @@ -14,6 +16,12 @@ root = Path(sys.argv[1]).resolve() (root / "project").mkdir(parents=True, exist_ok=True) +bins = root / "bin" +bins.mkdir(exist_ok=True) +(bins / "codex").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") +(bins / "codex").chmod(0o755) +for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"): + os.environ.pop(variable, None) os.environ["LOOPX_USAGE_PING"] = "0" os.environ["DO_NOT_TRACK"] = "1" with socket.socket() as probe: @@ -27,6 +35,9 @@ scan_roots=[root / "project"], host="127.0.0.1", port=port, + codex_bin=str(bins / "codex"), + claude_bin=str(bins / "claude"), + kiro_cli_bin=str(bins / "kiro-cli"), assets_dir=default_chat_assets_dir(), open_browser=False, ) From 6d6b8bfbe884769c7d7cba1a6b16c542f4bf6b73 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 07:35:10 +0800 Subject: [PATCH 6/8] fix(actions): persist the first Goal under the serving Chat's runtime root Review reproduced it with two isolated roots: with no registry, the creation the previous commit opened resolved its runtime root from the still-empty registry and fell back to the implicit default, so the registry's common_runtime_root, the canonical Todo state and the SQLite authority landed beside the tester's default state while the Chat's own action and session stores stayed under the root the service had selected. Retrying the original operation kept the wrong root. The action service now receives the serving Chat's runtime root override and resolves creation through it, so the first Goal persists where the service keeps its state; a registry that records a root, or an explicit --runtime-root, still wins as before. The disposable Todo backend passes the same override. Regression: a real HTTP server with a selected root and a monkeypatched implicit default; preview on a missing registry, apply, then assert the registry records the selected root, the Goal's canonical state lives there, nothing lands in the default, replaying the original operation keeps one Goal under the same root, and a registry that appears between preview and apply leaves the proposal stale. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../goal-workspace-todo-backend.py | 2 +- loopx/chat_actions.py | 12 ++- loopx/chat_server.py | 1 + tests/test_chat_goal_workspace_selection.py | 96 +++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/examples/personal-workspace-browser/goal-workspace-todo-backend.py b/examples/personal-workspace-browser/goal-workspace-todo-backend.py index 508bdf46cf..1f875610f6 100644 --- a/examples/personal-workspace-browser/goal-workspace-todo-backend.py +++ b/examples/personal-workspace-browser/goal-workspace-todo-backend.py @@ -26,7 +26,7 @@ server.registry_path, server.runtime_root, server.runtime_root_override = registry, runtime, str(runtime) server.selected_goal_id, server.scan_roots, server.limit = "progress-projection", [], 20 server.action_store = ChatActionStore(root / "actions") -server.action_service = ChatActionService(store=server.action_store, registry_path=registry) +server.action_service = ChatActionService(store=server.action_store, registry_path=registry, runtime_root_override=str(runtime)) server.verbose = False signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) print(json.dumps({"port": server.server_port, "registry": str(registry)}), flush=True) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index f74f93b6ed..d8efa87704 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -232,11 +232,19 @@ def __init__( chat_store: ChatSessionStore | None = None, runtime_controller: Any | None = None, workspace_roots: Sequence[Path] = (), + runtime_root_override: str | Path | None = None, ) -> None: self.store = store self.registry_path = Path(registry_path).expanduser().resolve() self.chat_store = chat_store self.runtime_controller = runtime_controller + # The runtime root the serving Chat selected (its --runtime-root). A + # creation on a registry that does not exist yet has no recorded root + # to follow, so the first Goal must persist where the service already + # keeps its own state, never in an implicit default beside it. + self.runtime_root_override = ( + str(runtime_root_override) if runtime_root_override else None + ) self.workspace_roots = tuple( Path(root).expanduser().resolve() for root in workspace_roots ) @@ -664,7 +672,9 @@ def require_first_turn_readback(turn: dict[str, Any]) -> None: }, ) registry = self._registry() - runtime_root = resolve_runtime_root(registry, registry_path=self.registry_path).resolve() + runtime_root = resolve_runtime_root( + registry, self.runtime_root_override, registry_path=self.registry_path + ).resolve() objective = str(parameters.get("objective") or parameters["title"]) if existing_goal is not None: # Registry publication precedes storage initialization. Resume the diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 488818eb06..5d1b8d70b3 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1763,6 +1763,7 @@ def _lark_snapshot(): chat_store=server.chat_store, runtime_controller=server.runtime_controller, workspace_roots=resolved_scan_roots, + runtime_root_override=server.runtime_root_override, ) private_transport.core.actions = server.action_service # An admitted steward team preview is projected into the typed action store, diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index dc90dbcb3f..a0a0e2015f 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -267,3 +267,99 @@ def test_fresh_install_previews_its_first_goal_before_any_registry_exists(tmp_pa with pytest.raises(ValueError, match="registry is unavailable"): service._registry_fingerprint() + +def test_first_creation_persists_under_the_serving_chat_runtime_root(tmp_path, monkeypatch): + # A fresh install has no registry, so a creation has no recorded runtime + # root to follow. It must persist where the serving Chat keeps its own + # state (its selected runtime root), never in the implicit default beside + # it; replaying the original operation keeps that root, and a registry + # that appears between preview and apply goes stale instead of applying. + import http.client + import runpy + import threading + from pathlib import Path + + from loopx import paths + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + from loopx.chat_actions import goal_workspace_ref + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.chat_store import ChatSessionStore + + implicit_default = tmp_path / "implicit-default" + monkeypatch.setattr(paths, "select_default_runtime_root", lambda: implicit_default) + selected = tmp_path / "selected-runtime" + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx" / "registry.json" + fixture = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples/loopx-chat-actions-smoke.py")) + sessions = ChatSessionStore(selected) + controller = fixture["FakeRuntimeController"](sessions) + controller.project_contexts = ChatProjectContexts([project]) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root, server.runtime_root_override = registry, selected, str(selected) + server.action_store = ChatActionStore(selected / "chat/actions") + server.action_service = ChatActionService(store=server.action_store, registry_path=registry, + chat_store=sessions, runtime_controller=controller, workspace_roots=[project], + runtime_root_override=str(selected)) + server.chat_store, server.runtime_controller, server.verbose = sessions, controller, False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(path, body=None): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30) + try: + connection.request("GET" if body is None else "POST", path, + None if body is None else json.dumps(body), {"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + def create(key, goal_id): + return {"action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": key, + "context": {"kind": "manager", "goal_id": None}, + "normalized_parameters": {"goal_id": goal_id, "title": "First Goal", "objective": "Start here", + "agent_id": "codex", "workspace_ref": goal_workspace_ref(project)}} + + def entries(root): + return sorted(str(path.relative_to(root)) for path in root.rglob("*")) if root.exists() else [] + + try: + assert not registry.exists() + status, previewed = request("/api/actions/preview", create("first", "goal-first")) + assert status == 201, previewed + assert previewed["proposal"]["expected_state_fingerprint"] == "registry-absent" + proposal_id = previewed["proposal"]["proposal_id"] + status, applied = request(f"/api/actions/{proposal_id}/apply", {}) + assert status in {200, 202}, applied + + recorded = json.loads(registry.read_text()) + assert [goal["id"] for goal in recorded["goals"]] == ["goal-first"] + assert Path(str(recorded["common_runtime_root"])).resolve() == selected.resolve(), recorded + assert any("goal-first" in entry for entry in entries(selected)), "canonical Goal state lives under the selected root" + assert entries(implicit_default) == [], "nothing lands in the implicit default runtime root" + + # Replaying the original operation keeps one Goal under the same root. + status, replayed = request(f"/api/actions/{proposal_id}/apply", {}) + assert status in {200, 202, 409}, replayed + again = json.loads(registry.read_text()) + assert [goal["id"] for goal in again["goals"]] == ["goal-first"] + assert Path(str(again["common_runtime_root"])).resolve() == selected.resolve() + assert entries(implicit_default) == [] + + # A registry that appears between preview and apply is a change: the + # proposal goes stale rather than creating against stale facts. + registry.unlink() + status, second = request("/api/actions/preview", create("second", "goal-second")) + assert status == 201, second + assert second["proposal"]["expected_state_fingerprint"] == "registry-absent" + registry.write_text(json.dumps(recorded), encoding="utf-8") + status, stale = request(f"/api/actions/{second['proposal']['proposal_id']}/apply", {}) + assert stale["proposal"]["status"] != "applied", stale + assert [goal["id"] for goal in json.loads(registry.read_text())["goals"]] == ["goal-first"] + assert entries(implicit_default) == [] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + From 9779a2ae8e975f4acac44e382f88d5a3b197178d Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 07:35:10 +0800 Subject: [PATCH 7/8] test(dashboard): assert where the first Goal persists in the live journey The fresh-install backend points HOME inside its disposable root before LoopX is imported, so the implicit default runtime root is observable and never the tester's own, and reports both roots. The live scenario asserts that the registry records the service's selected runtime root, that the Goal's canonical state lives under it, and that nothing of the Goal lands in the implicit default. Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- .../first-goal-home-live.mjs | 13 +++++++++++-- .../fresh-install-backend.py | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/first-goal-home-live.mjs b/examples/personal-workspace-browser/first-goal-home-live.mjs index 56bdeceddf..be511050f2 100644 --- a/examples/personal-workspace-browser/first-goal-home-live.mjs +++ b/examples/personal-workspace-browser/first-goal-home-live.mjs @@ -111,8 +111,17 @@ export const firstGoalHomeLiveScenario = { // The real service now has a registry with exactly this Goal, and the // home shows it instead of the first-Goal state. - const { existsSync } = await import("node:fs"); - assert.ok(existsSync(join(ready.project, ".loopx", "registry.json")), "the first creation writes the registry"); + const { existsSync, readFileSync, readdirSync } = await import("node:fs"); + const registryPath = join(ready.project, ".loopx", "registry.json"); + assert.ok(existsSync(registryPath), "the first creation writes the registry"); + // The Goal persists where the serving Chat keeps its own state (its + // selected runtime root), never in the implicit default beside it. + const registry = JSON.parse(readFileSync(registryPath, "utf8")); + assert.equal(resolve(String(registry.common_runtime_root)), resolve(ready.runtime), `the registry records the service's runtime root: ${registry.common_runtime_root}`); + const goalId = registry.goals[0].id; + const underRuntime = (dir) => existsSync(dir) ? readdirSync(dir, { recursive: true }).map(String) : []; + assert.ok(underRuntime(ready.runtime).some((entry) => entry.includes(goalId)), "the Goal's canonical state lives under the service's runtime root"); + assert.ok(!underRuntime(ready.default_runtime).some((entry) => entry.includes(goalId)), "nothing of the Goal lands in the implicit default runtime root"); const after = await (await fetch(`${backend}/status.json?view=workspace-directory`)).json(); assert.deepEqual(after.goals.map((goal) => goal.display_name), [title], "the real discovery view lists the created Goal"); assert.deepEqual(after.contract.warnings, [], "no missing-registry warning remains"); diff --git a/examples/personal-workspace-browser/fresh-install-backend.py b/examples/personal-workspace-browser/fresh-install-backend.py index 5e3652c342..6fac32c3e8 100644 --- a/examples/personal-workspace-browser/fresh-install-backend.py +++ b/examples/personal-workspace-browser/fresh-install-backend.py @@ -12,9 +12,17 @@ import sys from pathlib import Path -from loopx.chat_server import default_chat_assets_dir, serve_chat - root = Path(sys.argv[1]).resolve() +# The implicit default runtime root lives under HOME; point it inside this +# disposable root before LoopX is imported so a creation that ignored the +# service's selected root would land somewhere the scenario can observe, and +# never in the tester's real state. +home = root / "home" +home.mkdir(parents=True, exist_ok=True) +os.environ["HOME"] = str(home) + +from loopx.chat_server import default_chat_assets_dir, serve_chat # noqa: E402 + (root / "project").mkdir(parents=True, exist_ok=True) bins = root / "bin" bins.mkdir(exist_ok=True) @@ -27,7 +35,12 @@ with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] -print(json.dumps({"port": port, "project": str(root / "project")}), flush=True) +print(json.dumps({ + "port": port, + "project": str(root / "project"), + "runtime": str(root / "runtime"), + "default_runtime": str(home / ".loopx"), +}), flush=True) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) serve_chat( registry_path=root / "project" / ".loopx" / "registry.json", From f62abc7c4d0d298564cb77d0b83ad23fd4265f13 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 16 Oct 2026 07:57:59 +0800 Subject: [PATCH 8/8] test: drop the extra blank line at the end of two test modules Co-Authored-By: Claude Fable 5.1 Signed-off-by: song --- tests/test_chat_goal_workspace_selection.py | 1 - tests/test_chat_server_cors.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index a0a0e2015f..49ed2b8d73 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -362,4 +362,3 @@ def entries(root): server.shutdown() thread.join(timeout=5) server.server_close() - diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 034f7c9344..5cf8c447db 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -708,4 +708,3 @@ def test_workspace_directory_states_no_warning_for_a_present_registry(tmp_path) server.shutdown() thread.join(timeout=5) server.server_close() -