Skip to content

Commit f5709d0

Browse files
committed
Merge remote-tracking branch 'origin/main' into HEAD
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
2 parents 1c8c8d1 + cd30acc commit f5709d0

2 files changed

Lines changed: 314 additions & 1 deletion

File tree

‎examples/personal-workspace-browser-smoke.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ import {
2323
} from "./personal-workspace-browser/fixture.mjs";
2424
import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs";
2525
import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs";
26+
import { stewardJourneyScenario } from "./personal-workspace-browser/steward-journey.mjs";
2627
import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs";
2728
import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs";
2829

29-
const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario, progressiveLoadingScenario];
30+
const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, progressiveLoadingScenario];
3031
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
3132
const scenarios = requestedScenario
3233
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)