Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
312 changes: 312 additions & 0 deletions examples/personal-workspace-browser/steward-journey.mjs
Original file line number Diff line number Diff line change
@@ -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"})`,
};
},
};