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
40 changes: 39 additions & 1 deletion apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
* the steward had already validated.
*/

import { typedActionKindSchema, typedActionProposalSchema } from "../src/data/chat.js";
import {
agentResponseSchema,
isTeamPlanPreviewProposal,
isTodoProposal,
typedActionKindSchema,
typedActionProposalSchema,
} from "../src/data/chat.js";
import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "../src/features/personal-workspace/team-plan-preview.js";

const GOAL_ID = "team-plan-smoke-goal";
Expand All @@ -21,6 +27,38 @@ function check(condition: boolean, message: string) {
check(typedActionKindSchema.safeParse("team.plan").success, "transport accepts the steward team plan kind");
check(!typedActionKindSchema.safeParse("team.plans").success, "the kind stays exact");

// A manager Turn carries the admitted preview beside its Todo proposals: the
// transcript reads one response shape, and a preview must not fail it. Before
// this, a Turn that carried the preview was unreadable, so the answer a steward
// had already produced could not reach the owner at all.
const managerResponse = agentResponseSchema.safeParse({
schema_version: "loopx_chat_agent_response_v0",
message: "Here is the plan",
proposals: [
{ kind: "todo", text: "Do one thing", priority: "P1", rationale: "why" },
{ kind: "steward_team_plan_preview", preview: { goal_id: "team-plan-smoke-goal", lanes: [] } },
],
protected_action: null,
gate: null,
});
check(managerResponse.success, "a Turn response may carry the admitted team preview");
if (managerResponse.success) {
const todoProposals = managerResponse.data.proposals.filter(isTodoProposal);
const teamPlans = managerResponse.data.proposals.filter(isTeamPlanPreviewProposal);
check(todoProposals.length === 1, "only the Todo proposal becomes a candidate card");
check(teamPlans.length === 1 && teamPlans[0].preview.goal_id === "team-plan-smoke-goal", "the preview keeps the Goal it staffs");
}
check(
!agentResponseSchema.safeParse({
schema_version: "loopx_chat_agent_response_v0",
message: "Here is the plan",
proposals: [{ kind: "steward_team_plan_preview" }],
protected_action: null,
gate: null,
}).success,
"a preview without the validated plan is not a readable answer",
);

const plan = {
schema_version: "steward_team_plan_preview_v0",
kind: "steward_team_plan_preview",
Expand Down
28 changes: 27 additions & 1 deletion apps/presentation/dashboard/src/data/chat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,36 @@ export type TodoProposal = {
rationale: string;
};

/**
* An admitted steward team plan that rode in a Turn response.
*
* It is not a Todo and it is not a confirmation: the host validated the plan
* before it surfaced it, and the surface that confirms it is the typed
* `team.plan` action the manager channel stores as a card. Reading it here only
* keeps the answer intact, so a manager Turn that carries a preview still
* reaches the owner instead of failing the response schema.
*/
export type TeamPlanPreviewProposal = {
kind: "steward_team_plan_preview";
preview: Record<string, unknown>;
};

export type AgentProposal = TodoProposal | TeamPlanPreviewProposal;

export function isTodoProposal(proposal: AgentProposal): proposal is TodoProposal {
return proposal.kind === "todo";
}

export function isTeamPlanPreviewProposal(
proposal: AgentProposal,
): proposal is TeamPlanPreviewProposal {
return proposal.kind === "steward_team_plan_preview";
}

export type AgentResponse = {
schema_version: "loopx_chat_agent_response_v0";
message: string;
proposals: TodoProposal[];
proposals: AgentProposal[];
gate: {
kind: string;
summary: string;
Expand Down
22 changes: 21 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export {
buildGoalStudioNodes,
chatFailureMessage,
completedGoalReviews,
isTeamPlanPreviewProposal,
isTodoProposal,
pendingGoalReviews,
proposalReviewState,
sessionInvalidatedByPayload,
Expand Down Expand Up @@ -175,6 +177,24 @@ export const todoProposalSchema = z.object({
rationale: z.string(),
});

/**
* The steward's admitted team plan, carried beside todo proposals.
*
* The plan is validated by the host before it reaches this response, and the
* card that confirms it is the typed `team.plan` action the manager channel
* stores. This schema exists so a Turn that carries the preview still parses
* here; it grants nothing and reads no lane into existence.
*/
export const teamPlanPreviewProposalSchema = z.object({
kind: z.literal("steward_team_plan_preview"),
preview: z.record(z.string(), z.unknown()),
});

export const agentProposalSchema = z.discriminatedUnion("kind", [
todoProposalSchema,
teamPlanPreviewProposalSchema,
]);

export const protectedActionProposalSchema = z.object({
operation: z.enum(["merge", "release", "deploy", "delete", "payment"]),
target: z.string().min(1).max(160),
Expand All @@ -186,7 +206,7 @@ export type ProtectedActionProposal = z.infer<typeof protectedActionProposalSche
export const agentResponseSchema = z.object({
schema_version: z.literal("loopx_chat_agent_response_v0"),
message: z.string(),
proposals: z.array(todoProposalSchema),
proposals: z.array(agentProposalSchema),
protected_action: protectedActionProposalSchema.nullable().optional().default(null),
gate: z
.object({
Expand Down
39 changes: 31 additions & 8 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
sessionInvalidatedByPayload,
todoNoWriteReceiptFromPayload,
todoReceiptLabel,
isTeamPlanPreviewProposal,
isTodoProposal,
type ChatSessionSnapshot,
type ChatSessionSummary,
type ChatImageAttachment,
Expand Down Expand Up @@ -1849,7 +1851,11 @@ function PersonalGoalHome({
?? model.goals[0]
?? null;
if (recoveryGoal && streamed.response.proposals.length > 0) {
const cards = streamed.response.proposals.map((proposal) => ({
// A recovered Turn may carry the steward's admitted team plan beside
// its todo proposals. The plan is not a candidate Todo: the manager
// channel already stored it as the typed card the owner confirms, so
// only the todos become cards here.
const cards = streamed.response.proposals.filter(isTodoProposal).map((proposal) => ({
goalId: recoveryGoal.goalId,
id: proposalId.current++,
previewId: null,
Expand All @@ -1858,10 +1864,12 @@ function PersonalGoalHome({
state: "candidate" as const,
statusMessage: null,
}));
setProposalsByContext((current) => ({
...current,
[targetContextId]: [...(current[targetContextId] ?? []), ...cards],
}));
if (cards.length > 0) {
setProposalsByContext((current) => ({
...current,
[targetContextId]: [...(current[targetContextId] ?? []), ...cards],
}));
}
}
} catch (error) {
if (cancelled) return;
Expand Down Expand Up @@ -2221,13 +2229,28 @@ function PersonalGoalHome({
text: visibleAgentMessage(response.message || streamedText.trim())
|| `${answerIdentityLabel(targetContextId, selectedRoute.label)} 已完成分析。`,
});
if (response.proposals.length > 0 && !targetGoal) {
const teamPlanPreviews = response.proposals.filter(isTeamPlanPreviewProposal);
if (teamPlanPreviews.length > 0) {
// The steward's team plan is offered as the typed card the manager
// channel stored for the Goal it staffs, so the owner confirms the plan
// there instead of typing the request again.
const goals = [...new Set(teamPlanPreviews
.map((preview) => String(preview.preview.goal_id ?? ""))
.filter(Boolean))];
updateManagerAssistantMessage(targetContextId, streamingMessageId, {
lines: [goals.length > 0
? `团队计划已生成可确认卡片(${goals.join("、")}),确认后才会创建 lane。`
: "团队计划已生成可确认卡片,确认后才会创建 lane。"],
});
}
const todoProposals = response.proposals.filter(isTodoProposal);
if (todoProposals.length > 0 && !targetGoal && teamPlanPreviews.length === 0) {
updateManagerAssistantMessage(targetContextId, streamingMessageId, {
lines: ["请进入要修改的 Goal,预览并确认具体变更。"],
});
}
if (response.proposals.length > 0 && targetGoal) {
const cards = response.proposals.map((proposal) => ({
if (todoProposals.length > 0 && targetGoal) {
const cards = todoProposals.map((proposal) => ({
goalId: targetGoal.goalId,
id: proposalId.current++,
previewId: null,
Expand Down
46 changes: 38 additions & 8 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,15 +543,32 @@ may not invent:
- the stop condition that ends the team.

A requested lane that cannot be staffed is a typed gap -- `agent_not_registered`,
`capability_not_granted` or `audience_not_authorized` -- and the gap keeps the
work it did not staff under `declined_first_todo`, so the owner sees what was
asked for and what is missing instead of a lane that was quietly filled in or
dropped. A lane that declares a gap may not declare work. The plan is a preview:
`capability_not_granted` or `audience_not_authorized`, plus the host's own
`action_kind_not_supported` -- and the gap keeps the work it did not staff under
`declined_first_todo`, so the owner sees what was asked for and what is missing
instead of a lane that was quietly filled in or dropped. The reasons a plan may
declare for a lane it cannot staff itself and the reasons the host reports about
one are two vocabularies: a plan cannot claim the host's verdict about its own
lane, and a reader can tell a declared gap from a staffability verdict Core made.

Two facts about *one lane* can make it unstaffable: this Goal does not register
its Agent, or this host does not ship the action kind the lane asked for. Both
are lane facts, so both become the same typed gap and neither refuses the plan:
a plan whose first lane cannot be staffed is still the owner's request, and its
staffable lanes are exactly what the owner asked to review. Refusing the whole
plan for one lane's kind is what made a live steward answer a one-sentence team
request correctly and still offer the owner nothing to confirm.

A lane that declares a gap may not declare work. The plan is a preview:
the validated payload carries `applies: false`, and an owner's confirmation of
that exact preview is the only thing that admits an apply. Apply routes to the
canonical owners each effect already has -- Agent registration, Todo creation,
quota or goal policy -- reuses the identities the preview named, may not widen
the confirmed scope, and a team plan is never settled as if the work were done.
The confirmed payload is therefore a fixed point of the validator: validating an
admitted preview again returns the same lanes, and a lane the preview reported as
unstaffed is preserved as a gap rather than re-derived from the host's *current*
facts, so an apply can never staff a lane the owner was shown as unstaffed.

Shipped enforcement, in delivery order:

Expand Down Expand Up @@ -589,10 +606,23 @@ Shipped enforcement, in delivery order:
bounded Todo and returns the lane readback. A registration change between
preview and apply makes the proposal stale rather than applying a plan whose
staffing has drifted.

What is still missing is the surface that sends that confirmation and the
per-Goal coverage behind it: a multi-lane preview has no frontend confirmation
surface yet. Traceability is recorded rather than implied: the settlement reads
6. **Admitted preview to confirmable card.** The owner's own local manager
channel projects the preview it admitted into the typed action store, so the
product surface the owner already reads lists exactly one `team.plan` card
scoped to the Goal the plan staffs. The projection is idempotent per plan, so
a replayed Turn or a repeated ask reuses the card instead of stacking a
second one; it creates no work, and it grants nothing that confirming the card
would not grant. A Turn response now carries the preview beside its Todo
proposals, so the surfaces read one response shape instead of deciding which
kinds of answer may arrive.

The confirmation surface the owner's own channel needs is shipped: the admitted
preview reaches the typed action store, and the card it produces renders the
same click path a single-lane `team.plan` card already had. What is still
missing is the per-audience coverage behind it: a remote manager audience (the
Lark manager channel) keeps the preview in its answer text and has no card on
its own surface yet, so no card is written on its behalf. Traceability is
recorded rather than implied: the settlement reads
the Goal's canonical source basis before it writes, and the receipt carries it as
a bounded `intent_basis`, so each lane Todo can be tied to the revision it was
meant to advance even though the Todo row itself does not carry the field. The
Expand Down
47 changes: 33 additions & 14 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,13 +423,25 @@ kind 为 `steward_team_plan_preview`(`steward_team_plan_preview_v0`)的提
- 结束每条 lane 的验收信号;
- 结束整个团队的终止条件。

配不齐的 lane 是类型化的 gap——`agent_not_registered`、`capability_not_granted` 或
`audience_not_authorized`——并且该 gap 会把没配上人的那份工作留在 `declined_first_todo`
里,让业主看到"要了什么、缺了什么",而不是一条被悄悄填上或被丢掉的 lane;声明 gap 的
lane 不得再声明工作。计划是**预览**:校验通过的载荷带 `applies: false`;只有业主对这份确切
预览的确认,才允许进入落地。落地只经各 effect 既有的 canonical owner——Agent 注册、
Todo 创建、quota 或 goal policy——复用预览点名的身份,不得扩大已确认范围,也不得把团队计划
当作工作已完成的结算。
配不齐的 lane 是类型化的 gap——`agent_not_registered`、`capability_not_granted`、
`audience_not_authorized`,以及宿主自己的 `action_kind_not_supported`——并且该 gap 会把没
配上人的那份工作留在 `declined_first_todo` 里,让业主看到"要了什么、缺了什么",而不是一条
被悄悄填上或被丢掉的 lane。计划可以为自己的 lane 声明 gap 理由,但不得冒用宿主对这条 lane
的判断:声明用一套词表,宿主回报用另一套,读者因此能区分"计划自述的 gap"和"Core 做出的
可配人性判断"。

让**一条 lane** 配不齐的事实有两种:本 Goal 没注册它的 Agent,或者本机不 ship 它要的
action kind。两者都是 lane 级别的事实,因此都变成同一种类型化 gap,且都不拒绝整份计划:
第一条 lane 配不齐的计划仍然是业主的请求,而它可配齐的那些 lane 正是业主要审的东西。当初
正是"因为一条 lane 的 kind 而拒绝整份计划",让线上管家把一句话团队请求答对了,却什么也给不
出确认。

声明 gap 的 lane 不得再声明工作。计划是**预览**:校验通过的载荷带 `applies: false`;只有
业主对这份确切预览的确认,才允许进入落地。落地只经各 effect 既有的 canonical owner——
Agent 注册、Todo 创建、quota 或 goal policy——复用预览点名的身份,不得扩大已确认范围,也不
得把团队计划当作工作已完成的结算。因此,这份被确认的载荷在校验器下是**不动点**:对已准入的
预览再校验一次会得到同样的 lanes,而预览已报为 unstaffed 的 lane 会被保留成 gap,而不是
按宿主**当前**事实重新推导,所以落地永远不会去给一条业主看到的是 gap 的 lane 配上人。

按交付顺序,已经落地并受强制的部分:

Expand All @@ -455,13 +467,20 @@ Todo 创建、quota 或 goal policy——复用预览点名的身份,不得扩
提案所有者在 `PRE_SETTLEMENT` 相位重新校验,因此**一次业主确认**就会为每条 ready lane
建出首个有界 Todo 并返回 lane 回读。预览与落地之间若发生注册变化,提案会变为 stale,而
不是把 staffing 已经漂移的计划落地。

仍然缺的是**发出这次确认的表面**:多 lane 预览还没有前端确认面。可追溯性已经被记录而不是被
暗示:结算在写入**之前**读取该 Goal 的规范 source basis,回执以有界字段 `intent_basis`
携带它,因此每条 lane Todo 都能被追溯回它本应推进的那个修订——尽管 Todo 行本身还不携带该
字段。回读也不再是缺口:落地会把这次确保的每一条 lane Todo 以有界字段 `lane_todo_ids` 发布
出去;这两个字段都是那个封闭且持久化的回执字段集的加性例外,因此早前写下的回执仍然通过校验,
而团队计划回执不带 monitor key——计划不是 monitor。
6. **从准入预览到可确认卡片。** 业主自己的本地管家通道会把它已准入的预览投影进类型化
action store,因此业主本就在读的产品面上会列出**恰好一张** `team.plan` 卡片,且作用域是
计划点名的那个 Goal。投影按计划幂等:重放的 Turn 或重复的请求复用同一张卡片,而不会叠出
第二张;它不创建工作,也不授予任何"确认卡片"之外的东西。Turn 响应现在把预览与 Todo 提案
放在同一形状里,因此产品面读一种响应形状,而不是自己判断"哪种答案可能出现"。

业主自己那条通道所需的确认面已经落地:准入预览会到达类型化 action store,它产出的卡片走的
正是单 lane `team.plan` 卡片本就有的点击路径。仍然缺的是它背后的**按受众覆盖**:远端管家
受众(Lark 管家通道)仍只在回答正文里带着预览,在它自己的面上还没有卡片,因此不会替它写下
卡片。可追溯性已经被记录而不是被暗示:结算在写入**之前**读取该 Goal 的规范 source basis,
回执以有界字段 `intent_basis` 携带它,因此每条 lane Todo 都能被追溯回它本应推进的那个修订
——尽管 Todo 行本身还不携带该字段。回读也不再是缺口:落地会把这次确保的每一条 lane Todo 以
有界字段 `lane_todo_ids` 发布出去;这两个字段都是那个封闭且持久化的回执字段集的加性例外,
因此早前写下的回执仍然通过校验,而团队计划回执不带 monitor key——计划不是 monitor。

### 与 multi-agent / shared authority 契约的关系

Expand Down
Loading
Loading