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
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ? <div className="personal-proposal-state is-error"><span>{selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}</span>{selection.item.errorMessage ? <small>{selection.item.errorMessage}</small> : null}<small>{t(selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryHint" : "drawer.proposalApplyFailedHint")}</small></div> : null}
{selection.item.status === "rejected" ? <p className="personal-proposal-state is-error">{t("drawer.proposalRejected")}</p> : null}
{selection.item.status === "deferred" ? <p className="personal-proposal-state is-gated">{t("drawer.proposalDeferred")}</p> : null}
{selection.item.status === "gated" ? <div className="personal-proposal-state is-gated"><span><strong>{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.gateRequiresHost")}</strong>{selection.item.actionKind === "operation.execute" ? selection.item.impact : t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : null}
{selection.item.status === "gated" ? <div className="personal-proposal-state is-gated"><span><strong>{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}</strong>{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}</span>{selection.item.gate?.nextAction ? <small>{selection.item.gate.nextAction}</small> : null}</div> : null}
{selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ? (() => {
const fieldValue = (key: string) => selection.item.fields.find((field) => field.key === key)?.value;
const gateGoalId = fieldValue("goal_id");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,17 @@ function lifecycleOperationFor(proposal: TypedActionProposal): GoalLifecycleOper
: undefined;
}

function workspaceCandidatesFromGate(gate: Record<string, unknown> | null | undefined) {
const candidates = Array.isArray(gate?.candidates) ? gate.candidates : [];
return candidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const item = candidate as Record<string, unknown>;
return typeof item.workspace_ref === "string" && typeof item.label === "string"
? [{ label: item.label, workspaceRef: item.workspace_ref }]
: [];
});
}

function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate): WorkspaceActionPreview {
const lifecycleOperation = lifecycleOperationFor(proposal);
const reviewPlan = compileActionReviewPlan(proposal);
Expand All @@ -591,6 +602,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
: typeof proposal.normalized_parameters.goal_id === "string"
? proposal.normalized_parameters.goal_id
: "";
const workspaceCandidates = workspaceCandidatesFromGate(proposal.gate);
const target = typeof proposal.normalized_parameters.target === "string"
? proposal.normalized_parameters.target
: "";
Expand Down Expand Up @@ -649,6 +661,14 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
nextAction: typeof proposal.gate.next_action === "string" ? proposal.gate.next_action : undefined,
summary: String(proposal.gate.summary ?? t("proposal.gate.default")),
} : undefined,
sourceRequest: proposal.status === "gated" && proposal.action_kind === "goal.create" ? {
actionKind: proposal.action_kind,
context: proposal.context,
idempotencyKey: `${proposal.proposal_id}-workspace`,
normalizedParameters: proposal.normalized_parameters,
summary: proposal.summary,
} : undefined,
workspaceCandidates,
primaryLabel: reviewPlan.retryOriginal ? t("drawer.retryOriginal") : proposal.action_kind === "operation.execute"
? operationFrame?.kind === "result"
? operationFrame.resultDeliveryVerified
Expand Down Expand Up @@ -1176,14 +1196,7 @@ export function PersonalWorkspacePage({
const rawGate = error.payload.gate && typeof error.payload.gate === "object"
? error.payload.gate as Record<string, unknown>
: {};
const rawCandidates = Array.isArray(rawGate.candidates) ? rawGate.candidates : [];
const workspaceCandidates = rawCandidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const item = candidate as Record<string, unknown>;
return typeof item.workspace_ref === "string" && typeof item.label === "string"
? [{ label: item.label, workspaceRef: item.workspace_ref }]
: [];
});
const workspaceCandidates = workspaceCandidatesFromGate(rawGate);
const gateKind = String(rawGate.kind ?? "workspace_selection_required");
const requiresAgentBinding = gateKind === "agent_binding_required"
|| gateKind === "agent_identity_selection_required";
Expand Down
70 changes: 70 additions & 0 deletions examples/personal-workspace-browser/typed-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,76 @@ export const typedActionsScenario = {
await operationUi.close();
}

const storedWorkspaceGate = {
schema_version: "loopx_chat_action_proposal_v1",
proposal_id: "stored-goal-create-workspace-gate",
action_kind: "goal.create",
summary: "Create stored workspace Goal",
normalized_parameters: { goal_id: "product-release", title: "Stored workspace Goal", workspace_ref: "current" },
context: { kind: "goal_directory", goal_id: "product-release" },
expected_state_fingerprint: "fixture-workspace-gate-r1",
permission_classification: "durable_write",
validation_evidence: ["workspace selection required"],
available_transitions: ["regenerate", "reject", "defer"],
status: "gated",
receipt: null,
stale: null,
gate: {
kind: "workspace_selection_required",
summary: "Select one server-configured workspace before creating this Goal.",
next_action: "Select a workspace to regenerate the confirmation preview.",
candidates: [{ workspace_ref: "workspace-fixture", label: "Workspace 1" }],
},
created_at: "2026-09-14T01:00:00Z",
updated_at: "2026-09-14T01:00:01Z",
};
const workspaceGateUi = await openWorkspacePage(browser, url, {
apiOptions: { initialActionProposals: [storedWorkspaceGate] },
});
try {
const { api, page } = workspaceGateUi;
await page.reload({ waitUntil: "networkidle" });
await page.getByTestId("personal-goal-home").waitFor({ state: "visible" });
await page.locator(".personal-goal-link", { hasText: "Product Release" }).click();
await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click();
await page.locator(".personal-gated-summary summary").click();
const proposalRow = page.locator(".personal-proposal-row", { hasText: "Stored workspace Goal" });
try {
await proposalRow.waitFor({ state: "visible", timeout: 10_000 });
} catch (error) {
throw new Error(`${error.message}; proposals=${await page.locator(".personal-proposal-row").allInnerTexts()}; errors=${workspaceGateUi.errors.join(" | ")}; body=${(await page.locator("body").innerText()).slice(0, 2000)}`);
}
await proposalRow.click();
const drawer = page.locator('.personal-context-drawer[data-context-kind="proposal"]');
if ((await drawer.innerText()).includes("需要宿主确认")) {
throw new Error("Workspace selection gate was mislabeled as host-only confirmation");
}
const writesBeforeSelection = api.durableWriteCount;
await drawer.getByRole("button", { name: /Workspace 1/ }).click();
await page.getByRole("button", { name: "创建 Goal 并开始首轮", exact: true }).waitFor({ state: "visible" });
const regenerated = api.actionPreviews.at(-1);
if (regenerated?.normalized_parameters.workspace_ref !== "workspace-fixture") {
throw new Error(`Stored workspace selection did not regenerate the Goal preview: ${JSON.stringify(regenerated)}`);
}
if (api.durableWriteCount !== writesBeforeSelection) throw new Error("Selecting a workspace applied without confirmation");
await page.getByRole("button", { name: "创建 Goal 并开始首轮", exact: true }).click();
await page.getByText("已应用,LoopX 状态将刷新。", { exact: true }).waitFor({ state: "visible" });
if (!api.actionApplies.includes(regenerated.proposalId)) throw new Error("Confirmation did not apply the regenerated preview");
if (api.durableWriteCount !== writesBeforeSelection + 1) throw new Error("Confirmation did not write exactly one Goal");
await page.reload({ waitUntil: "networkidle" });
await page.getByTestId("personal-goal-home").waitFor({ state: "visible" });
const stored = await page.evaluate(async (proposalId) => {
const response = await fetch("/api/actions?goal_id=product-release");
const payload = await response.json();
return payload.proposals?.find((proposal) => proposal.proposal_id === proposalId);
}, regenerated.proposalId);
if (stored?.status !== "applied" || stored.receipt?.projection_verified !== true) {
throw new Error(`Confirmed workspace Goal did not survive readback: ${JSON.stringify(stored)}`);
}
} finally {
await workspaceGateUi.close();
}

// Real Goal button -> typed preview -> compiler -> drawer/apply, with only
// the service boundary controlled. No test computes the plan under review.
for (const width of [1512, 390]) {
Expand Down
Loading