Skip to content
Closed
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
37 changes: 36 additions & 1 deletion apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
typedActionKindSchema,
typedActionProposalSchema,
} from "../src/data/chat.js";
import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "../src/features/personal-workspace/team-plan-preview.js";
import { teamPlanFields, teamPlanGapLaneLine, teamPlanGoalId, teamPlanLaneCount, teamPlanReceiptGapLanes } from "../src/features/personal-workspace/team-plan-preview.js";

const GOAL_ID = "team-plan-smoke-goal";

Expand Down Expand Up @@ -132,6 +132,9 @@ const translate = (key: string, values?: Record<string, string | number>) => {
"proposal.teamPlan.acceptanceShort": "acceptance",
"proposal.teamPlan.gapLane": "unstaffed",
"proposal.teamPlan.laneUnstaffed": "staffing gap, no first Todo",
"proposal.teamPlan.appliedGapLane": "{lane} ({agent}) stayed unstaffed: {reason}",
"proposal.teamPlan.gapReason.agentNotRegistered": "the Agent is not registered for this Goal",
"proposal.teamPlan.gapReason.actionKindNotSupported": "this host does not ship that action kind",
};
const template = table[key] ?? key;
return Object.entries(values ?? {}).reduce(
Expand Down Expand Up @@ -168,4 +171,36 @@ check(
"the card never renders a lane as already created",
);

// Confirming the plan replaces the preview facts with the apply receipt, so
// the readback has to carry the same lane identity: a partial application that
// reported only a count left the owner unable to name what was missing.
const partialReceipt = {
outcome: "team_plan_partially_applied",
gap_count: 1,
gap_lanes: [
{ lane_id: "lane_review", agent_id: "agent-reviewer", reason_code: "agent_not_registered" },
],
};
const appliedGaps = teamPlanReceiptGapLanes(partialReceipt);
check(appliedGaps.length === 1 && appliedGaps[0].laneId === "lane_review", "the apply receipt names the lane that stayed unstaffed");
check(
teamPlanGapLaneLine(appliedGaps[0], translate as never)
=== "lane_review (agent-reviewer) stayed unstaffed: the Agent is not registered for this Goal",
"the applied card says which lane is missing and what would let it run",
);
check(
teamPlanGapLaneLine({ laneId: "lane_x", agentId: "", reasonCode: "future_reason" }, translate as never)
.endsWith("future_reason"),
"an unrecognized host reason is shown verbatim instead of being invented",
);
check(
teamPlanReceiptGapLanes({ outcome: "team_plan_applied" }).length === 0,
"a plan that staffed every lane reports no gap lanes",
);
check(
teamPlanReceiptGapLanes(null).length === 0
&& teamPlanReceiptGapLanes({ gap_lanes: [{ agent_id: "agent-reviewer" }] }).length === 0,
"a receipt without the field, or without a lane, reports no gap lanes",
);

if (process.exitCode !== 1) console.log("team plan proposal smoke ok");
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
import type { LarkGoalConnection } from "../../data/chat";
import { localizedAttentionAge, localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n";
import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue } from "./personal-workspace-model";
import { teamPlanGapLaneLine } from "./team-plan-preview";
import { todoResumeWhenFromMessage } from "./personal-workspace-router";

function subagentModelRequest(include: boolean, model: string, effort: string) {
Expand Down Expand Up @@ -972,6 +973,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<dl>{selection.item.fields.map((field) => <div key={field.key}><dt>{field.label}</dt><dd>{field.value}</dd></div>)}</dl>
</section>
{selection.item.status === "applied" ? <p className={`personal-proposal-state ${selection.item.actionKind === "operation.execute" && selection.item.reviewPlan?.reason === "readback_unverified" ? "is-gated" : "is-applied"}`}><Check size={16} />{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.proposalApplied")}</p> : null}
{selection.item.status === "applied" && selection.item.actionKind === "team.plan" && (selection.item.teamPlanGapLanes?.length ?? 0) > 0 ? <div className="personal-proposal-state is-gated"><span><strong>{t("drawer.proposalAppliedUnstaffed", { count: selection.item.teamPlanGapLanes!.length })}</strong><ul className="personal-proposal-explainer">{selection.item.teamPlanGapLanes!.map((gap) => <li key={gap.laneId}>{teamPlanGapLaneLine(gap, t)}</li>)}</ul></span></div> : null}
{selection.item.status === "applied" && selection.item.actionKind !== "operation.execute" && selection.item.goalId ? <button className="personal-primary-action" onClick={() => { const goalId = selection.item.goalId!; onClose(); void callbacks.onOpenGoal?.(goalId); }} type="button"><ExternalLink size={16} />{selection.item.actionKind === "goal.create" ? t("drawer.proposalEnterGoal") : t("drawer.proposalViewGoal")}</button> : null}
{selection.item.status === "stale" ? <p className="personal-proposal-state is-stale">{t("drawer.proposalStale")}</p> : null}
{selection.item.status === "error" ? <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("drawer.proposalApplyFailedHint")}</small></div> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ const en = {
"drawer.priority": "Priority",
"drawer.progress": "Progress",
"drawer.proposalApplied": "Applied. LoopX state will refresh.",
"drawer.proposalAppliedUnstaffed": "Applied, with {count} lane(s) still unstaffed:",
"drawer.proposalApplyFailed": "Apply failed. No changes were written.",
"drawer.proposalApplyFailedHint": "Refresh the Goal state and regenerate. If it still fails, keep this page open and inspect advanced diagnostics.",
"drawer.proposalClose": "Close",
Expand Down Expand Up @@ -701,6 +702,9 @@ const en = {
"proposal.teamPlan.acceptanceShort": "acceptance",
"proposal.teamPlan.gapLane": "unstaffed",
"proposal.teamPlan.laneUnstaffed": "staffing gap, no first Todo",
"proposal.teamPlan.appliedGapLane": "{lane} ({agent}) stayed unstaffed: {reason}",
"proposal.teamPlan.gapReason.agentNotRegistered": "the Agent is not registered for this Goal",
"proposal.teamPlan.gapReason.actionKindNotSupported": "this host does not ship that action kind",
"proposal.workspace.current": "Current local workspace (no Repository bound)",
"proposal.workspace.named": "{workspace} (execution environment only; does not bind a repository)",
"proposal.workspaceGate.agentImpact": "Bind an Agent identity, then recheck the original action. No Goal has been written.",
Expand Down Expand Up @@ -1274,6 +1278,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"drawer.priority": "优先级",
"drawer.progress": "进度",
"drawer.proposalApplied": "已应用,LoopX 状态将刷新。",
"drawer.proposalAppliedUnstaffed": "已应用,但有 {count} 条 lane 仍未组建:",
"drawer.proposalApplyFailed": "应用失败,没有写入任何变更。",
"drawer.proposalApplyFailedHint": "请刷新 Goal 状态后重新生成;若仍失败,可保留此页并查看高级诊断。",
"drawer.proposalClose": "关闭",
Expand Down Expand Up @@ -1738,6 +1743,9 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"proposal.teamPlan.acceptanceShort": "验收",
"proposal.teamPlan.gapLane": "未配齐",
"proposal.teamPlan.laneUnstaffed": "编制缺口,无首个 Todo",
"proposal.teamPlan.appliedGapLane": "{lane}({agent})仍未组建:{reason}",
"proposal.teamPlan.gapReason.agentNotRegistered": "该 Agent 未在该 Goal 注册",
"proposal.teamPlan.gapReason.actionKindNotSupported": "本机不支持该动作类型",
"proposal.workspace.current": "当前本地工作区(未绑定 Repository)",
"proposal.workspace.named": "{workspace}(仅提供执行环境,不会自动关联仓库)",
"proposal.workspaceGate.agentImpact": "先完成 Agent 身份绑定,再重新检查原操作;当前没有写入 Goal。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ export type WorkspaceActionPreview = {
primaryLabel?: string;
errorMessage?: string;
status: "draft" | "ready" | "applying" | "applied" | "gated" | "stale" | "error" | "rejected" | "deferred";
// The lanes a confirmed team plan left unstaffed, read from the apply
// receipt so the card can name them after the confirmation, not only in the
// preview that the confirmation replaced.
teamPlanGapLanes?: Array<{ laneId: string; agentId: string; reasonCode: string }>;
title: string;
sourceRequest?: WorkspaceActionPreviewRequest;
workspaceCandidates?: Array<{ label: string; workspaceRef: string }>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
isStaleActionFailure,
} from "../../../../../../loopx/control_plane/presentation/action_review_plan.js";
import { refreshAttention } from "./attention-details";
import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "./team-plan-preview";
import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount, teamPlanReceiptGapLanes } from "./team-plan-preview";
import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent } from "react";
import { AlertCircle, Bot, CalendarClock, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X } from "lucide-react";

Expand Down Expand Up @@ -634,6 +634,9 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
&& reviewPlan.interaction !== "completed"
? "error"
: proposalStatus(proposal.status),
teamPlanGapLanes: proposal.action_kind === "team.plan"
? teamPlanReceiptGapLanes(proposal.receipt)
: undefined,
title: localizedSummary,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,56 @@ export function teamPlanGoalId(parameters: Record<string, unknown>): string {
const plan = asRecord(parameters.plan);
return asText(parameters.goal_id) || asText(plan.goal_id);
}

export type TeamPlanGapLane = { laneId: string; agentId: string; reasonCode: string };

/**
* Read the lanes a confirmed plan left unstaffed out of the apply receipt.
*
* The applied state reported only how many lanes were missing, which an owner
* cannot act on: a count does not say which lane is still waiting or what would
* let it run. The receipt names each missing lane with the host fact behind it,
* so the card that confirmed the plan can say both. A receipt without the field
* (an older runtime, or a plan that staffed every lane) reports no gap lanes.
*/
export function teamPlanReceiptGapLanes(receipt: unknown): TeamPlanGapLane[] {
const record = asRecord(receipt);
const raw = Array.isArray(record.gap_lanes) ? record.gap_lanes : [];
return raw
.map((entry) => {
const gap = asRecord(entry);
return {
laneId: asText(gap.lane_id),
agentId: asText(gap.agent_id),
reasonCode: asText(gap.reason_code),
};
})
.filter((gap) => gap.laneId.length > 0);
}

/** The host fact that kept one lane unstaffed, in the reader's language. */
export function teamPlanGapReason(
reasonCode: string,
t: WorkspaceTranslate,
): string {
if (reasonCode === "agent_not_registered") {
return t("proposal.teamPlan.gapReason.agentNotRegistered");
}
if (reasonCode === "action_kind_not_supported") {
return t("proposal.teamPlan.gapReason.actionKindNotSupported");
}
// An unrecognized code is a host fact this card has no words for. Naming it
// verbatim is honest; inventing a reason for it would not be.
return reasonCode;
}

export function teamPlanGapLaneLine(
gap: TeamPlanGapLane,
t: WorkspaceTranslate,
): string {
return t("proposal.teamPlan.appliedGapLane", {
lane: gap.laneId,
agent: gap.agentId || gap.laneId,
reason: teamPlanGapReason(gap.reasonCode, t),
});
}
54 changes: 53 additions & 1 deletion examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,55 @@ function withMachineConfiguration(capability, { configuration, description }) {
};
}

/**
* The receipt a confirmed team plan writes, for the plan the fixture stored.
*
* The product answers an apply with the plan's own outcome, so a scenario that
* confirms a plan carrying a staffing gap has to observe the same readback the
* card renders: which lanes became work and which ones stayed unstaffed, with
* the host fact behind each gap. The lanes are derived from the stored plan
* because that is the plan the apply was confirmed against.
*/
function teamPlanApplyReceipt(proposal) {
const plan = proposal?.normalized_parameters?.plan;
if (!plan || typeof plan !== "object" || !Array.isArray(plan.lanes)) return null;
const ready = plan.lanes.filter((lane) => lane.staffing !== "gap");
const gaps = plan.lanes.filter((lane) => lane.staffing === "gap");
const receipt = {
projection_verified: true,
receipt_id: "fixture-team-plan-receipt",
};
if (ready.length > 0) {
receipt.outcome = gaps.length > 0 ? "team_plan_partially_applied" : "team_plan_applied";
}
if (ready.length > 0) {
receipt.resource_ids = {
goal_id: proposal?.normalized_parameters?.goal_id ?? null,
todo_id: `todo_${ready[0].lane_id}`,
lane_todo_ids: ready.map((lane) => `todo_${lane.lane_id}`),
};
receipt.lanes = ready.map((lane) => ({
lane_id: lane.lane_id,
agent_id: lane.agent_id,
priority: lane.first_todo?.priority ?? "P1",
disposition: "created",
todo_id: `todo_${lane.lane_id}`,
acceptance: lane.acceptance,
}));
}
if (gaps.length > 0) {
// The count and the lanes it counts stay together, and a lane that stayed
// unstaffed names the Agent it was meant to run on.
receipt.gap_count = gaps.length;
receipt.gap_lanes = gaps.map((lane) => ({
lane_id: lane.lane_id,
agent_id: lane.agent_id,
reason_code: lane.gap_reason_code,
}));
}
return receipt;
}

export function startServer() {
if (packaged) {
return spawn(process.env.LOOPX_PYTHON_BIN || "python3", [
Expand Down Expand Up @@ -1565,11 +1614,14 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
state.durableResources.add(resourceKey);
state.durableWriteCount += 1;
}
// Injected proposals live in the action store, not in the session
// previews, so the plan a confirmed card carries has to be read there.
const teamPlanReceipt = teamPlanApplyReceipt(actionProposals.get(apply[1]));
const proposal = {
schema_version: "loopx_chat_action_proposal_v1", proposal_id: apply[1], action_kind: actionKind,
summary: "已应用", normalized_parameters: preview?.normalized_parameters ?? {}, context: preview?.context ?? {}, expected_state_fingerprint: "fixture-r1",
permission_classification: "durable_write", validation_evidence: [], available_transitions: ["apply", "cancel"],
status: "applied", receipt: { projection_verified: true, receipt_id: "fixture-receipt" }, stale: null, created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:01Z",
status: "applied", receipt: teamPlanReceipt ?? { projection_verified: true, receipt_id: "fixture-receipt" }, stale: null, created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:01Z",
};
actionProposals.set(apply[1], proposal);
await route.fulfill({ contentType: "application/json", json: { ok: true, proposal, turn: acceptedTurn }, status: acceptedTurn ? 202 : 200 });
Expand Down
13 changes: 13 additions & 0 deletions examples/personal-workspace-browser/team-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,19 @@ export const teamPlanScenario = {
check(api.durableWriteCount === 1, "the confirmed apply performed exactly one durable write");
await drawer.getByText("已应用,LoopX 状态将刷新。", { exact: true })
.waitFor({ state: "visible", timeout: 15_000 });
// The confirmation replaced the preview with the apply receipt, so the
// card has to keep naming the lane that stayed unstaffed and the host
// fact behind it; a bare count would leave the owner with a number and
// nothing to act on.
const appliedText = await drawer.innerText();
check(
appliedText.includes("已应用,但有 1 条 lane 仍未组建:"),
"the applied card says how many lanes stayed unstaffed",
);
check(
appliedText.includes("lane_review(agent-reviewer)仍未组建:该 Agent 未在该 Goal 注册"),
"the applied card names the unstaffed lane, its Agent and the host reason",
);
await page.screenshot({
path: resolve(outputDir, "team-plan-applied.png"),
fullPage: false,
Expand Down
2 changes: 1 addition & 1 deletion loopx/canary/module_metric_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"loopx/chat_actions.py": {
"any_count": 52,
"dict_any_count": 0,
"lines": 1604
"lines": 1608
},
"loopx/chat_runtime.py": {
"any_count": 35,
Expand Down
4 changes: 4 additions & 0 deletions loopx/chat_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,10 @@ def _apply_team_plan(
receipt["lanes"] = [dict(item) for item in lane_settlements]
if gap_count:
receipt["gap_count"] = gap_count
if settlement.get("gap_lanes"):
# A partial commitment names what is still missing and the host fact
# behind each lane; the settlement already resolved both.
receipt["gap_lanes"] = [dict(item) for item in settlement["gap_lanes"]]
if intent_basis:
# The canonical revision these lanes were created against, so the
# owner's readback can name what the work advances.
Expand Down
Loading
Loading