|
| 1 | +// Frontend-first steward journey acceptance. |
| 2 | +// |
| 3 | +// The existing scenarios cover one surface each. This one walks the owner |
| 4 | +// journey through the workspace in order: the first screen, asking the steward |
| 5 | +// for work, the admitted team plan card, and confirming it. Beats that the |
| 6 | +// product does not yet prove from these surfaces are recorded as typed gaps |
| 7 | +// with the probe that looked for them, so the journey reports current truth |
| 8 | +// instead of asserting a happy path the workspace cannot show. |
| 9 | +// |
| 10 | +// Everything here is synthetic: the fixture substitutes the agent turn, and no |
| 11 | +// live workspace, Goal, agent or local path is read or captured. |
| 12 | + |
| 13 | +import { writeFile } from "node:fs/promises"; |
| 14 | +import { resolve } from "node:path"; |
| 15 | + |
| 16 | +import { outputDir, packaged } from "./fixture.mjs"; |
| 17 | +import { openWorkspacePage } from "./scenario-context.mjs"; |
| 18 | + |
| 19 | +const GOAL_ID = "product-release"; |
| 20 | +const GOAL_TITLE = "Product Release"; |
| 21 | +const PROPOSAL_ID = "proposal-steward-journey-fixture"; |
| 22 | +const PLAN_SUMMARY = "为 product-release 配出 2 条 lane 的团队"; |
| 23 | +const READY_TODO = "Implement the bounded intake"; |
| 24 | +const GAP_TODO = "Independently review the intake"; |
| 25 | +const STEWARD_PROMPT = "找下一步"; |
| 26 | + |
| 27 | +function teamPlanProposal() { |
| 28 | + return { |
| 29 | + schema_version: "loopx_chat_action_proposal_v1", |
| 30 | + proposal_id: PROPOSAL_ID, |
| 31 | + action_kind: "team.plan", |
| 32 | + summary: PLAN_SUMMARY, |
| 33 | + normalized_parameters: { |
| 34 | + goal_id: GOAL_ID, |
| 35 | + plan: { |
| 36 | + schema_version: "steward_team_plan_preview_v0", |
| 37 | + kind: "steward_team_plan_preview", |
| 38 | + goal_id: GOAL_ID, |
| 39 | + objective: "Ship the bounded intake", |
| 40 | + lanes: [ |
| 41 | + { |
| 42 | + lane_id: "lane_intake", |
| 43 | + agent_id: "agent-backend", |
| 44 | + acceptance: "the bounded Todo is created through the canonical owner", |
| 45 | + staffing: "ready", |
| 46 | + first_todo: { |
| 47 | + text: READY_TODO, |
| 48 | + priority: "P1", |
| 49 | + task_class: "advancement_task", |
| 50 | + action_kind: "implement", |
| 51 | + }, |
| 52 | + }, |
| 53 | + { |
| 54 | + lane_id: "lane_review", |
| 55 | + agent_id: "agent-reviewer", |
| 56 | + acceptance: "the review receipt is recorded", |
| 57 | + staffing: "gap", |
| 58 | + gap_reason_code: "agent_not_registered", |
| 59 | + declined_first_todo: { |
| 60 | + text: GAP_TODO, |
| 61 | + priority: "P1", |
| 62 | + task_class: "advancement_task", |
| 63 | + action_kind: "validate", |
| 64 | + }, |
| 65 | + }, |
| 66 | + ], |
| 67 | + gaps: [{ lane_id: "lane_review", reason_code: "agent_not_registered" }], |
| 68 | + quota_envelope: { slots: 4, window: "1d" }, |
| 69 | + stop_condition: "every lane reports a typed outcome or a stated gap", |
| 70 | + applies: false, |
| 71 | + }, |
| 72 | + requested_by: "owner", |
| 73 | + }, |
| 74 | + context: { kind: "goal", goal_id: GOAL_ID }, |
| 75 | + expected_state_fingerprint: "fixture-steward-journey", |
| 76 | + permission_classification: "durable_write", |
| 77 | + validation_evidence: ["every ready lane names an Agent this Goal registers"], |
| 78 | + available_transitions: ["apply", "cancel"], |
| 79 | + status: "preview_ready", |
| 80 | + receipt: null, |
| 81 | + stale: null, |
| 82 | + created_at: "2026-09-16T01:00:00Z", |
| 83 | + updated_at: "2026-09-16T01:00:01Z", |
| 84 | + }; |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Probes that look for a surface the journey needs but the workspace may not |
| 89 | + * expose yet. A probe records what it searched for and what it found, so a gap |
| 90 | + * stays evidence-bearing instead of becoming a claim about the whole product. |
| 91 | + */ |
| 92 | +async function probe(page, { beat, need, selectors = [], phrases = [] }) { |
| 93 | + const foundSelectors = []; |
| 94 | + for (const selector of selectors) { |
| 95 | + if (await page.locator(selector).count() > 0) foundSelectors.push(selector); |
| 96 | + } |
| 97 | + const bodyText = await page.locator("body").innerText(); |
| 98 | + const foundPhrases = phrases.filter((phrase) => bodyText.includes(phrase)); |
| 99 | + return { |
| 100 | + beat, |
| 101 | + need, |
| 102 | + status: foundSelectors.length > 0 || foundPhrases.length > 0 ? "present" : "gap", |
| 103 | + probe: { selectors, phrases }, |
| 104 | + observed: { matched_selectors: foundSelectors, matched_phrases: foundPhrases }, |
| 105 | + }; |
| 106 | +} |
| 107 | + |
| 108 | +export const stewardJourneyScenario = { |
| 109 | + id: "steward-journey", |
| 110 | + async run({ browser, collectCoverage, url }) { |
| 111 | + const failures = []; |
| 112 | + const beats = []; |
| 113 | + const gaps = []; |
| 114 | + // The lane's acceptance is that this journey is reproducible from the |
| 115 | + // *packaged* frontend, not only from the development server. Recording the |
| 116 | + // mode keeps that claim checkable instead of implied by whoever ran it, and |
| 117 | + // the bundle assertion catches a packaged run that silently served source. |
| 118 | + const servedMode = packaged ? "packaged" : "development"; |
| 119 | + if (packaged) { |
| 120 | + if (!url.includes("/chat/")) { |
| 121 | + failures.push(`packaged run did not serve the built bundle (${url})`); |
| 122 | + } |
| 123 | + } |
| 124 | + const record = (beat, detail) => beats.push({ beat, detail }); |
| 125 | + const check = (condition, message) => { |
| 126 | + if (!condition) failures.push(message); |
| 127 | + }; |
| 128 | + const context = await openWorkspacePage(browser, url, { |
| 129 | + apiOptions: { initialActionProposals: [teamPlanProposal()] }, |
| 130 | + collectCoverage, |
| 131 | + }); |
| 132 | + try { |
| 133 | + const { api, page } = context; |
| 134 | + |
| 135 | + // Beat 1: the first screen the owner sees before asking for anything. |
| 136 | + await page.locator(".personal-home-board").waitFor({ state: "visible", timeout: 15_000 }); |
| 137 | + const homeText = await page.locator(".personal-home-board").innerText(); |
| 138 | + const laneCounts = {}; |
| 139 | + for (const lane of ["needs_you", "running", "observing", "scheduled"]) { |
| 140 | + const section = page.locator(`[data-testid="personal-home-lane-${lane}"]`); |
| 141 | + laneCounts[lane] = await section.count() ? await section.locator(".personal-home-goal-card").count() : 0; |
| 142 | + } |
| 143 | + const stewardCard = page.locator(".personal-home-goal-card", { hasText: GOAL_TITLE }); |
| 144 | + check(await stewardCard.count() >= 1, "the registered steward Goal is visible on the first screen"); |
| 145 | + check( |
| 146 | + homeText.includes("需要你") || homeText.includes("执行中"), |
| 147 | + "the first screen separates what needs the owner from what is running", |
| 148 | + ); |
| 149 | + record("1-first-screen", { |
| 150 | + goal_visible: await stewardCard.count() >= 1, |
| 151 | + lane_counts: laneCounts, |
| 152 | + card_text: await stewardCard.first().innerText().catch(() => ""), |
| 153 | + screenshot: "steward-journey-1-first-screen.png", |
| 154 | + }); |
| 155 | + await page.screenshot({ |
| 156 | + path: resolve(outputDir, "steward-journey-1-first-screen.png"), |
| 157 | + fullPage: false, |
| 158 | + animations: "disabled", |
| 159 | + }); |
| 160 | + |
| 161 | + // Beat 2: ask the steward for work in the Goal conversation and keep the |
| 162 | + // answer and the plan card in the conversation the owner asked in. |
| 163 | + await stewardCard.first().click(); |
| 164 | + const goalNavigation = page.getByRole("navigation", { name: "Goal 视图" }); |
| 165 | + await goalNavigation.getByRole("button", { name: "Chat" }).click(); |
| 166 | + // Probed before the owner types, so the owner's own words cannot be |
| 167 | + // mistaken for a rendered steward affordance. |
| 168 | + gaps.push(await probe(page, { |
| 169 | + beat: "2-steward-prompts", |
| 170 | + need: "the steward's bounded prompt set (找下一步 / 看阻塞 / 查证据) reachable from the conversation", |
| 171 | + selectors: ["[data-testid='personal-steward-prompts']", "[data-steward-prompt]"], |
| 172 | + phrases: ["看阻塞", "查证据"], |
| 173 | + })); |
| 174 | + const composer = page.getByLabel("向 LoopX 发送消息"); |
| 175 | + await composer.fill(`${STEWARD_PROMPT}:请给我一份当前 Goal 的下一步。`); |
| 176 | + await page.getByRole("button", { name: "发送", exact: true }).click(); |
| 177 | + let turn; |
| 178 | + for (let attempt = 0; attempt < 40 && !turn; attempt += 1) { |
| 179 | + turn = api.turnRequests.find((request) => request.message.includes(STEWARD_PROMPT)); |
| 180 | + if (!turn) await page.waitForTimeout(50); |
| 181 | + } |
| 182 | + check(Boolean(turn), "the steward prompt reaches the Goal conversation as an accepted Turn"); |
| 183 | + const row = page.locator(".personal-proposal-row", { hasText: PLAN_SUMMARY }); |
| 184 | + await row.waitFor({ state: "visible", timeout: 15_000 }); |
| 185 | + await row.click(); |
| 186 | + const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]'); |
| 187 | + await drawer.getByText("配额包络", { exact: true }).waitFor({ state: "visible", timeout: 15_000 }); |
| 188 | + const previewText = await drawer.innerText(); |
| 189 | + check(previewText.includes("agent-backend"), "a ready lane names the Agent that runs it"); |
| 190 | + check( |
| 191 | + previewText.includes(`P1 · implement · ${READY_TODO}`), |
| 192 | + "a ready lane shows its first bounded Todo with priority and action kind", |
| 193 | + ); |
| 194 | + check( |
| 195 | + previewText.includes("未配齐") && previewText.includes(GAP_TODO), |
| 196 | + "a gap lane says it is unstaffed and keeps the work it did not staff", |
| 197 | + ); |
| 198 | + check( |
| 199 | + previewText.includes("确认后会通过既有 owner"), |
| 200 | + "the card states that confirming is what creates the lanes", |
| 201 | + ); |
| 202 | + record("2-ask-and-plan-card", { |
| 203 | + accepted_turn: turn?.turnId ?? null, |
| 204 | + plan_summary: PLAN_SUMMARY, |
| 205 | + ready_lane: "agent-backend", |
| 206 | + gap_lane_reason: "agent_not_registered", |
| 207 | + asked_from: "composer", |
| 208 | + screenshot: "steward-journey-2-plan-card.png", |
| 209 | + }); |
| 210 | + await page.screenshot({ |
| 211 | + path: resolve(outputDir, "steward-journey-2-plan-card.png"), |
| 212 | + fullPage: false, |
| 213 | + animations: "disabled", |
| 214 | + }); |
| 215 | + |
| 216 | + // Beat 3: confirm, and record what the workspace actually reports after |
| 217 | + // the canonical owner ran. |
| 218 | + const confirm = drawer.getByRole("button", { name: "确认并组建各 lane", exact: true }); |
| 219 | + check(await confirm.count() === 1, "exactly one confirmation control is offered"); |
| 220 | + await confirm.click(); |
| 221 | + for (let attempt = 0; attempt < 60 && api.actionApplies.length === 0; attempt += 1) { |
| 222 | + await page.waitForTimeout(50); |
| 223 | + } |
| 224 | + check( |
| 225 | + api.actionApplies.filter((proposalId) => proposalId === PROPOSAL_ID).length === 1, |
| 226 | + "confirming sends exactly one apply for the confirmed proposal", |
| 227 | + ); |
| 228 | + check(api.durableWriteCount === 1, "the confirmed apply performed exactly one durable write"); |
| 229 | + const applied = drawer.getByText("已应用,LoopX 状态将刷新。", { exact: true }); |
| 230 | + await applied.waitFor({ state: "visible", timeout: 15_000 }); |
| 231 | + record("3-confirm", { |
| 232 | + applies: api.actionApplies.length, |
| 233 | + durable_writes: api.durableWriteCount, |
| 234 | + outcome_text: await applied.innerText(), |
| 235 | + outcome_fidelity: "single applied sentence; no per-lane committed/partial/all-gap/stale/rejected", |
| 236 | + }); |
| 237 | + gaps.push({ |
| 238 | + beat: "3-confirm", |
| 239 | + need: "per-lane outcome after confirm (committed / partial / all-gap / stale / rejected)", |
| 240 | + status: "gap", |
| 241 | + probe: { selectors: [], phrases: ["部分", "缺人", "已提交", "未配齐"] }, |
| 242 | + observed: { |
| 243 | + matched_phrases: (await drawer.innerText()).includes("部分") ? ["部分"] : [], |
| 244 | + surface_text: await applied.innerText(), |
| 245 | + }, |
| 246 | + owner_hint: "steward R1 remainder (confirmation card outcome fidelity)", |
| 247 | + }); |
| 248 | + await page.screenshot({ |
| 249 | + path: resolve(outputDir, "steward-journey-3-confirmed.png"), |
| 250 | + fullPage: false, |
| 251 | + animations: "disabled", |
| 252 | + }); |
| 253 | + |
| 254 | + // Beats 4-7: record what these surfaces do and do not yet prove. |
| 255 | + gaps.push(await probe(page, { |
| 256 | + beat: "4-readiness", |
| 257 | + need: "a per-lane readiness ladder (registered -> bound -> launchable -> executing)", |
| 258 | + selectors: ["[data-lane-readiness]", "[data-testid='personal-lane-readiness']"], |
| 259 | + phrases: ["可启动", "已绑定", "未绑定执行器"], |
| 260 | + })); |
| 261 | + gaps.push(await probe(page, { |
| 262 | + beat: "5-correction", |
| 263 | + need: "correcting a confirmed lane commitment (pause / supersede) from the same conversation", |
| 264 | + selectors: ["[data-testid='personal-lane-correction']", "[data-lane-correction]"], |
| 265 | + phrases: ["撤销该 lane", "暂停 lane"], |
| 266 | + })); |
| 267 | + gaps.push(await probe(page, { |
| 268 | + beat: "6-recovery", |
| 269 | + need: "a failed lane naming its blocker owner and next step on the workspace", |
| 270 | + selectors: ["[data-testid='personal-lane-blocker']", "[data-lane-blocker-owner]"], |
| 271 | + phrases: ["该 lane 阻塞", "负责重新派发"], |
| 272 | + })); |
| 273 | + gaps.push(await probe(page, { |
| 274 | + beat: "7-return", |
| 275 | + need: "completion judged by the lane's returned result, not by the conversation", |
| 276 | + selectors: ["[data-testid='personal-lane-return']", "[data-lane-return-receipt]"], |
| 277 | + phrases: ["按回执完成", "交付回执已核验"], |
| 278 | + })); |
| 279 | + |
| 280 | + const scriptErrors = context.errors.filter((message) => !message.startsWith("Failed to load resource")); |
| 281 | + check(scriptErrors.length === 0, `no client-side exception was raised (${scriptErrors.join(" | ")})`); |
| 282 | + |
| 283 | + await writeFile( |
| 284 | + resolve(outputDir, "steward-journey-report.json"), |
| 285 | + `${JSON.stringify( |
| 286 | + { |
| 287 | + beats, |
| 288 | + gaps, |
| 289 | + mode: servedMode, |
| 290 | + served_root: packaged ? "loopx/web/chat" : "vite-development-server", |
| 291 | + url, |
| 292 | + scenario: "steward-journey", |
| 293 | + }, |
| 294 | + null, |
| 295 | + 2, |
| 296 | + )}\n`, |
| 297 | + "utf8", |
| 298 | + ); |
| 299 | + const gapBeats = gaps.filter((entry) => entry.status === "gap").map((entry) => entry.beat); |
| 300 | + console.log( |
| 301 | + `steward-journey mode=${servedMode} beats=${beats.length} gaps=${gapBeats.join(",") || "none"}`, |
| 302 | + ); |
| 303 | + } finally { |
| 304 | + await context.close(); |
| 305 | + } |
| 306 | + if (failures.length) throw new Error(failures.join(" | ")); |
| 307 | + return { |
| 308 | + coverageEntries: context.coverageEntries, |
| 309 | + note: `frontend journey beats recorded; unproven beats reported as gaps (${gaps.filter((entry) => entry.status === "gap").map((entry) => entry.beat).join(", ") || "none"})`, |
| 310 | + }; |
| 311 | + }, |
| 312 | +}; |
0 commit comments