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..53ac3bd919 --- /dev/null +++ b/examples/personal-workspace-browser/steward-journey.mjs @@ -0,0 +1,312 @@ +// 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, packaged } 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 = []; + // 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); + }; + 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, + 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 mode=${servedMode} 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"})`, + }; + }, +};