From c3d1f8fbe184b44c70408d61d1b6e5e9da057bce Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:47:37 +0800 Subject: [PATCH 1/2] test(steward): walk the frontend steward journey and record its gaps The steward lane had a CLI-level qualification record but no frontend-first journey: nothing proved what an owner actually sees from the first screen through asking the steward for work, reading the admitted team plan card, and confirming it. Add a `steward-journey` scenario to the existing personal-workspace browser smoke catalog that walks those beats in order on the workspace first screen and asserts the frontend-visible facts: the Goal board lanes, the Goal conversation accepting the owner's ask, the plan card naming each lane's Agent, first bounded Todo, acceptance and staffing gap, and confirming sending exactly one apply with one durable write. Beats the surfaces do not yet prove are recorded as typed gaps with the probe that looked for them instead of being asserted away: the steward prompt set is defined in the client model but not reachable from the conversation, the confirmation outcome does not distinguish committed/partial/all-gap/stale per lane, and no per-lane readiness ladder, lane-level correction, blocker ownership, or completion-by-return is visible. The scenario emits `steward-journey-report.json` for the follow-up product case. Everything is synthetic: the fixture substitutes the agent turn, and no live Goal, agent, workspace or local path is read or captured. Validation: - `LOOPX_PERSONAL_WORKSPACE_SCENARIO=steward-journey node examples/personal-workspace-browser-smoke.mjs` - packaged: same with `LOOPX_PERSONAL_WORKSPACE_PACKAGED=1` - full catalog: `node examples/personal-workspace-browser-smoke.mjs` Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/personal-workspace-browser-smoke.mjs | 3 +- .../steward-journey.mjs | 291 ++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 examples/personal-workspace-browser/steward-journey.mjs diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 16872f5371..fcd27a7ae3 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -23,10 +23,11 @@ import { } from "./personal-workspace-browser/fixture.mjs"; import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; +import { stewardJourneyScenario } from "./personal-workspace-browser/steward-journey.mjs"; import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; -const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario, progressiveLoadingScenario]; +const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, progressiveLoadingScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/steward-journey.mjs b/examples/personal-workspace-browser/steward-journey.mjs new file mode 100644 index 0000000000..519a3d25e9 --- /dev/null +++ b/examples/personal-workspace-browser/steward-journey.mjs @@ -0,0 +1,291 @@ +// Frontend-first steward journey acceptance. +// +// The existing scenarios cover one surface each. This one walks the owner +// journey through the workspace in order: the first screen, asking the steward +// for work, the admitted team plan card, and confirming it. Beats that the +// product does not yet prove from these surfaces are recorded as typed gaps +// with the probe that looked for them, so the journey reports current truth +// instead of asserting a happy path the workspace cannot show. +// +// Everything here is synthetic: the fixture substitutes the agent turn, and no +// live workspace, Goal, agent or local path is read or captured. + +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +const GOAL_ID = "product-release"; +const GOAL_TITLE = "Product Release"; +const PROPOSAL_ID = "proposal-steward-journey-fixture"; +const PLAN_SUMMARY = "为 product-release 配出 2 条 lane 的团队"; +const READY_TODO = "Implement the bounded intake"; +const GAP_TODO = "Independently review the intake"; +const STEWARD_PROMPT = "找下一步"; + +function teamPlanProposal() { + return { + schema_version: "loopx_chat_action_proposal_v1", + proposal_id: PROPOSAL_ID, + action_kind: "team.plan", + summary: PLAN_SUMMARY, + normalized_parameters: { + goal_id: GOAL_ID, + plan: { + schema_version: "steward_team_plan_preview_v0", + kind: "steward_team_plan_preview", + goal_id: GOAL_ID, + objective: "Ship the bounded intake", + lanes: [ + { + lane_id: "lane_intake", + agent_id: "agent-backend", + acceptance: "the bounded Todo is created through the canonical owner", + staffing: "ready", + first_todo: { + text: READY_TODO, + priority: "P1", + task_class: "advancement_task", + action_kind: "implement", + }, + }, + { + lane_id: "lane_review", + agent_id: "agent-reviewer", + acceptance: "the review receipt is recorded", + staffing: "gap", + gap_reason_code: "agent_not_registered", + declined_first_todo: { + text: GAP_TODO, + priority: "P1", + task_class: "advancement_task", + action_kind: "validate", + }, + }, + ], + gaps: [{ lane_id: "lane_review", reason_code: "agent_not_registered" }], + quota_envelope: { slots: 4, window: "1d" }, + stop_condition: "every lane reports a typed outcome or a stated gap", + applies: false, + }, + requested_by: "owner", + }, + context: { kind: "goal", goal_id: GOAL_ID }, + expected_state_fingerprint: "fixture-steward-journey", + permission_classification: "durable_write", + validation_evidence: ["every ready lane names an Agent this Goal registers"], + available_transitions: ["apply", "cancel"], + status: "preview_ready", + receipt: null, + stale: null, + created_at: "2026-09-16T01:00:00Z", + updated_at: "2026-09-16T01:00:01Z", + }; +} + +/** + * Probes that look for a surface the journey needs but the workspace may not + * expose yet. A probe records what it searched for and what it found, so a gap + * stays evidence-bearing instead of becoming a claim about the whole product. + */ +async function probe(page, { beat, need, selectors = [], phrases = [] }) { + const foundSelectors = []; + for (const selector of selectors) { + if (await page.locator(selector).count() > 0) foundSelectors.push(selector); + } + const bodyText = await page.locator("body").innerText(); + const foundPhrases = phrases.filter((phrase) => bodyText.includes(phrase)); + return { + beat, + need, + status: foundSelectors.length > 0 || foundPhrases.length > 0 ? "present" : "gap", + probe: { selectors, phrases }, + observed: { matched_selectors: foundSelectors, matched_phrases: foundPhrases }, + }; +} + +export const stewardJourneyScenario = { + id: "steward-journey", + async run({ browser, collectCoverage, url }) { + const failures = []; + const beats = []; + const gaps = []; + const record = (beat, detail) => beats.push({ beat, detail }); + const check = (condition, message) => { + if (!condition) failures.push(message); + }; + const context = await openWorkspacePage(browser, url, { + apiOptions: { initialActionProposals: [teamPlanProposal()] }, + collectCoverage, + }); + try { + const { api, page } = context; + + // Beat 1: the first screen the owner sees before asking for anything. + await page.locator(".personal-home-board").waitFor({ state: "visible", timeout: 15_000 }); + const homeText = await page.locator(".personal-home-board").innerText(); + const laneCounts = {}; + for (const lane of ["needs_you", "running", "observing", "scheduled"]) { + const section = page.locator(`[data-testid="personal-home-lane-${lane}"]`); + laneCounts[lane] = await section.count() ? await section.locator(".personal-home-goal-card").count() : 0; + } + const stewardCard = page.locator(".personal-home-goal-card", { hasText: GOAL_TITLE }); + check(await stewardCard.count() >= 1, "the registered steward Goal is visible on the first screen"); + check( + homeText.includes("需要你") || homeText.includes("执行中"), + "the first screen separates what needs the owner from what is running", + ); + record("1-first-screen", { + goal_visible: await stewardCard.count() >= 1, + lane_counts: laneCounts, + card_text: await stewardCard.first().innerText().catch(() => ""), + screenshot: "steward-journey-1-first-screen.png", + }); + await page.screenshot({ + path: resolve(outputDir, "steward-journey-1-first-screen.png"), + fullPage: false, + animations: "disabled", + }); + + // Beat 2: ask the steward for work in the Goal conversation and keep the + // answer and the plan card in the conversation the owner asked in. + await stewardCard.first().click(); + const goalNavigation = page.getByRole("navigation", { name: "Goal 视图" }); + await goalNavigation.getByRole("button", { name: "Chat" }).click(); + // Probed before the owner types, so the owner's own words cannot be + // mistaken for a rendered steward affordance. + gaps.push(await probe(page, { + beat: "2-steward-prompts", + need: "the steward's bounded prompt set (找下一步 / 看阻塞 / 查证据) reachable from the conversation", + selectors: ["[data-testid='personal-steward-prompts']", "[data-steward-prompt]"], + phrases: ["看阻塞", "查证据"], + })); + const composer = page.getByLabel("向 LoopX 发送消息"); + await composer.fill(`${STEWARD_PROMPT}:请给我一份当前 Goal 的下一步。`); + await page.getByRole("button", { name: "发送", exact: true }).click(); + let turn; + for (let attempt = 0; attempt < 40 && !turn; attempt += 1) { + turn = api.turnRequests.find((request) => request.message.includes(STEWARD_PROMPT)); + if (!turn) await page.waitForTimeout(50); + } + check(Boolean(turn), "the steward prompt reaches the Goal conversation as an accepted Turn"); + const row = page.locator(".personal-proposal-row", { hasText: PLAN_SUMMARY }); + await row.waitFor({ state: "visible", timeout: 15_000 }); + await row.click(); + const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]'); + await drawer.getByText("配额包络", { exact: true }).waitFor({ state: "visible", timeout: 15_000 }); + const previewText = await drawer.innerText(); + check(previewText.includes("agent-backend"), "a ready lane names the Agent that runs it"); + check( + previewText.includes(`P1 · implement · ${READY_TODO}`), + "a ready lane shows its first bounded Todo with priority and action kind", + ); + check( + previewText.includes("未配齐") && previewText.includes(GAP_TODO), + "a gap lane says it is unstaffed and keeps the work it did not staff", + ); + check( + previewText.includes("确认后会通过既有 owner"), + "the card states that confirming is what creates the lanes", + ); + record("2-ask-and-plan-card", { + accepted_turn: turn?.turnId ?? null, + plan_summary: PLAN_SUMMARY, + ready_lane: "agent-backend", + gap_lane_reason: "agent_not_registered", + asked_from: "composer", + screenshot: "steward-journey-2-plan-card.png", + }); + await page.screenshot({ + path: resolve(outputDir, "steward-journey-2-plan-card.png"), + fullPage: false, + animations: "disabled", + }); + + // Beat 3: confirm, and record what the workspace actually reports after + // the canonical owner ran. + const confirm = drawer.getByRole("button", { name: "确认并组建各 lane", exact: true }); + check(await confirm.count() === 1, "exactly one confirmation control is offered"); + await confirm.click(); + for (let attempt = 0; attempt < 60 && api.actionApplies.length === 0; attempt += 1) { + await page.waitForTimeout(50); + } + check( + api.actionApplies.filter((proposalId) => proposalId === PROPOSAL_ID).length === 1, + "confirming sends exactly one apply for the confirmed proposal", + ); + check(api.durableWriteCount === 1, "the confirmed apply performed exactly one durable write"); + const applied = drawer.getByText("已应用,LoopX 状态将刷新。", { exact: true }); + await applied.waitFor({ state: "visible", timeout: 15_000 }); + record("3-confirm", { + applies: api.actionApplies.length, + durable_writes: api.durableWriteCount, + outcome_text: await applied.innerText(), + outcome_fidelity: "single applied sentence; no per-lane committed/partial/all-gap/stale/rejected", + }); + gaps.push({ + beat: "3-confirm", + need: "per-lane outcome after confirm (committed / partial / all-gap / stale / rejected)", + status: "gap", + probe: { selectors: [], phrases: ["部分", "缺人", "已提交", "未配齐"] }, + observed: { + matched_phrases: (await drawer.innerText()).includes("部分") ? ["部分"] : [], + surface_text: await applied.innerText(), + }, + owner_hint: "steward R1 remainder (confirmation card outcome fidelity)", + }); + await page.screenshot({ + path: resolve(outputDir, "steward-journey-3-confirmed.png"), + fullPage: false, + animations: "disabled", + }); + + // Beats 4-7: record what these surfaces do and do not yet prove. + gaps.push(await probe(page, { + beat: "4-readiness", + need: "a per-lane readiness ladder (registered -> bound -> launchable -> executing)", + selectors: ["[data-lane-readiness]", "[data-testid='personal-lane-readiness']"], + phrases: ["可启动", "已绑定", "未绑定执行器"], + })); + gaps.push(await probe(page, { + beat: "5-correction", + need: "correcting a confirmed lane commitment (pause / supersede) from the same conversation", + selectors: ["[data-testid='personal-lane-correction']", "[data-lane-correction]"], + phrases: ["撤销该 lane", "暂停 lane"], + })); + gaps.push(await probe(page, { + beat: "6-recovery", + need: "a failed lane naming its blocker owner and next step on the workspace", + selectors: ["[data-testid='personal-lane-blocker']", "[data-lane-blocker-owner]"], + phrases: ["该 lane 阻塞", "负责重新派发"], + })); + gaps.push(await probe(page, { + beat: "7-return", + need: "completion judged by the lane's returned result, not by the conversation", + selectors: ["[data-testid='personal-lane-return']", "[data-lane-return-receipt]"], + phrases: ["按回执完成", "交付回执已核验"], + })); + + const scriptErrors = context.errors.filter((message) => !message.startsWith("Failed to load resource")); + check(scriptErrors.length === 0, `no client-side exception was raised (${scriptErrors.join(" | ")})`); + + await writeFile( + resolve(outputDir, "steward-journey-report.json"), + `${JSON.stringify({ beats, gaps, scenario: "steward-journey" }, null, 2)}\n`, + "utf8", + ); + const gapBeats = gaps.filter((entry) => entry.status === "gap").map((entry) => entry.beat); + console.log( + `steward-journey beats=${beats.length} gaps=${gapBeats.join(",") || "none"}`, + ); + } finally { + await context.close(); + } + if (failures.length) throw new Error(failures.join(" | ")); + return { + coverageEntries: context.coverageEntries, + note: `frontend journey beats recorded; unproven beats reported as gaps (${gaps.filter((entry) => entry.status === "gap").map((entry) => entry.beat).join(", ") || "none"})`, + }; + }, +}; From 016d3b8f2983b14d468e84eeca0ed197ccd531a6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 04:38:52 +0800 Subject: [PATCH 2/2] test(steward): record which frontend the journey ran against The lane's acceptance is that the steward journey is reproducible from the *packaged* frontend on synthetic data, but the report could not tell a packaged run from a development-server run: both produced the same `{beats, gaps, scenario}` file and the same console line. A packaged acceptance therefore rested on who happened to run it rather than on an artifact. The report now carries `mode`, `served_root` and the served `url`, the console line prints `mode=packaged|development`, and a packaged run asserts it served the built bundle (`/chat/`) so a packaged run that silently served source fails instead of passing quietly. Recorded on this branch: `mode=packaged` and `mode=development` both report `beats=3` with gaps `2-steward-prompts,3-confirm,4-readiness,5-correction, 6-recovery,7-return`, and the full development suite still passes all seven scenarios. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../steward-journey.mjs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/examples/personal-workspace-browser/steward-journey.mjs b/examples/personal-workspace-browser/steward-journey.mjs index 519a3d25e9..53ac3bd919 100644 --- a/examples/personal-workspace-browser/steward-journey.mjs +++ b/examples/personal-workspace-browser/steward-journey.mjs @@ -13,7 +13,7 @@ import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { outputDir } from "./fixture.mjs"; +import { outputDir, packaged } from "./fixture.mjs"; import { openWorkspacePage } from "./scenario-context.mjs"; const GOAL_ID = "product-release"; @@ -111,6 +111,16 @@ export const stewardJourneyScenario = { const failures = []; const beats = []; const gaps = []; + // The lane's acceptance is that this journey is reproducible from the + // *packaged* frontend, not only from the development server. Recording the + // mode keeps that claim checkable instead of implied by whoever ran it, and + // the bundle assertion catches a packaged run that silently served source. + const servedMode = packaged ? "packaged" : "development"; + if (packaged) { + if (!url.includes("/chat/")) { + failures.push(`packaged run did not serve the built bundle (${url})`); + } + } const record = (beat, detail) => beats.push({ beat, detail }); const check = (condition, message) => { if (!condition) failures.push(message); @@ -272,12 +282,23 @@ export const stewardJourneyScenario = { await writeFile( resolve(outputDir, "steward-journey-report.json"), - `${JSON.stringify({ beats, gaps, scenario: "steward-journey" }, null, 2)}\n`, + `${JSON.stringify( + { + beats, + gaps, + mode: servedMode, + served_root: packaged ? "loopx/web/chat" : "vite-development-server", + url, + scenario: "steward-journey", + }, + null, + 2, + )}\n`, "utf8", ); const gapBeats = gaps.filter((entry) => entry.status === "gap").map((entry) => entry.beat); console.log( - `steward-journey beats=${beats.length} gaps=${gapBeats.join(",") || "none"}`, + `steward-journey mode=${servedMode} beats=${beats.length} gaps=${gapBeats.join(",") || "none"}`, ); } finally { await context.close();