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
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/chat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function selectAvailableChatAgent<T extends ChatRouteCandidate>(

export type TodoProposal = {
kind: "todo";
goal_id?: string;
text: string;
priority: "P0" | "P1" | "P2";
rationale: string;
Expand Down
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export const chatCapabilitiesSchema = z.object({

export const todoProposalSchema = z.object({
kind: z.literal("todo"),
goal_id: z.string().min(1).optional(),
text: z.string(),
priority: z.enum(["P0", "P1", "P2"]),
rationale: z.string(),
Expand Down
59 changes: 35 additions & 24 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,21 @@ function semanticProtectedActionPreview(
// derived from the Turn, so observing the same completion twice reuses the
// stored preview instead of offering a duplicate.
function todoProposalPreviewRequests(
goalId: string,
goalId: string | undefined,
turnId: string,
proposals: AgentResponse["proposals"],
): WorkspaceActionPreviewRequest[] {
return proposals.filter(isTodoProposal).map((proposal, index) => ({
actionKind: "todo.create",
context: { goal_id: goalId, kind: "goal" },
idempotencyKey: `chat-todo-proposal:${turnId}:${index}`,
normalizedParameters: { goal_id: goalId, priority: proposal.priority, text: proposal.text },
summary: proposal.text,
}));
return proposals.filter(isTodoProposal).flatMap((proposal, index) => {
const target = goalId ?? proposal.goal_id;
if (!target || (goalId && proposal.goal_id && proposal.goal_id !== goalId)) return [];
return [{
actionKind: "todo.create",
context: goalId ? { goal_id: goalId, kind: "goal" } : { goal_id: "loopx-manager", kind: "manager" },
idempotencyKey: `chat-todo-proposal:${turnId}:${index}`,
normalizedParameters: { goal_id: target, priority: proposal.priority, text: proposal.text },
summary: proposal.text,
}];
});
}
import type { StatusSourceControl } from "../features/personal-workspace/status-source-switcher";
import { applyRemoteGoalLifecycle, ensureSshSource } from "../data/ssh-host-catalog";
Expand Down Expand Up @@ -1898,17 +1902,20 @@ function PersonalGoalHome({
proposals: AgentResponse["proposals"],
streamingMessageId: number | null,
) {
const requests = goalId && model.goals.some((goal) => goal.goalId === goalId)
? todoProposalPreviewRequests(goalId, turnId, proposals)
const manager = targetContextId === "manager";
const requests = (manager || model.goals.some((goal) => goal.goalId === goalId))
? todoProposalPreviewRequests(manager ? undefined : goalId, turnId, proposals)
.filter((request) => model.goals.some((goal) => goal.goalId === request.normalizedParameters.goal_id))
: [];
if (!requests.length) return true;
// Existing previews own their lifecycle, including applied/rejected states.
// Recomputing a preview after Goal state changes can conflict with its stored
// fingerprint; use the persisted Turn key before attempting any new preview.
const stored = await listTypedActions({ contextKind: "goal", goalId }).catch(() => null);
const contextGoalId = manager ? "loopx-manager" : goalId;
const stored = await listTypedActions({ contextKind: manager ? "manager" : "goal", goalId: contextGoalId }).catch(() => null);
if (!stored) return false;
const known = new Set(stored.filter((proposal) => proposal.action_kind === "todo.create"
&& proposal.context.goal_id === goalId).map((proposal) => proposal.idempotency_key));
&& proposal.context.goal_id === contextGoalId).map((proposal) => proposal.idempotency_key));
const missing = requests.filter((request) => !known.has(request.idempotencyKey));
const results = await Promise.allSettled(missing.map((request) => previewTypedAction(request)));
if (results.some((result) => result.status === "fulfilled")) setTypedActionsRevision((current) => current + 1);
Expand All @@ -1931,7 +1938,8 @@ function PersonalGoalHome({
useEffect(() => {
const history = conversationHistory.history;
const goalId = selectedGoal?.goalId;
if (readOnly || !goalId || !history) return;
const manager = contextId === "manager";
if (readOnly || (!goalId && !manager) || !history) return;
const controller = new AbortController();
const projected = new Set<string>(); // Optimization only; never a durable claim.
let timer: number | undefined;
Expand All @@ -1940,7 +1948,8 @@ function PersonalGoalHome({
let retry = false;
for (const snapshot of history.snapshots) {
const session = snapshot.session;
if (session.goal_id !== goalId || session.channel_id !== `goal.${goalId}`) continue;
if (manager ? session.goal_id !== "loopx-manager" || session.channel_id !== "manager"
: session.goal_id !== goalId || session.channel_id !== `goal.${goalId}`) continue;
const turnIds = new Set(snapshot.messages
.map((message) => message.turn_id)
.filter((turnId): turnId is string => Boolean(turnId) && turnId !== session.active_turn_id));
Expand All @@ -1951,7 +1960,7 @@ function PersonalGoalHome({
try {
const completed = await readCompletedChatTurn(session.session_id, turnId, controller.signal);
if (controller.signal.aborted) return;
const persisted = !completed || await projectRecoveredTurnProposals(goalId, session.goal_id, turnId, completed.proposals, null);
const persisted = !completed || await projectRecoveredTurnProposals(manager ? "manager" : goalId!, session.goal_id ?? undefined, turnId, completed.proposals, null);
if (persisted) projected.add(key);
else retry = true;
} catch {
Expand All @@ -1970,7 +1979,7 @@ function PersonalGoalHome({
controller.abort();
window.clearTimeout(timer);
};
}, [conversationHistory.history, selectedGoal?.goalId, readOnly]);
}, [conversationHistory.history, contextId, selectedGoal?.goalId, readOnly]);

useEffect(() => {
if (readOnly) return;
Expand Down Expand Up @@ -2313,20 +2322,22 @@ function PersonalGoalHome({
}
}).catch(() => { /* The original conversation remains readable. */ });
const todoProposals = response.proposals.filter(isTodoProposal);
// The channel already states where a team plan is confirmed: its answer
// names the Goal whose workspace holds the card, so a manager-channel
// proposal here is only ever a Todo the owner has to be sent to.
if (todoProposals.length > 0 && !targetGoal) {
// A scoped manager Todo can use the existing typed action card. Legacy
// unscoped proposals still require a Goal; never guess from UI selection.
if (todoProposals.some((proposal) => !proposal.goal_id) && !targetGoal) {
updateConversationMessage(targetContextId, streamingMessageId, {
text: [visibleAgentMessage(response.message || streamedText.trim()), "请进入要修改的 Goal,预览并确认具体变更。"].filter(Boolean).join("\n\n"),
});
}
if (targetGoal && todoProposals.some((proposal) => proposal.goal_id && proposal.goal_id !== targetGoal.goalId)) {
updateConversationMessage(targetContextId, streamingMessageId, {
lines: ["请进入要修改的 Goal,预览并确认具体变更。"],
text: [visibleAgentMessage(response.message || streamedText.trim()), locale === "zh-CN" ? "待办目标与当前 Goal 不一致;尚未准备写入预览,请核对目标。" : "The task targets another Goal; no write preview was prepared. Check the intended Goal."].filter(Boolean).join("\n\n"),
});
}
const decision = targetContext.kind === "goal" && response.protected_action
? semanticProtectedActionPreview(targetContextId, question, response.protected_action) ?? undefined
: undefined;
const candidates = targetGoal
? todoProposalPreviewRequests(targetGoal.goalId, streamed.turnId, response.proposals)
: [];
const candidates = todoProposalPreviewRequests(targetGoal?.goalId, streamed.turnId, response.proposals);
if (decision || candidates.length > 0) return { candidates, decision };
} catch (error) {
if (preparationController.signal.aborted && !submittedTurnId) {
Expand Down
21 changes: 20 additions & 1 deletion docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,26 @@ and the written execution boundary must both be respected downstream.
The explicit status-only profile returns a labelled snapshot, not a keyword-built
answer. Converting a reply into a task opens the complete editable text rather
than guessing its next-action sentence. Structured ID/date/resume-condition
validation remains. Lark routing is unchanged.
validation remains. Lark admission retains its existing conversation owner.

Todo proposals may carry an explicit `goal_id` resolved from the permitted
directory. A manager answer with that target reaches the existing typed Todo
confirmation card; an unscoped manager proposal and a proposal naming a different
Goal from the selected Goal never silently inherit a UI target. Legacy unscoped
Goal replies keep their current Goal behavior. This adds no automatic write.

In a bound steward private conversation, the same scoped proposal is prepared
by the canonical `todo.create` action owner and returned with the existing
`/confirm` and `/cancel` commands. The request journal retains its expiry and
idempotency before preview IO. Confirmation requires the same current App,
owner, private source and granted Goal, within fifteen minutes; read scope or a
model proposal alone authorizes no Todo mutation. The existing canonical writer
supplies the verified receipt, and the result returns to that original private
conversation. It neither creates a Goal nor starts or assigns an execution.
Stale, revoked or expired previews fail closed. Lost preview replies recover
the same preview; an already committed Todo recovers its existing receipt.
No callback consumer, executor or independent work queue is introduced. These
source/fixture contracts do not certify an installed Bot or model recognition.

### Conversational goal preparation: integrating the team-workspace proposal

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,19 @@ Goal 权限在 form 中显式选择,保留既有“确认后允许修改工作
下游须同时尊重权限选择与书面执行边界。
显式 status-only profile 返回有标签的 snapshot,不返回以关键词构造的答案。
把回复转为任务时打开完整可编辑文本,不猜测其中的下一步句子。
保留结构化 ID、date 与 resume-condition 验证。Lark routing 不变。
保留结构化 ID、date 与 resume-condition 验证。Lark 准入继续使用原对话 owner。

Todo 提案可以携带从许可目录核实的 `goal_id`。带明确目标的管家回答进入既有类型化
Todo 确认卡;未指定目标的管家提案、或与当前 Goal 不一致的提案,不会静默继承界面
当前选择。旧版未带目标的 Goal 回复仍使用原 Goal;这里不增加自动写入。

在已绑定的管家私聊中,同一有目标提案由规范 `todo.create` owner 准备,并返回既有
`/confirm`、`/cancel` 入口。原请求 journal 在预览 IO 前保留有效期和幂等身份。
确认须来自当前相同 App、本人、私聊来源及获准 Goal,且在十五分钟有效期内;读取
范围和模型提案本身都不授权写 Todo。既有规范 writer 提供已核验回执,结果返回原
私聊;不创建 Goal,不分配或启动执行。过期、撤权和 stale 预览拒绝写入;预览回执
丢失恢复同一提案,已提交的 Todo 恢复原回执,不重复写。没有新增 callback consumer、
执行器或独立工作队列。源码与 fixture 合同不代表已安装 Bot 或模型理解已通过验收。

### 对话式目标准备:吸收团队工作台提案

Expand Down
59 changes: 57 additions & 2 deletions examples/personal-workspace-browser/chat-todo-proposal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { openWorkspacePage } from "./scenario-context.mjs";
// the owner confirms; without a target Goal it must not create anything.
const GOAL_PROMPT = "请给出一个下一步任务建议。";
const MANAGER_PROMPT = "请为全局给出一个任务建议。";
const SCOPED_MANAGER_PROMPT = "请在 Product Release 记录稍后处理的需求。";
const SCOPED_MANAGER_TEXT = "稍后完善阅读体验";
const MANAGER_RECOVERY_PROMPT = "刷新恢复:管家记录另一项需求。";
const MANAGER_RECOVERY_TEXT = "刷新后恢复管家的需求预览";
const FOREIGN_GOAL_PROMPT = "给出另一 Goal 的需求。";
const FOREIGN_GOAL_TEXT = "另一目标的建议不得写入当前目标";
const PROPOSAL_TEXT = "[P1] 核对发布清单并补齐缺失的验证记录";
// The fixture keeps a "刷新恢复" Turn running long enough to reload into it.
const RECOVERY_PROMPT = "刷新恢复:请再给出一个任务建议。";
Expand Down Expand Up @@ -38,7 +44,16 @@ export const chatTodoProposalScenario = {
async run({ browser, collectCoverage, url }) {
const context = await openWorkspacePage(browser, url, { collectCoverage });
const { api, page } = context;
api.answerForMessage = (message) => (message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer
api.answerForMessage = (message) => (message === MANAGER_RECOVERY_PROMPT ? {
message: "恢复管家的待办建议。",
proposals: [{kind: "todo", goal_id: "product-release", priority: "P2", rationale: "刷新不能丢失原建议。", text: MANAGER_RECOVERY_TEXT}],
} : message === SCOPED_MANAGER_PROMPT ? {
message: "已为 Product Release 准备需求预览,尚未写入。",
proposals: [{kind: "todo", goal_id: "product-release", priority: "P2", rationale: "只记录,不启动执行。", text: SCOPED_MANAGER_TEXT}],
} : message === FOREIGN_GOAL_PROMPT ? {
message: "这份建议属于另一个目标。",
proposals: [{kind: "todo", goal_id: "research-monitor", priority: "P2", rationale: "目标不同。", text: FOREIGN_GOAL_TEXT}],
} : message === GOAL_PROMPT || message === MANAGER_PROMPT ? proposalAnswer
: message === DEPARTED_PROMPT ? { message: "离开后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "离开对话不改变建议归属。", text: DEPARTED_TEXT }] }
: message === COMBINED_PROMPT ? { message: "我识别到一个明确的合并请求,并建议先补齐发布说明。", proposals: [{ kind: "todo", priority: "P1", rationale: "合并前需要可核对的说明。", text: COMBINED_TEXT }] }
: message === RECOVERY_PROMPT ? { message: "恢复后给出一个步骤。", proposals: [{ kind: "todo", priority: "P2", rationale: "恢复回合同样需要可确认的草稿。", text: RECOVERY_TEXT }] }
Expand Down Expand Up @@ -66,7 +81,47 @@ export const chatTodoProposalScenario = {
await managerChatTab.click();
if (await managerChatTab.getAttribute("aria-current") !== "page") throw new Error("Manager Chat did not open");
};
// Explicit manager targets reach the existing confirmation card in place;
// this is a preview, never an implicit apply or a new execution.
await openManagerChat();
await composer.fill(SCOPED_MANAGER_PROMPT);
await page.getByRole("button", {name: "发送", exact: true}).click();
const scopedCard = page.locator(".personal-proposal-row", {hasText: SCOPED_MANAGER_TEXT});
await scopedCard.waitFor({state: "visible", timeout: 10_000});
await waitFor(() => previewsWithText(SCOPED_MANAGER_TEXT).length === 1, "Scoped manager Todo did not reach its confirmation card");
const scoped = previewsWithText(SCOPED_MANAGER_TEXT)[0];
if (scoped.normalized_parameters.goal_id !== "product-release" || api.actionApplies.length) {
throw new Error("Manager Todo lost its target or applied without confirmation");
}
await page.reload();
await openManagerChat();
await scopedCard.waitFor({state: "visible", timeout: 10_000});
if (previewsWithText(SCOPED_MANAGER_TEXT).length !== 1 || api.actionApplies.length) {
throw new Error("Reload duplicated or applied the pending manager Todo");
}
await scopedCard.click();
await page.getByRole("dialog").getByRole("button", {name: "确认并应用", exact: true}).click();
await waitFor(() => api.actionApplies.map(decodeURIComponent).includes(scoped.proposalId), "Manager confirmation did not apply the reviewed Todo");
const beforeManagerRecovery = api.turnRequests.length;
await composer.fill(MANAGER_RECOVERY_PROMPT);
await page.getByRole("button", {name: "发送", exact: true}).click();
await waitFor(() => api.turnRequests.length > beforeManagerRecovery, "Manager recovery Turn was not admitted");
api.failNextActionPreview = true;
await page.reload({waitUntil: "domcontentloaded"});
await openManagerChat();
await page.locator(".personal-proposal-row", {hasText: MANAGER_RECOVERY_TEXT}).waitFor({state: "visible", timeout: 15_000});
if (api.failNextActionPreview || previewsWithText(MANAGER_RECOVERY_TEXT).length !== 1) {
throw new Error("Manager recovery lost or duplicated its preview after temporary refusal");
}
if (api.actionApplies.map(decodeURIComponent).includes(previewsWithText(MANAGER_RECOVERY_TEXT)[0].proposalId)) {
throw new Error("Recovering the manager answer implicitly applied its preview");
}
await openGoalChat();
await composer.fill(FOREIGN_GOAL_PROMPT);
await page.getByRole("button", {name: "发送", exact: true}).click();
await page.locator(".personal-channel-timeline").getByText("这份建议属于另一个目标。", {exact: true}).waitFor({state: "visible"});
await page.locator(".personal-channel-timeline").getByText("待办目标与当前 Goal 不一致;尚未准备写入预览,请核对目标。", {exact: true}).waitFor({state: "visible"});
if (previewsWithText(FOREIGN_GOAL_TEXT).length) throw new Error("Another Goal's proposal became a write preview");
await composer.fill(GOAL_PROMPT);
await page.getByRole("button", { name: "发送", exact: true }).click();
const card = page.locator(".personal-proposal-row", { hasText: PROPOSAL_TEXT });
Expand Down Expand Up @@ -216,7 +271,7 @@ export const chatTodoProposalScenario = {
}
return {
coverageEntries: context.coverageEntries,
note: "An Agent Todo proposal becomes a persisted typed preview in its Goal conversation, including one from a Turn recovered after a reload; it stays out of Manager Chat and other Goals, and none is created without a target Goal.",
note: "Scoped Manager and Goal Todo suggestions reach the existing confirmation card. Unscoped Manager and foreign-Goal proposals cannot become writes; reload/departure recovery preserves exactly one preview and never implicitly applies it.",
};
},
};
Loading
Loading