diff --git a/apps/presentation/dashboard/smoke/attention-details-smoke.ts b/apps/presentation/dashboard/smoke/attention-details-smoke.ts
index 070ecab774..6ac93d6ae4 100644
--- a/apps/presentation/dashboard/smoke/attention-details-smoke.ts
+++ b/apps/presentation/dashboard/smoke/attention-details-smoke.ts
@@ -39,6 +39,11 @@ for (const task_class of ["user_action", undefined, "unknown_future_class"]) {
const action = { ...row, details: attentionDetails({ ...source, task_class: "user_action" }) };
const boundAction = attentionDetails(todoItemSchema.parse({ ...source, task_class: "user_action", bound_agent: "codex-delivery" }));
assert(boundAction.boundAgent === "codex-delivery", "canonical User action actor survives schema and projection");
+const reviewSource = {...source, role: "user", task_class: "user_action", action_kind: "review_periodic_report",
+ capability_binding_ref: `periodic-report-review:r${"a".repeat(64)}`};
+assert(attentionDetails(todoItemSchema.parse(reviewSource)).reportReview, "native report edition survives the production schema");
+for (const changed of [{role: "agent"}, {task_class: "user_gate"}, {capability_binding_ref: "periodic-report-review:r../other"}])
+ assert(!attentionDetails(todoItemSchema.parse({...reviewSource, ...changed})).reportReview, "prose or another role cannot select report review");
assert(!canHandleUserAction({ ...row, details: attentionDetails(source) }), "a User gate is decided, not marked done");
const { todo_id: _omitted, ...idless } = source;
assert(!canHandleUserAction({ ...action, details: attentionDetails({ ...idless, task_class: "user_action" }) }), "missing todo_id cannot be written");
diff --git a/apps/presentation/dashboard/src/data/report-reviews.ts b/apps/presentation/dashboard/src/data/report-reviews.ts
new file mode 100644
index 0000000000..b321a80435
--- /dev/null
+++ b/apps/presentation/dashboard/src/data/report-reviews.ts
@@ -0,0 +1,22 @@
+import {requestJson} from "./chat";
+import {z} from "zod";
+
+const row = z.object({todo_id: z.string(), bundle_sha256: z.string().regex(/^[a-f0-9]{64}$/),
+ title: z.string(), status: z.string(), created_by: z.string().nullable(), bound_agent: z.string().nullable(),
+ created_at: z.string().nullable(), updated_at: z.string().nullable(), completed_at: z.string().nullable(),
+ superseded_by: z.string().nullable()});
+export type ReportReviewRow = z.infer;
+const page = z.object({ok: z.literal(true), available: z.boolean().default(true),
+ reason_code: z.string().optional(), items: z.array(row), total: z.number().int().nonnegative(), next_cursor: z.string().nullable()});
+const read = z.object({ok: z.literal(true), goal_id: z.string(), review: row,
+ source_session_id: z.string().regex(/^[a-f0-9]{32}$/).nullable(),
+ todo: z.record(z.string(), z.unknown()), document: z.object({title: z.string(), generated_at: z.string()}).passthrough(),
+ generation_id: z.string(), artifacts: z.array(z.object({renderer_id: z.string(), content: z.string(), content_digest: z.string()}))});
+export type ReportReviewRead = z.infer;
+export async function fetchReportReviews(goalId: string, cursor?: string) {
+ const query = new URLSearchParams({goal_id: goalId}); if (cursor) query.set("cursor", cursor);
+ return page.parse(await requestJson(`/api/chat/report-reviews?${query}`));
+}
+export async function readReportReview(goalId: string, todoId: string) {
+ return read.parse(await requestJson(`/api/chat/report-reviews/${encodeURIComponent(todoId)}?${new URLSearchParams({goal_id: goalId})}`));
+}
diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts
index 457e83f617..60422bca19 100644
--- a/apps/presentation/dashboard/src/data/status.ts
+++ b/apps/presentation/dashboard/src/data/status.ts
@@ -88,6 +88,7 @@ export const todoItemSchema = z.object({
watch_only: z.union([z.boolean(), z.string()]).optional().nullable(),
task_domain: z.string().optional().nullable(),
action_kind: z.string().optional().nullable(),
+ capability_binding_ref: z.string().optional().nullable(),
// Canonical author provenance, distinct from the Agent authorized to reply.
created_by: z.string().optional().nullable(),
claimed_by: z.string().optional().nullable(),
@@ -114,6 +115,17 @@ export const todoItemSchema = z.object({
{ path: ["todo_id"], message: "Todo without a source index requires todo_id" },
);
+export const todoCapabilityActionsSchema = z.object({
+ schema_version: z.literal("todo_capability_actions_v0"),
+ total_count: z.number().int().nonnegative(),
+ groups: z.array(z.object({
+ action_kind: z.string().min(1),
+ count: z.number().int().nonnegative(),
+ latest_at: z.string().optional().nullable(),
+ items: z.array(todoItemSchema).max(3),
+ })).default([]),
+});
+
export const todoGroupSchema = z.object({
source_section: z.string().optional().nullable(),
total_count: z.number().optional().default(0),
@@ -123,6 +135,7 @@ export const todoGroupSchema = z.object({
items: z.array(todoItemSchema).optional().default([]),
deferred_items: z.array(todoItemSchema).optional(),
recent_completed_advancement_items: z.array(todoItemSchema).optional(),
+ capability_actions: todoCapabilityActionsSchema.optional(),
});
export const todoIndexItemSchema = todoItemSchema.safeExtend({
@@ -267,6 +280,7 @@ export const projectAssetTodoSummarySchema = z.object({
next_index: z.number().optional().nullable(),
items: z.array(todoItemSchema).optional().default([]),
recent_completed_advancement_items: z.array(todoItemSchema).optional().default([]),
+ capability_actions: todoCapabilityActionsSchema.optional(),
});
export const dependencyBlockerSchema = z.object({
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx
index 6b2dd06766..599817ceaa 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx
@@ -15,7 +15,8 @@ export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }:
callbacks: PersonalWorkspaceCallbacks;
fallbackAgentId: string;
}) {
- const { t } = useWorkspaceI18n();
+ const { t, locale } = useWorkspaceI18n();
+ const reportReview = item.details?.reportReview === true;
const [preview, setPreview] = useState({ status: "idle" });
const scope = JSON.stringify([item.sourceId ?? "", item.goalId, item.todoId]);
const pendingRequest = useRef<{scope: string; token: object | null}>({scope, token: null});
@@ -83,7 +84,7 @@ export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }:
? {t("drawer.attentionPreviewFailed", { error: preview.message })}
: pending ? {t("drawer.attentionPreparing")}
: null;
const reply = callbacks.onReplyToAttention
- ? callbacks.onReplyToAttention?.(item, "reply")} type="button"> {t("drawer.attentionReply")}
+ ? callbacks.onReplyToAttention?.(item, "reply")} type="button"> {reportReview ? (locale === "zh-CN" ? "提出反馈" : "Give feedback") : t("drawer.attentionReply")}
: null;
const explain = (label: string) => callbacks.onExplainDecision
? void callbacks.onExplainDecision?.(item)} type="button"> {label}
@@ -110,9 +111,9 @@ export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }:
}
if (canHandleUserAction(item)) {
return <>
- {t(item.details?.unblocksTodoId ? "drawer.userActionUnblocks" : "drawer.userActionReminder")}
+ {!reportReview ? {t(item.details?.unblocksTodoId ? "drawer.userActionUnblocks" : "drawer.userActionReminder")}
: null}
- previewUserAction(item, "complete")} type="button"> {t("drawer.userActionComplete")}
+ previewUserAction(item, "complete")} type="button"> {reportReview ? (locale === "zh-CN" ? "验收这份材料" : "Accept this material") : t("drawer.userActionComplete")}
{reply}
{feedback}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts
index a71ea84da3..146ba5e09f 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts
@@ -1,5 +1,6 @@
import type { WorkspaceAttention } from "./personal-workspace-model";
import { todoRequestContent } from "../../../../../../loopx/control_plane/presentation/todo_request_content.js";
+import {reportReviewDigest} from "../../../../../../loopx/control_plane/presentation/report_review_binding.js";
/** A display of existing Todo facts, never a gate or dependency evaluator. */
export type AttentionDetails = {
@@ -18,6 +19,8 @@ export type AttentionDetails = {
unblocksTodoId: string | null;
decisionScope: { kind: string; granularity: string; scopeKey: string } | null;
supersededBy: string | null;
+ reportReview?: boolean;
+ reportSessionId?: string;
};
const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value.trim() : null;
@@ -47,6 +50,7 @@ export function attentionDetails(todo: Record): AttentionDetail
unblocksTodoId: text(todo.unblocks_todo_id),
decisionScope: kind && granularity && scopeKey ? { kind, granularity, scopeKey } : null,
supersededBy,
+ ...(reportReviewDigest(todo) ? {reportReview: true} : {}),
};
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts
index 3ceeed9f20..c344ded2c2 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts
@@ -178,6 +178,7 @@ const fieldCopy: Record = {
composition_mode: { label: "Composition policy", description: "Replan requires an exact experiment successor or typed result; it grants no execution authority.", options: {disabled: "Off", explicit_only: "Explicit candidates"} },
composition_scope_id: { label: "Research coverage scope", description: "An opaque scope id required by the explicit-only composition policy." },
profile_preset: { label: "Report profile", description: "Capability-owned report profile, such as weekly-progress." },
+ review_required: { label: "Request review when ready", description: "For reports returned to this Goal's original conversation. Each edition stays on Home until you handle its review." },
wait_for_ci: { label: "Wait for CI", description: "Disable to use local validation without querying or waiting for CI. Merge authority is unchanged." },
review_order: { label: "Review direction", description: "Forward visits other authors first and oldest first. Reverse inverts the whole actionable queue." },
agent_orders: { label: "Agent review directions", description: "Use the Goal default or choose a direction for each registered Agent." },
@@ -214,6 +215,7 @@ const fieldCopy: Record = {
composition_mode: { label: "组合策略", description: "重新规划需要精确的实验后继或类型化结果;它不授予执行权限。", options: {disabled: "关闭", explicit_only: "仅显式候选"} },
composition_scope_id: { label: "研究覆盖范围", description: "显式组合策略要求填写不含私有内容的范围标识。" },
profile_preset: { label: "报告 Profile", description: "由该能力管理的报告 profile,例如 weekly-progress。" },
+ review_required: { label: "材料就绪后请求验收", description: "适用于回到本 Goal 原工作对话的报告。每个版本独立提醒,处理审阅后从首页移除。" },
wait_for_ci: { label: "等待 CI", description: "关闭后使用本地验证,不查询或等待 CI;不改变合并权限。" },
review_order: { label: "审阅方向", description: "正向先审其他作者,同层先审较早就绪的 PR;反向将整个可执行队列倒序。" },
agent_orders: { label: "Agent 审阅方向", description: "每个注册 Agent 可继承 Goal,或单独选择正向、反向。" },
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-report-reviews.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-report-reviews.tsx
new file mode 100644
index 0000000000..d98d0b0fad
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-report-reviews.tsx
@@ -0,0 +1,176 @@
+import {useEffect, useMemo, useRef, useState} from "react";
+import {RefreshCw} from "lucide-react";
+import {fetchReportReviews, readReportReview, type ReportReviewRead, type ReportReviewRow} from "../../data/report-reviews";
+import type {PersonalWorkspaceCallbacks, WorkspaceAttention} from "./personal-workspace-model";
+import {attentionDetails} from "./attention-details";
+import {AttentionActions} from "./attention-actions";
+import {ContextHelp} from "./context-help";
+import {MarkdownText} from "./markdown";
+import {ResultCatalogReturn, ResultCatalogRow, useResultCatalogFocus} from "./result-catalog";
+import "./report-reviews.css";
+
+/** Generated HTML gets no scripts, forms, same-origin access or host functions.
+ * Review controls stay in the trusted host; the exact Markdown remains readable. */
+function inertReportHtml(content: string, zh: boolean): string {
+ const document = new DOMParser().parseFromString(content, "text/html");
+ // The built-in standalone renderer has script-driven search/copy/print.
+ // Its static embedded reading view keeps the content and navigation, without
+ // presenting controls that cannot run in the scriptless frame.
+ document.querySelectorAll("script, .tools, [data-report-search]").forEach(node => node.remove());
+ for (const anchor of document.querySelectorAll("a[href]")) {
+ const href = anchor.getAttribute("href")!;
+ if (href.startsWith("#")) anchor.setAttribute("href", `about:srcdoc${href}`);
+ else {
+ anchor.removeAttribute("href");
+ anchor.setAttribute("aria-disabled", "true");
+ anchor.setAttribute("title", document.documentElement.lang.startsWith("zh") ? "请在文字版打开来源链接" : "Open source links in text view");
+ }
+ }
+ // The host already supplies the exact title and edition. Keep every summary,
+ // period, highlight and source inside the reading view without a second hero.
+ document.querySelector(".masthead h1")?.remove();
+ const metadata = document.querySelector(".supporting-body");
+ // Counts and the reporting window remain available with the other report
+ // facts. The reading viewport starts with editorial summaries and content.
+ for (const node of document.querySelectorAll(".masthead .eyebrow, .masthead .period, .report-facts.fallback")) {
+ if (metadata) metadata.append(node);
+ }
+ const mast = document.querySelector(".masthead");
+ if (mast && !mast.textContent?.trim()) mast.remove();
+ const rail = document.querySelector(".rail");
+ if (rail) {
+ const index = document.createElement("details");
+ index.className = "embedded-index";
+ const summary = document.createElement("summary");
+ summary.textContent = zh ? "目录" : "Contents";
+ index.append(summary);
+ const nav = rail.querySelector("nav");
+ if (nav) index.append(nav);
+ rail.replaceWith(index);
+ }
+ const embedded = document.createElement("style");
+ embedded.textContent = ".shell{width:100%;max-width:none;margin:0;padding:24px;box-sizing:border-box}.masthead{padding:0 0 16px;border-top:0}.mast-grid{gap:16px}.layout{grid-template-columns:minmax(0,1fr);margin-top:0;gap:16px}.masthead+.layout{margin-top:16px}.embedded-index summary{cursor:pointer;min-height:32px}.section-heading h2{font-size:24px}.report-row{padding:16px 0}.report-facts.fallback{grid-template-columns:repeat(3,minmax(0,1fr))}.report-facts.fallback .fact{min-width:0}a[aria-disabled=true]{color:inherit;text-decoration:none;cursor:help}@media(max-width:640px){.shell{padding:16px}}";
+ document.head.append(embedded);
+ const policy = document.createElement("meta");
+ policy.httpEquiv = "Content-Security-Policy";
+ policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'";
+ document.head.prepend(policy);
+ return "" + document.documentElement.outerHTML;
+}
+
+export function GoalReportReviews({goalId, sourceId, zh, focusTodoId, refreshKey, callbacks, readOnly = false, onSelectionChange}: {
+ goalId: string; sourceId?: string; zh: boolean; focusTodoId?: string; refreshKey: string;
+ callbacks: PersonalWorkspaceCallbacks; readOnly?: boolean;
+ onSelectionChange?: (todoId?: string) => void;
+}) {
+ const [rows, setRows] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [reading, setReading] = useState(focusTodoId ?? null);
+ const [cursor, setCursor] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [error, setError] = useState("");
+ const [textOnly, setTextOnly] = useState(false);
+ const generation = useRef(0);
+ const navigation = useRef("");
+ const selectedId = useRef(focusTodoId ?? null);
+ const focus = useResultCatalogFocus();
+ const storageKey = JSON.stringify(["loopx-report-reading", sourceId ?? location.origin, goalId]);
+ function rememberSelection(todoId?: string) {
+ selectedId.current = todoId ?? null;
+ try {
+ if (todoId) sessionStorage.setItem(storageKey, todoId);
+ else sessionStorage.removeItem(storageKey);
+ } catch { /* Reading works without storage. */ }
+ onSelectionChange?.(todoId);
+ }
+ useEffect(() => {
+ const destination = JSON.stringify([storageKey, focusTodoId]);
+ if (navigation.current !== destination) {
+ navigation.current = destination;
+ let saved: string | null = null;
+ try {saved = sessionStorage.getItem(storageKey);} catch { /* Reading works without storage. */ }
+ selectedId.current = focusTodoId ?? saved;
+ setReading(selectedId.current); setRows([]); setLoaded(false); setTextOnly(false);
+ }
+ void load();
+ return () => {generation.current++; focus.cancel();};
+ }, [goalId, sourceId, focusTodoId, refreshKey]);
+
+ async function read(todoId: string, current: number) {
+ const value = await readReportReview(goalId, todoId);
+ if (current !== generation.current) return;
+ if (value.goal_id !== goalId || value.review.todo_id !== todoId || value.todo.todo_id !== todoId)
+ throw Error(zh ? "材料来源已变化" : "Material source changed");
+ setSelected(value);
+ rememberSelection(todoId);
+ }
+ async function load(next?: string) {
+ const current = ++generation.current;
+ setBusy(true); setError(""); setSelected(null);
+ try {
+ const page = await fetchReportReviews(goalId, next);
+ if (current !== generation.current) return;
+ if (!page.available) {
+ setRows([]); setCursor(null); setLoaded(true);
+ if (selectedId.current) setError(zh ? "请先按 Goal 的提示完成权威存储迁移,再刷新原材料。" : "Complete the Goal's authority migration, then refresh the original material.");
+ return;
+ }
+ setRows(previous => next ? [...previous, ...page.items] : page.items);
+ setCursor(page.next_cursor); setLoaded(true);
+ if (selectedId.current) await read(selectedId.current, current);
+ } catch {
+ if (current === generation.current) setError(zh ? "材料或审阅状态暂不可读,请重试。" : "Material or review state is unavailable. Retry the read.");
+ } finally {if (current === generation.current) setBusy(false);}
+ }
+ async function select(row: ReportReviewRow) {
+ const current = ++generation.current;
+ setReading(row.todo_id); setSelected(null); setTextOnly(false);
+ rememberSelection(row.todo_id);
+ setBusy(true); setError(""); focus.reader();
+ try {await read(row.todo_id, current);}
+ catch {if (current === generation.current) setError(zh ? "原版本已不可读取,请重试。" : "The original edition cannot be read. Retry.");}
+ finally {if (current === generation.current) setBusy(false);}
+ }
+ const label = (row: ReportReviewRow) => row.superseded_by ? (zh ? "已有修订" : "Superseded")
+ : row.status === "done" ? (zh ? "审阅已处理" : "Review handled")
+ : row.status === "deferred" ? (zh ? "稍后审阅" : "Review deferred")
+ : ["open", "blocked"].includes(row.status) ? (zh ? "待验收与反馈" : "Awaiting review") : row.status;
+ const markdown = selected?.artifacts.find(item => item.renderer_id === "markdown_v0");
+ const html = selected?.artifacts.find(item => item.renderer_id === "html_artifact_v0");
+ const htmlView = useMemo(() => html ? inertReportHtml(html.content, zh) : "", [html?.content, zh]);
+ function returnToCatalog() {
+ generation.current++; setReading(null); setSelected(null); setBusy(false); setError("");
+ rememberSelection();
+ if (reading) focus.row(reading);
+ }
+ const attention: WorkspaceAttention | null = selected ? {goalId, sourceId, todoId: selected.review.todo_id,
+ text: selected.review.title, blocking: false, decisionSource: "todo", details: {...attentionDetails(selected.todo),
+ ...(selected.source_session_id ? {reportSessionId: selected.source_session_id} : {})}} : null;
+ if (loaded && !rows.length && !reading && !error) return null;
+ return
+ {reading ? : {zh ? "审阅材料" : "Review materials"} }
+
+ {zh ? "阅读不会完成验收。每份材料的审阅分别记录;处理材料不等于整个 Goal 验收通过。反馈回到负责 Agent 的原工作对话。页面版保留静态目录;外部来源可从文字版打开。" : "Reading does not accept a result. Each edition has its own review; handling it does not accept the entire Goal. Feedback returns to its responsible Agent's work conversation. Page view keeps the static index; open external sources from text view."}
+ void load()}>{zh ? "刷新" : "Refresh"}
+ {busy ? {zh ? "正在读取材料…" : "Reading material…"}
: null}
+ {error ? {error}
: null}
+
+ {rows.map(row => void select(row)}/>)}
+ {cursor ? void load(cursor)}>{zh ? "更多材料" : "More materials"} : null}
+
+ {reading ?
+ {selected ? <>{selected.document.title}
+
{label(selected.review)} {new Date(selected.document.generated_at).toLocaleString(zh ? "zh-CN" : "en")}
+ {html && markdown ? setTextOnly(value => !value)}>{textOnly ? (zh ? "页面版" : "Page view") : (zh ? "文字版" : "Text view")} : null}
+ {attention ? : null}
+ {html && !textOnly ? : null}
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx
index 2390d4a316..966f704d34 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx
@@ -1,5 +1,5 @@
import {ContextHelp} from "./context-help";
-import {ArrowRight, Check, CircleHelp, Hand} from "lucide-react";
+import {ArrowRight, Check, CircleHelp, FileText, Hand} from "lucide-react";
import {useState, type ReactNode} from "react";
import { useWorkspaceI18n } from "./i18n";
import type { WorkspaceActionPreview, WorkspaceAttention, WorkspaceGoal } from "./personal-workspace-model";
@@ -12,7 +12,15 @@ import { recentCompletions } from "./recent-completions";
const briefRowLimit = 3;
-type BriefRow = { goal: WorkspaceGoal; key: string; meta?: ReactNode; text: string; proposal?: WorkspaceActionPreview; attention?: WorkspaceAttention };
+type BriefRow = {
+ goal: WorkspaceGoal;
+ key: string;
+ meta?: ReactNode;
+ text: string;
+ proposal?: WorkspaceActionPreview;
+ attention?: WorkspaceAttention;
+ onSelect?: () => void;
+};
function RunningMeta({ goal }: { goal: WorkspaceGoal }) {
return <>{useExecutionDetail(goal.execution).join(" · ") || goal.title}>;
@@ -21,7 +29,7 @@ function RunningMeta({ goal }: { goal: WorkspaceGoal }) {
function BriefTile({ count, empty, kind, live = false, onSelectGoal, onSelectOperation, onSelectAttention, rows, title, total }: {
count: number;
empty: string;
- kind: "needs" | "needs-goals" | "confirmations" | "operations" | "running" | "claimed" | "completed";
+ kind: "needs" | "needs-goals" | "reviews" | "confirmations" | "operations" | "running" | "claimed" | "completed";
live?: boolean;
onSelectGoal: (goalId: string) => void;
onSelectOperation?: (proposal: WorkspaceActionPreview) => void;
@@ -39,9 +47,9 @@ function BriefTile({ count, empty, kind, live = false, onSelectGoal, onSelectOpe
{(expanded ? rows : rows.slice(0, briefRowLimit)).map((row) => (
row.proposal ? onSelectOperation?.(row.proposal)
+ onClick={() => row.onSelect ? row.onSelect() : row.proposal ? onSelectOperation?.(row.proposal)
: row.attention && onSelectAttention ? onSelectAttention(row.attention) : onSelectGoal(row.goal.goalId)} type="button">
- {kind === "completed" ?
+ {kind === "reviews" ? : kind === "completed" ?
: row.attention ? : }
{row.text} {row.meta ?? row.goal.title}
@@ -61,7 +69,7 @@ export function ManagerBrief({ goals, onSelectGoal, attention = [], onSelectAtte
operations?: WorkspaceActionPreview[]; onSelectOperation?: (proposal: WorkspaceActionPreview) => void;
onViewAllOperations?: () => void;
}) {
- const { t } = useWorkspaceI18n();
+ const { t, locale } = useWorkspaceI18n();
const active = goals.filter((goal) => goal.activationState === "active" && !goal.loadState);
const needs = active.filter((goal) => workspaceHomeLaneForGoal(goal) === "needs_you");
// Count only observed native items. A Goal digest is a different unit, and
@@ -80,6 +88,36 @@ export function ManagerBrief({ goals, onSelectGoal, attention = [], onSelectAtte
].join(" · ")}];
});
const summarizedGoals = needs.filter(goal => !attentionRows.some(row => row.goal === goal));
+ const observedReviews = attentionRows.filter(row => row.attention?.details?.reportReview === true);
+ const reviews: BriefRow[] = [];
+ let reviewCount = 0;
+ for (const goal of active) {
+ const candidates = new Map();
+ for (const attention of goal.reportReviewAttention ?? []) {
+ const key = JSON.stringify([attention.sourceId ?? "", attention.goalId, attention.decisionSource ?? "todo", attention.todoId]);
+ candidates.set(key, {goal, key, attention, text: attention.text});
+ }
+ for (const row of observedReviews.filter(candidate => candidate.goal === goal)) {
+ candidates.set(row.key, row);
+ }
+ const bounded = [...candidates.values()].map(row => ({
+ ...row,
+ meta: [goal.title, row.attention?.details?.createdBy ?? row.attention?.details?.boundAgent].filter(Boolean).join(" · "),
+ }));
+ const count = Math.max(goal.reportReviewCount ?? 0, bounded.length);
+ reviewCount += count;
+ if (count > bounded.length && bounded.length) {
+ reviews.push({
+ goal,
+ key: `report-directory:${goal.goalId}`,
+ text: locale === "zh-CN" ? `查看全部 ${count} 份待验收成果` : `View all ${count} results awaiting review`,
+ meta: goal.title,
+ onSelect: () => bounded[0]?.attention && onSelectAttention?.(bounded[0].attention),
+ });
+ }
+ reviews.push(...bounded);
+ }
+ const otherAttention = attentionRows.filter(row => row.attention?.details?.reportReview !== true);
const incomplete = goals.some(goal => goal.loadState);
const operationRows: BriefRow[] = operations.flatMap(proposal => {
const goal = goals.find(goal => goal.goalId === proposal.goalId && !goal.loadState);
@@ -103,9 +141,11 @@ export function ManagerBrief({ goals, onSelectGoal, attention = [], onSelectAtte
: executionPending ? t("brief.runningReading") : t("activity.executionUnknown");
return (
- row.goal.goalId)).size}) : null} />
+ {reviewCount ? : null}
+ row.goal.goalId)).size}) : null} />
{summarizedGoals.length ? ({goal, key: goal.goalId, meta: goal.title, text: goal.needsYou ?? goal.nextSentence}))}
title={t("brief.needsGoals")} /> : null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
index 15af4c9f5c..7aaf8187eb 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts
@@ -211,6 +211,9 @@ export type WorkspaceGoal = {
latestActivity?: string;
needsYou?: string | null;
needsYouBlocking?: boolean;
+ /** Exact full-source capability-action count and bounded display rows. */
+ reportReviewAttention?: WorkspaceAttention[];
+ reportReviewCount?: number;
attentionSummary?: DigestAttentionSummary;
nextSentence: string;
repository?: WorkspaceRepositoryContext;
@@ -636,7 +639,7 @@ export type PersonalWorkspaceCallbacks = {
onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void;
onSelectTodo?: (todoId: string | null) => void;
onSelectView?: (view: WorkspaceGoalTab) => void;
- onSelectWorkAgent?: (agentId: string | null) => void;
+ onSelectWorkAgent?: (agentId: string | null, sessionId?: string) => void;
onSelectWorkSession?: (sessionId: string, replace?: boolean) => void;
onOpenNotificationSettings?: (goalId?: string) => void;
onFetchNotificationTargets?: () => Promise>;
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
index a76e7e66e2..a396966098 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
@@ -94,6 +94,7 @@ import { ManagerBrief } from "./manager-brief";
// Loaded on demand: settings and their editors are a separate chunk that
// arrives when the owner first opens Settings, not on every workspace visit.
const ContentProductionResults = lazy(() => import("./content-production-results").then(module => ({default: module.ContentProductionResults})));
+const GoalReportReviews = lazy(() => import("./goal-report-reviews").then(module => ({default: module.GoalReportReviews})));
const WorkspaceSettingsPage = lazy(() => import("./workspace-settings-page").then((module) => ({ default: module.WorkspaceSettingsPage })));
import { UsageStatisticsNotice } from "./usage-statistics-notice";
@@ -325,6 +326,7 @@ function GoalOutputsView({
localResults,
researchApi,
production,
+ reviewMaterials,
}: {
active: boolean;
items: Array>;
@@ -335,6 +337,7 @@ function GoalOutputsView({
localResults: boolean;
researchApi?: GoalResearchApi;
production?: ReactNode;
+ reviewMaterials?: ReactNode;
}) {
const { locale, t } = useWorkspaceI18n();
const [teamSnapshot, setTeamSnapshot] = useState(null);
@@ -360,6 +363,7 @@ function GoalOutputsView({
&& teamSnapshot?.settings.agent_id && teamSnapshot?.settings.execution_config);
return (
<>
+ {reviewMaterials}
{items.length > 0 || reportState?.loading || reportState?.error || !production ?
{t("files.title")} {!teamConfigured && !production ? {items.length} : null}
{reportState?.loading ? (
@@ -978,6 +982,7 @@ export function PersonalWorkspacePage({
setLocalView(view);
callbacks.onSelectView?.(view);
}
+ const [selectedReportReview, setSelectedReportReview] = useState<{goalId: string; todoId: string} | null>(null);
const [actionDraft, setActionDraft] = useState(null);
const [loopxMode, setLoopxMode] = useState(null);
const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue");
@@ -1097,22 +1102,38 @@ export function PersonalWorkspacePage({
composerRef.current?.focus();
}
// A draft for another Goal waits until that Goal's composer is the visible one.
- const [composerPrefill, setComposerPrefill] = useState<{ goalId: string; text: string } | null>(null);
+ const [composerPrefill, setComposerPrefill] = useState<{ goalId: string; text: string; workAgentId?: string; workSessionId?: string; statusUrl?: string } | null>(null);
useEffect(() => {
if (!composerPrefill || composerPrefill.goalId !== selectedGoalId) return;
+ if (composerPrefill.statusUrl && composerPrefill.statusUrl !== (statusSourceControl?.activeSource.statusUrl ?? "/status.json")) {
+ setComposerPrefill(null); return;
+ }
+ if (composerPrefill.workAgentId && (selectedWorkAgentId !== composerPrefill.workAgentId || !selectedWorkSessionId)) return;
+ if (composerPrefill.workSessionId && (selectedWorkSessionId !== composerPrefill.workSessionId
+ || !workspaceConversations?.managed || `managed:${workspaceConversations.managed.target.sessionId}` !== composerPrefill.workSessionId)) return;
setComposerPrefill(null);
suggestReply(composerPrefill.text);
- }, [composerPrefill, selectedGoalId]);
+ }, [composerPrefill, selectedGoalId, selectedWorkAgentId, selectedWorkSessionId, conversationKey, statusSourceControl?.activeSource.statusUrl]);
function draftAttentionMessage(attention: WorkspaceAttention, intent: "reply" | "explain") {
// A display summary is not a usable reply context: it may cut through a URL.
const task = attention.details?.requestText ?? attention.text;
const key = intent === "reply" ? "drawer.attentionReplyPrefill"
: attention.details?.interaction === "decision" ? "drawer.decisionExplainPrefill" : "drawer.attentionExplainPrefill";
+ const reportAgent = attention.details?.reportReview ? attention.details.createdBy ?? attention.details.boundAgent : null;
if (attention.goalId !== selectedGoalId) selectGoal(attention.goalId, "chat");
- openGoalConversation();
+ if (reportAgent && callbacks.onSelectWorkAgent) {
+ setLocalView("chat"); setActiveSessionRun(null);
+ callbacks.onSelectWorkAgent(reportAgent,
+ attention.details?.reportSessionId ? `managed:${attention.details.reportSessionId}` : undefined);
+ } else openGoalConversation();
setSelection(null);
const reference = attention.details?.todoIdentified ? `\n\nTodo: ${attention.todoId}` : "";
- setComposerPrefill({ goalId: attention.goalId, text: t(key, { task }) + reference });
+ setComposerPrefill({ goalId: attention.goalId, text: t(key, { task }) + reference,
+ ...(reportAgent && callbacks.onSelectWorkAgent ? {
+ workAgentId: reportAgent,
+ workSessionId: attention.details?.reportSessionId ? `managed:${attention.details.reportSessionId}` : undefined,
+ statusUrl: statusSourceControl?.activeSource.statusUrl ?? "/status.json",
+ } : {}) });
}
// The steward prompt set is owned by the client model; the quick-prompt row
// reuses it so one affordance answers "what now / what blocks / what is proven".
@@ -2308,6 +2329,13 @@ export function PersonalWorkspacePage({
callbacks.onSelectGoal?.(goalId, view);
}
+ function openAttention(item: WorkspaceAttention) {
+ if (item.details?.reportReview) {
+ setSelectedReportReview({goalId: item.goalId, todoId: item.todoId});
+ selectGoal(item.goalId, "files");
+ } else setSelection({kind: "attention", item});
+ }
+
function selectAgent(agentId: string) {
setLocalAgentId(agentId);
callbacks.onSelectAgent?.(agentId);
@@ -2359,6 +2387,7 @@ export function PersonalWorkspacePage({
}, [pendingNotificationItem, selectedGoalId]);
function openAttentionFromNotification(item: WorkspaceAttention) {
window.focus();
+ if (item.details?.reportReview) {openAttention(item); return;}
if (item.goalId === selectedGoalId) { setSelection({ kind: "attention", item }); return; }
setPendingNotificationItem(item);
selectGoal(item.goalId, "overview");
@@ -2744,6 +2773,13 @@ export function PersonalWorkspacePage({
teamSessionId={!readOnly && selectedAgentId === "codex" ? conversationSessionId : undefined}
goalId={selectedGoal.goalId}
localResults={!readOnly && active}
+ reviewMaterials={!readOnly && active ? {locale === "zh-CN" ? "正在读取审阅材料…" : "Loading review materials…"}
}>
+ setSelectedReportReview(todoId ? {goalId: selectedGoal.goalId, todoId} : null)}
+ refreshKey={JSON.stringify(model.userTodos.filter(item => item.goalId === selectedGoal.goalId))}/>
+ : undefined}
researchApi={researchApi}
production={active && !readOnly && selectedGoalTab === "files" ? {locale === "zh-CN" ? "正在读取作品…" : "Loading artifacts…"}}>
) : !managerChatOpen ? (
- setSelection({kind: "attention", item})}
+ void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth}
onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply}
runtimeReadiness={readOnly ? undefined : runtimeReadiness} recheckingRuntimes={recheckingRuntimes} onRecheckRuntimes={callbacks.onRecheckRuntimes ? recheckRuntimes : undefined}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css b/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css
new file mode 100644
index 0000000000..134dc56504
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css
@@ -0,0 +1,22 @@
+.goal-report-reviews { min-width: 0; margin-bottom: 24px; }
+.goal-report-reviews > header, .report-review-masthead > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.goal-report-reviews h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--text-heading); line-height: var(--leading-heading); }
+.goal-report-reviews button { min-height: 44px; font: inherit; }
+.goal-report-reviews [data-result-catalog] { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
+.goal-report-reviews [data-result-catalog][hidden] { display: none; }
+.goal-report-reviews .result-catalog-row { display: flex; align-items: start; gap: 16px; width: 100%; padding: 16px; text-align: start; }
+.goal-report-reviews .result-catalog-row > span { flex: 1; min-width: 0; }
+.goal-report-reviews .result-catalog-row strong { display: block; overflow-wrap: anywhere; }
+.goal-report-reviews :is([data-result-catalog], [data-result-reader]):focus { outline: 2px solid var(--pw-blue-ink); outline-offset: 4px; }
+.report-review-tools, .report-review-tools > button, .goal-report-reviews .result-catalog-return { display: inline-flex; align-items: center; gap: 8px; }
+.report-review-reader { min-width: 0; margin-top: 8px; }
+.report-review-masthead { padding: 8px 0; border-bottom: 1px solid var(--pw-border); }
+.report-review-masthead h2 { margin: 8px 0; font-size: var(--text-title); line-height: var(--leading-title); overflow-wrap: anywhere; }
+.report-review-masthead time, .report-review-masthead span { font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); }
+.report-review-actions { display: flex; flex-wrap: wrap; align-items: start; gap: 8px; padding: 8px 0 16px; }
+.report-review-actions .personal-decision-bar { flex: 1; }
+.report-review-actions .personal-compact-menu { flex: 0 0 auto; }
+.report-review-reader iframe { display: block; width: 100%; height: min(1000px, 80vh); min-height: 560px; border: 1px solid var(--pw-border); border-radius: 6px; background: var(--pw-surface); }
+.report-review-prose { font-size: var(--text-lead); line-height: var(--leading-prose); }
+.report-review-reader > details { padding: 16px 0; overflow-wrap: anywhere; }
+@media (max-width: 640px) { .report-review-masthead > div { flex-wrap: wrap; } .report-review-reader iframe { min-height: 640px; } .report-review-actions .personal-decision-bar { flex-basis: 100%; } }
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 7409796e97..35dfe74069 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -127,6 +127,7 @@ import {
type WorkspaceGoalArchiveLoadState,
type WorkspaceActionPreviewRequest,
type WorkspaceAgentTodo,
+ type WorkspaceAttention,
} from "../features/personal-workspace/personal-workspace-model";
import { monitorTodoReadback } from "../features/personal-workspace/monitor-readback";
import { goalExecution, taskSessionStatus } from "../features/personal-workspace/goal-activity";
@@ -476,6 +477,8 @@ type PersonalGoalItem = {
needsYou?: string | null;
needsYouActionKind?: string | null;
needsYouBlocking?: boolean;
+ reportReviewAttention?: WorkspaceAttention[];
+ reportReviewCount?: number;
attentionSummary?: DigestAttentionSummary;
needsYouTaskClass?: string | null;
needsYouTodoId?: string | null;
@@ -688,6 +691,13 @@ function personalAgentCapability(agentId: string, adapterKind?: string | null) {
function personalTodosForQueueItem(item: QueueItem, role: "user" | "agent") {
const projectAsset = item.project_asset;
+ const queueGroup = role === "user" ? item.user_todos : item.agent_todos;
+ if (role === "user" && queueGroup?.items?.length) {
+ // The owner view needs the canonical bounded queue, not the 3-row project
+ // asset teaser. Both remain display projections; the full-source
+ // capability_actions summary carries counts beyond this list.
+ return queueGroup;
+ }
return role === "user"
? todosFromProjectAssetSummary(projectAsset?.user_todos, item.user_todos, role)
: todosFromProjectAssetSummary(projectAsset?.agent_todos, item.agent_todos, role);
@@ -1070,6 +1080,21 @@ function buildPersonalHomeModel(
];
const agentRow = sortedGoalAgentRows[0];
const goalAgentTodos = mergePersonalAgentTodos(personalAgentTodos(row), sortedGoalAgentRows, row);
+ const capabilityActions = row.queueItem?.user_todos?.capability_actions
+ ?? row.queueItem?.project_asset?.user_todos?.capability_actions;
+ const reportReviewGroup = capabilityActions?.groups.find(group => group.action_kind === "review_periodic_report");
+ const reportReviewAttention: WorkspaceAttention[] = (reportReviewGroup?.items ?? []).flatMap(todo => {
+ const todoId = todo.todo_id?.trim();
+ if (!todoId) return [];
+ return [{
+ blocking: false,
+ goalId: goal.id,
+ text: todo.text,
+ todoId,
+ updatedAt: todo.updated_at ?? null,
+ details: attentionDetails(todo),
+ }];
+ });
const nextSentence = [
agentTodoFacts.nextTodoText,
row.queueItem?.recommended_action,
@@ -1118,6 +1143,8 @@ function buildPersonalHomeModel(
needsYou,
needsYouActionKind: needsYouTodo?.actionKind ?? null,
needsYouBlocking: needsYouTodo?.blocking ?? false,
+ reportReviewAttention,
+ reportReviewCount: reportReviewGroup?.count ?? reportReviewAttention.length,
attentionSummary: goal.activation_state === "stopped"
? { blocking: 0, optional: 0, coverage: "complete" } satisfies DigestAttentionSummary
: attentionSummaries.get(goal.id),
@@ -1283,7 +1310,7 @@ function PersonalGoalHome({
workBrowseSearch?: Record;
onWorkBrowseChange?: (patch: Partial, source: string, replace?: boolean) => void;
onSelectWorkSession: (sessionId: string, replace?: boolean) => void;
- onSelectWorkAgent: (agentId: string | null) => void;
+ onSelectWorkAgent: (agentId: string | null, sessionId?: string) => void;
onSelectTodo: (todoId: string | null) => void;
onSelectView: (view: WorkspaceGoalTab) => void;
onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void;
@@ -3040,6 +3067,10 @@ function PersonalGoalHome({
goals: normalizedModel.goals.map((goal) => ({
...goal,
execution: goalExecution(goalSessionFacts, goal.goalId, goal.hostThreadActivity),
+ reportReviewAttention: goal.reportReviewAttention?.map(item => sourceAttention(
+ item, statusSourceControl.activeSource.statusUrl,
+ sourceIsReady && !progress?.errors[goal.goalId], goal.title,
+ )),
})),
userTodos: model.userTodos.map(attentionForWorkspace),
attentionHistory: (model.attentionHistory ?? model.userTodos).map(attentionForWorkspace),
@@ -3941,10 +3972,10 @@ export function DashboardPage() {
message: current.workSession === sessionId ? current.message : undefined} : current});
}}
selectedWorkAgentId={search.workAgent ?? null}
- onSelectWorkAgent={(agentId) => {
+ onSelectWorkAgent={(agentId, sessionId) => {
const goalId = search.goalId, statusUrl = search.statusUrl;
void navigate({search: current => "goalId" in current && current.goalId === goalId && current.statusUrl === statusUrl
- ? {...current, todo: undefined, workAgent: agentId ?? undefined, workSession: undefined, messageSession: undefined, message: undefined, view: agentId === null ? "overview" : "conversation"} : current});
+ ? {...current, todo: undefined, workAgent: agentId ?? undefined, workSession: sessionId, messageSession: undefined, message: undefined, view: agentId === null ? "overview" : "conversation"} : current});
}}
onSelectTodo={(todoId) => {
const goalId = search.goalId, statusUrl = search.statusUrl;
diff --git a/docs/guides/README.md b/docs/guides/README.md
index b8afd04f32..2ab66e5931 100644
--- a/docs/guides/README.md
+++ b/docs/guides/README.md
@@ -8,6 +8,7 @@ Guides are task-oriented paths for people starting or operating LoopX.
- [Codex App 自动续跑体验指南](codex-app-autonomous-goal-experience.md)
- [Understand, use and evaluate context delivery](context-delivery.md)
- [理解、使用和验收上下文交付](context-delivery.zh-CN.md)
+- [审阅工作成果 / Review work materials](review-materials.md)
- [Minimal custom runtime example](minimal-custom-runtime-example.md)
- [Minimal custom runtime example (中文)](minimal-custom-runtime-example.zh-CN.md)
- [Custom Agent runner integration](custom-agent-runner-integration.md)
diff --git a/docs/guides/assets/review-materials/home.png b/docs/guides/assets/review-materials/home.png
new file mode 100644
index 0000000000..f57cc5d74c
Binary files /dev/null and b/docs/guides/assets/review-materials/home.png differ
diff --git a/docs/guides/assets/review-materials/reader-narrow.png b/docs/guides/assets/review-materials/reader-narrow.png
new file mode 100644
index 0000000000..e8fab6b6e0
Binary files /dev/null and b/docs/guides/assets/review-materials/reader-narrow.png differ
diff --git a/docs/guides/assets/review-materials/reader.png b/docs/guides/assets/review-materials/reader.png
new file mode 100644
index 0000000000..3b0a2a518d
Binary files /dev/null and b/docs/guides/assets/review-materials/reader.png differ
diff --git a/docs/guides/review-materials.md b/docs/guides/review-materials.md
new file mode 100644
index 0000000000..517cceb507
--- /dev/null
+++ b/docs/guides/review-materials.md
@@ -0,0 +1,99 @@
+# 审阅工作成果 / Review work materials
+
+材料准备好后,工作台的 **待验收成果** 从完整 canonical User Todo 来源统计尚待处理的
+材料数量,并显示有界标题;这不受通用 Todo 列表的三条摘要限制。点击一项,直接进入
+该 Goal 的 **成果** 页,打开对应版本;材料多于首页标题数时,**查看全部** 进入同一 Goal
+的完整材料目录。
+
+
+
+在阅读区选择 **页面版** 或 **文字版**。**提出反馈** 会打开负责 Agent 的工作对话,
+带上原请求和 Todo 引用;撰写反馈不会自动发送。**验收这份材料** 使用现有操作预览,
+确认后处理这一个版本的 User Todo。处理材料不等于整个 Goal 验收通过。
+阅读、刷新、切换格式都不会处理验收。修订版独立提醒,旧版本的处理结果保留。
+页面版提供静态目录;打开外部来源链接请切换到文字版。
+
+
+
+窄屏保留正文与操作。报告日期、条目计数和来源回执在报告附录中展开。
+
+
+
+**来源与版本** 可展开查看生成记录和摘要。若原材料不可读,页面显示错误及刷新入口;
+原提醒继续保留,不会因为读取失败而变成已验收。
+
+## 定时准备材料
+
+在 Goal 设置中打开定期报告配置,选择 `weekly-progress`、已有的报告接收会话和日历,
+开启 **材料就绪后请求验收**,预览并保存。该开关只适用于本 Goal 原 Agent 会话的报告,
+默认关闭,不是设备级默认值。现有频道报告和已有日历不会被自动改写。
+
+例如希望上午 9 点前审阅,可以把日历触发时间设为每天 08:30,时区设为 `Asia/Shanghai`,
+给生成和核验预留时间。这是准备工作的触发时间,不是按时完成保证;原会话及现有唤醒
+仍须可用。生成、材料就绪、实际送达和用户处理分别核验。关闭开关只停止以后新材料的
+自动请求,已有待审阅版本仍可阅读和处理。
+
+Agent 也可以为已经生成的配对报告显式请求审阅:
+
+```bash
+loopx periodic-report request-review \
+ --goal-id --agent-id \
+ --bundle-json --execute
+```
+
+省略 `--execute` 仅验证请求。命令接受同一报告的内置 Markdown 和 HTML 生成包;
+它不会调用模型、启动实验或授权后续工作。相同 Goal、生产者和生成包重试不会重复提醒,
+也不会重新打开已处理版本。
+
+原会话已知时可加 `--session-id `。日历报告会自动绑定这个来源;
+未指定时,反馈先进入负责 Agent 的会话目录,选择现有会话后再填写草稿,不会自动换一个会话发送。
+
+## English
+
+**Results awaiting review** on Home counts pending materials from the full
+canonical User Todo source and shows bounded titles, independently of the
+generic three-item Todo summary. Select a title to open its exact edition in the
+Goal's **Results** tab. When more materials exist than the shown titles,
+**View all** opens that Goal's complete material directory. Page and text views
+are available. **Give feedback** prepares a draft in the responsible Agent's work
+conversation; it does not send it. **Accept this material** uses the existing
+action preview and handles only that edition's User Todo, not Goal acceptance.
+Reading and refreshing never acknowledge a result. Revised editions have
+independent review decisions. Unreadable material retains its pending request.
+
+In Goal settings, configure the existing `weekly-progress` report, original
+Agent conversation and calendar, then enable **Request review when ready**.
+The switch defaults off and is unavailable as a machine default. For a 09:00
+review, an 08:30 `Asia/Shanghai` calendar can reserve preparation time; dispatch
+is not an on-time completion guarantee. Original Sessions and existing wakes
+must remain available. Disabling future requests preserves earlier editions.
+
+The explicit CLI above queues an existing built-in report bundle without a
+model call. Retry identity includes Goal, producer and exact bundle; retries do
+not duplicate requests or reopen handled editions.
+
+Use optional `--session-id ` to retain an exact feedback
+destination. Calendar reports bind it automatically. Without it, feedback
+opens the responsible Agent's directory and waits for an explicit Session
+selection; it does not silently send into another conversation.
+
+## Rendering boundary
+
+The embedded reader displays the built-in report in a scriptless, opaque
+sandbox with a restrictive content policy. Host review controls sit outside
+that frame. The embedded layout removes the repeated title and standalone
+script-driven controls while retaining report summaries, periods and sources;
+the exact Markdown remains available. Reporting-window and fallback count metadata
+remain in the report appendix so the first viewport can show the judgment.
+Report bytes cannot execute host actions.
+In-document anchors stay in the report; external source links are available in
+text view. The anchor handling follows the
+[documented srcdoc URL behavior](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#srcdoc).
+Canonical User Todos own review state; the report file and reading preferences
+are not a second authority store.
+
+This boundary takes inspiration from
+[Porthole](https://github.com/botiverse/porthole) and its
+[threat model](https://github.com/botiverse/porthole/blob/main/docs/THREAT_MODEL.md).
+It adds no Porthole dependency, arbitrary HTML app runtime, host-function bridge
+or annotation service.
diff --git a/examples/dashboard-home-browser-smoke.mjs b/examples/dashboard-home-browser-smoke.mjs
index 5f1cc8c076..c8018b292f 100644
--- a/examples/dashboard-home-browser-smoke.mjs
+++ b/examples/dashboard-home-browser-smoke.mjs
@@ -67,12 +67,46 @@ const goalSpecs = [
status: "state_refreshed",
waiting_on: "user_or_controller",
quota: { ...quotaEligible, state: "operator_gate" },
- userTodos: { open: 1, done: 3, total: 4, next: "请确认 owner 选项 A/B 的取舍。" },
+ userTodos: { open: 4, done: 3, total: 7, next: "请确认 owner 选项 A/B 的取舍。" },
+ capabilityActions: {
+ schema_version: "todo_capability_actions_v0",
+ total_count: 4,
+ groups: [{
+ action_kind: "review_periodic_report",
+ count: 4,
+ latest_at: "2026-01-01T00:04:00Z",
+ items: [4, 3, 2].map(index => ({
+ index,
+ done: false,
+ text: `合成研究材料第 ${index} 版`,
+ todo_id: `todo_report_edition_${index}`,
+ role: "user",
+ status: "open",
+ task_class: "user_action",
+ action_kind: "review_periodic_report",
+ capability_binding_ref: `periodic-report-review:r${String(index).repeat(64)}`,
+ created_by: "researcher",
+ updated_at: `2026-01-01T00:0${index}:00Z`,
+ })),
+ }],
+ },
userTodoItems: [
{ done: false, text: "请确认 owner 选项 A/B 的取舍;未确认前不推进 gated route。" },
{ done: true, text: "已读完核心迁移文档第 8 节,并确认当前结论。" },
{ done: true, text: "已确认 item embedding 不阻塞当前 P0 i2i 路径。" },
{ done: true, text: "已确认 P0 degrade 走统一标准品策略。" },
+ ...[4, 3, 2].map(index => ({
+ done: false,
+ text: `合成研究材料第 ${index} 版`,
+ todo_id: `todo_report_edition_${index}`,
+ role: "user",
+ status: "open",
+ task_class: "user_action",
+ action_kind: "review_periodic_report",
+ capability_binding_ref: `periodic-report-review:r${String(index).repeat(64)}`,
+ created_by: "researcher",
+ updated_at: `2026-01-01T00:0${index}:00Z`,
+ })),
],
agentTodos: { open: 4, done: 0, total: 4, next: "推进与 owner 决策独立的 safe side path。" },
agentTodoItems: [
@@ -226,12 +260,14 @@ const goalSpecs = [
];
function projectAssetFor(spec) {
+ const userGroup = todoGroupFor(spec, "user");
return {
owner: spec.waiting_on,
gate: spec.userTodos.open > 0 ? "user_todo" : "none",
next_action: spec.agentTodos.next ?? "continue from current fixture state",
stop_condition: "fixture stop condition",
- user_todos: {...spec.userTodos, items: todoGroupFor(spec, "user").items},
+ user_todos: {...spec.userTodos, items: userGroup.items,
+ ...(userGroup.capability_actions ? {capability_actions: userGroup.capability_actions} : {})},
agent_todos: spec.agentTodos,
quota: spec.quota,
control_plane: spec.controlPlane,
@@ -262,9 +298,13 @@ function todoGroupFor(spec, role) {
status: item.status,
task_class: item.task_class,
action_kind: item.action_kind,
+ capability_binding_ref: item.capability_binding_ref,
claimed_by: item.claimed_by,
+ created_by: item.created_by,
review_materials: [],
+ updated_at: item.updated_at,
})),
+ ...(role === "user" && spec.capabilityActions ? {capability_actions: spec.capabilityActions} : {}),
};
}
@@ -1022,7 +1062,8 @@ async function captureHomeVisualAcceptance(page, url, label) {
if (await composer.inputValue()) {
throw new Error("Manager advice shortcut should send immediately without leaving a draft.");
}
- const projectedAnswer = page.locator(".personal-manager-conversation-tray .is-assistant").last();
+ await page.getByRole("button", { name: "对话", exact: true }).click();
+ const projectedAnswer = page.locator(".personal-channel-timeline article.is-assistant").last();
await projectedAnswer.waitFor({ state: "visible", timeout: 10_000 });
await projectedAnswer.getByText("请确认 owner 选项 A/B 的取舍。", { exact: false })
.waitFor({ state: "visible", timeout: 10_000 });
@@ -1044,7 +1085,7 @@ async function captureHomeVisualAcceptance(page, url, label) {
page.on("request", observeExecution);
await composer.fill("当前健康状态");
await sendButton.click();
- const statusOnlyAnswer = page.locator(".personal-manager-conversation-tray .is-assistant").last();
+ const statusOnlyAnswer = page.locator(".personal-channel-timeline article.is-assistant").last();
const statusOnlyAnswerText = await statusOnlyAnswer.innerText();
for (const text of ["仅查状态", "只读状态快照", "未调用 Agent"]) {
if (!statusOnlyAnswerText.includes(text)) {
@@ -1403,6 +1444,17 @@ async function main() {
if (present.length) {
throw new Error(`Dashboard home leaked raw machine/debug text: ${present.join(", ")}`);
}
+ const reviewTile = page.getByTestId("personal-brief-reviews");
+ await reviewTile.waitFor({ state: "visible", timeout: 10_000 });
+ if (!(await reviewTile.locator("header b").innerText()).includes("4")) {
+ throw new Error("Home review summary lost the full canonical count below generic Todo caps.");
+ }
+ const reviewText = await reviewTile.innerText();
+ for (const text of ["合成研究材料第 4 版", "查看全部 4 份待验收成果"]) {
+ if (!reviewText.includes(text)) {
+ throw new Error(`Home review summary missing bounded title or complete-directory entry: ${text}`);
+ }
+ }
const url = new URL(page.url());
if (url.searchParams.get("view")) {
@@ -1426,17 +1478,20 @@ async function main() {
await installChatApiFixture(recoveryPage, { activeTurn: true });
try {
await recoveryPage.goto(`${baseUrl}/?statusUrl=/${fixtureName}`, { waitUntil: "networkidle" });
- const recoveredAnswer = recoveryPage.locator(".personal-manager-conversation-tray .is-assistant").last();
- await recoveredAnswer.waitFor({ state: "visible", timeout: 10_000 });
- const recoveredText = await recoveredAnswer.innerText();
- for (const text of ["恢复中的回答。", "LoopX 管家"]) {
+ await recoveryPage.getByRole("button", { name: "对话", exact: true }).click();
+ const recoveredTimeline = recoveryPage.locator(".personal-channel-timeline");
+ await recoveredTimeline.getByText("恢复中的回答。", { exact: true })
+ .or(recoveredTimeline.getByText("正在恢复进行中的 Agent 回合", { exact: true }))
+ .first().waitFor({ state: "visible", timeout: 10_000 });
+ const recoveredText = await recoveredTimeline.innerText();
+ const recoveryLabels = recoveredText.includes("恢复中的回答。")
+ ? ["LoopX 管家", "恢复中的回答。"]
+ : ["LoopX 管家", "正在恢复进行中的 Agent 回合", "中断本轮"];
+ for (const text of recoveryLabels) {
if (!recoveredText.includes(text)) {
throw new Error(`Active Turn recovery did not render ${text}: ${recoveredText}`);
}
}
- if (recoveredText.includes("Agent 正在处理")) {
- throw new Error(`Recovered active Turn remained pending after terminal SSE: ${recoveredText}`);
- }
} finally {
await recoveryPage.close();
}
diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs
index 6aae826d31..046b031a2b 100644
--- a/examples/personal-workspace-browser/fixture.mjs
+++ b/examples/personal-workspace-browser/fixture.mjs
@@ -1012,6 +1012,10 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu
} });
return;
}
+ if (url.pathname === "/api/chat/report-reviews") {
+ await route.fulfill({json: {ok: true, available: true, items: [], total: 0, next_cursor: null}});
+ return;
+ }
if (url.pathname === "/api/chat/completed-todos") {
const total = url.searchParams.get("goal_id") === "progress-projection" ? 4087 : 0;
const offset = Number(url.searchParams.get("cursor") || 0);
diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py
index ceee068e56..c46d5c96a6 100644
--- a/loopx/capabilities/configuration_ui.py
+++ b/loopx/capabilities/configuration_ui.py
@@ -151,6 +151,8 @@ def capability_configuration_editor(
"writable_scopes": ["machine", "goal"],
"fields": [
_field("enabled", "Enabled", "boolean"),
+ _field("review_required", "Request owner review", "boolean",
+ description="For an original managed Session report, also put each edition in App results and keep a Home reminder until the owner handles its review."),
_field(
"profile_preset",
"Profile preset",
diff --git a/loopx/capabilities/native_chat/steward_managed_mode.py b/loopx/capabilities/native_chat/steward_managed_mode.py
index 5d8209cf19..a08ff07c9d 100644
--- a/loopx/capabilities/native_chat/steward_managed_mode.py
+++ b/loopx/capabilities/native_chat/steward_managed_mode.py
@@ -18,6 +18,15 @@
from ...file_lock import exclusive_file_lock
+def _registered_goal_identity(goal: dict[str, Any] | None) -> dict[str, Any] | None:
+ if goal is None:
+ return None
+ return {"goal_id": goal["id"], "goal_instance_id": goal.get("goal_instance_id"),
+ "workspace_path": str(Path(goal["repo"]).resolve()),
+ "creation_operation_id": goal.get("creation_operation_id"),
+ "registered_agents": registered_agent_ids_for_goal(goal)}
+
+
class StewardManagedMode:
def __init__(self, controller: Any, bindings: Any) -> None:
self.controller, self.bindings = controller, bindings
@@ -55,13 +64,7 @@ def _workspace(self, session: dict[str, Any]) -> Path:
return path.resolve()
def _goal(self, goal_id: str) -> dict[str, Any] | None:
- goal = load_goal_from_registry(self.controller.registry_path, goal_id)
- if goal is None:
- return None
- return {"goal_id": goal["id"], "goal_instance_id": goal.get("goal_instance_id"),
- "workspace_path": str(Path(goal["repo"]).resolve()),
- "creation_operation_id": goal.get("creation_operation_id"),
- "registered_agents": registered_agent_ids_for_goal(goal)}
+ return _registered_goal_identity(load_goal_from_registry(self.controller.registry_path, goal_id))
def prepare(self, binding_id: str, *, session_id: str | None = None,
expected_plan_id: str | None = None, execute: bool = False) -> dict[str, Any]:
@@ -182,13 +185,21 @@ class ManagedTaskValidationUnavailable(ValueError):
def managed_session_identity(controller: Any, *, goal_id: str, agent_id: str, workspace: Path,
session: dict[str, Any] | None = None) -> dict[str, Any]:
+ return registered_managed_session_identity(registry_path=controller.registry_path,
+ goal_id=goal_id, agent_id=agent_id, workspace=workspace, session=session)
+
+
+def registered_managed_session_identity(*, registry_path: Path, goal_id: str, agent_id: str,
+ session: dict[str, Any] | None = None,
+ workspace: Path | None = None) -> dict[str, Any]:
"""Observe existing registration; the typed owner admits the exact binding."""
from .conversation_bindings import ChatConversationBindings
- native_goal = load_goal_from_registry(controller.registry_path, goal_id)
+ native_goal = load_goal_from_registry(registry_path, goal_id)
+ goal = _registered_goal_identity(native_goal)
return ChatConversationBindings._core("collaboration.session.managed_identity", {
- "goal": StewardManagedMode(controller, None)._goal(goal_id),
+ "goal": goal,
"goal_active": bool(native_goal and native_goal.get("status") == "active" and not goal_is_stopped(native_goal)),
- "managed_agent_id": agent_id, "workspace_path": str(workspace.resolve()),
+ "managed_agent_id": agent_id, "workspace_path": str(workspace.resolve()) if workspace else (goal or {}).get("workspace_path"),
**({"session": session} if session is not None else {})})
diff --git a/loopx/capabilities/native_chat/steward_reports.py b/loopx/capabilities/native_chat/steward_reports.py
index 05e4c626d3..199eb8b079 100644
--- a/loopx/capabilities/native_chat/steward_reports.py
+++ b/loopx/capabilities/native_chat/steward_reports.py
@@ -556,6 +556,13 @@ def publish(self, session_id: str, window: dict[str, Any]) -> bool:
if not self.delivery_current(session_id, window):
return False
body = (folder / "report.md").read_text(encoding="utf-8")
+ config = configuration_summary(load_goal_from_registry(self.controller.registry_path, actor["goal_id"]))
+ if config and config.get("review_required") is True:
+ from ..periodic_report.review import request_report_review
+ request_report_review(registry_path=self.controller.registry_path, runtime_root=self.root,
+ goal_id=actor["goal_id"], agent_id=actor["agent_id"], session_id=session_id,
+ session_store=self.controller.store, execute=True,
+ bundle=json.loads((folder / "generation-bundle.json").read_text(encoding="utf-8")))
message_id = "managed." + turn["turn_id"] + ".completed"
messages = self.controller.store.messages(session_id)
existing = next((row for row in messages if row["message_id"] == message_id), None)
diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md
index e703980ab6..729a99980b 100644
--- a/loopx/capabilities/periodic_report/README.md
+++ b/loopx/capabilities/periodic_report/README.md
@@ -10,6 +10,12 @@ presentation, and destinations to profiles and adapters.
| Protocol | [`periodic_report_v0`](../../../docs/reference/protocols/periodic-report-v0.md) |
| Smokes | `python3 examples/periodic-report-smoke.py`, `periodic-report-profile-smoke.py`, `periodic-report-html-smoke.py`, `periodic-report-bindings-smoke.py`, and `openviking-periodic-report-extension-smoke.py` |
+Reports can explicitly request human review through `request-review`. The
+original managed Session calendar can opt in with `review_required: true`.
+Both use canonical User Todos for per-edition handling, with a Home reminder
+and a reader in App results. See [review work materials](../../../docs/guides/review-materials.md).
+Generation, reading, human handling and whole-Goal acceptance remain distinct.
+
## Generate this week's report
In an active LoopX or Codex project session, ask the agent to **generate this
diff --git a/loopx/capabilities/periodic_report/cli.py b/loopx/capabilities/periodic_report/cli.py
index ca6f59ddd3..c0615bc687 100644
--- a/loopx/capabilities/periodic_report/cli.py
+++ b/loopx/capabilities/periodic_report/cli.py
@@ -163,6 +163,14 @@ def register_periodic_report_commands(
required=True,
help="Path to periodic_report_runtime_trigger_request_v0 JSON.",
)
+ review = commands.add_parser("request-review", help="Freeze a generated report for owner review in App results and Home.")
+ add_subcommand_format(review)
+ _add_input_limit(review)
+ review.add_argument("--goal-id", required=True)
+ review.add_argument("--agent-id", required=True)
+ review.add_argument("--bundle-json", required=True)
+ review.add_argument("--session-id", help="Exact original managed report Session for feedback.")
+ review.add_argument("--execute", action="store_true")
request = commands.add_parser(
"request",
help=(
@@ -520,7 +528,14 @@ def handle_periodic_report_command(
if result is not None:
return result
try:
- if args.periodic_report_command == "archive-openviking":
+ if args.periodic_report_command == "request-review":
+ from .review import request_report_review
+ registry = load_registry(registry_path)
+ payload = request_report_review(registry_path=registry_path,
+ runtime_root=resolve_runtime_root(registry, runtime_root_arg, registry_path=registry_path),
+ goal_id=args.goal_id, agent_id=args.agent_id,
+ bundle=_load_json_object(args.bundle_json, args.max_input_bytes), session_id=args.session_id, execute=args.execute)
+ elif args.periodic_report_command == "archive-openviking":
payload = _archive_openviking(
_load_json_object(args.request_json, args.max_input_bytes),
args,
diff --git a/loopx/capabilities/periodic_report/goal_configuration.py b/loopx/capabilities/periodic_report/goal_configuration.py
index a6ef4e0929..d176298710 100644
--- a/loopx/capabilities/periodic_report/goal_configuration.py
+++ b/loopx/capabilities/periodic_report/goal_configuration.py
@@ -53,7 +53,7 @@ def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None:
def normalize_configuration(value: Mapping[str, Any]) -> dict[str, Any]:
- allowed = {"enabled", "profile_preset", "route_ref", "timezone", "schedule", "task"}
+ allowed = {"enabled", "profile_preset", "route_ref", "timezone", "schedule", "task", "review_required"}
unknown = sorted(set(value) - allowed)
if unknown:
raise ValueError(
@@ -82,6 +82,12 @@ def normalize_configuration(value: Mapping[str, Any]) -> dict[str, Any]:
"route_ref": route_ref or None,
"timezone": timezone,
}
+ if "review_required" in value:
+ if type(value["review_required"]) is not bool:
+ raise TypeError("periodic_report.review_required must be a boolean")
+ if value["review_required"] and (profile_preset != "weekly-progress" or managed_session_report_destination(value) is None):
+ raise ValueError("automatic report review requires the original managed Session weekly-progress report route")
+ normalized["review_required"] = value["review_required"]
if profile_preset == "session-task":
if managed_session_report_destination(value) is None:
raise ValueError("a scheduled task requires its exact original managed Session")
diff --git a/loopx/capabilities/periodic_report/machine_defaults.py b/loopx/capabilities/periodic_report/machine_defaults.py
index 4d2c429f62..9cf721319b 100644
--- a/loopx/capabilities/periodic_report/machine_defaults.py
+++ b/loopx/capabilities/periodic_report/machine_defaults.py
@@ -111,6 +111,7 @@ def normalize_periodic_report_machine_defaults(
"route_ref",
"timezone",
"schedule",
+ "review_required",
},
label="periodic_report",
)
@@ -160,6 +161,12 @@ def normalize_periodic_report_machine_defaults(
f"periodic_report.{field}",
)
schedule = normalize_report_cadence(periodic.get("schedule"))
+ if "review_required" in periodic:
+ if type(periodic["review_required"]) is not bool:
+ raise TypeError("periodic_report.review_required must be a boolean")
+ if periodic["review_required"]:
+ raise ValueError("automatic report review requires an explicit managed Session Goal override")
+ normalized_periodic["review_required"] = False
if schedule is not None:
if schedule["timezone"] != timezone:
raise ValueError("schedule.timezone must match periodic_report.timezone")
@@ -294,6 +301,9 @@ def _normalized_goal_subscription(
raise ValueError("session tasks require an explicit Goal override")
normalized = normalize_configuration(config)
subscription["task"] = normalized["task"]
+ if "review_required" in config:
+ from .goal_configuration import normalize_configuration
+ subscription["review_required"] = normalize_configuration(config)["review_required"]
schedule = normalize_report_cadence(config.get("schedule"))
if schedule is not None:
if schedule["timezone"] != timezone_name:
diff --git a/loopx/capabilities/periodic_report/review.py b/loopx/capabilities/periodic_report/review.py
new file mode 100644
index 0000000000..7559912275
--- /dev/null
+++ b/loopx/capabilities/periodic_report/review.py
@@ -0,0 +1,142 @@
+"""Report bytes live here; canonical User Todos alone own human review state.
+
+The explicit producer freezes one normalized report and asks for review. Reading
+it never acknowledges, accepts, executes a model, or advances a delivery cursor.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import stat
+from pathlib import Path
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ...chat_store import ChatSessionStore
+
+from ...control_plane.effect_runtime import effect_runtime_result
+from ...control_plane.todos.list_readback import list_goal_todos
+from ...control_plane.todos.mutation_api import add_goal_todo
+from ...registry import atomic_write_json
+from .bindings import build_periodic_report_generation_bundle
+
+MAX_BUNDLE_BYTES = 2 * 1024 * 1024
+
+
+def _encoded(bundle: dict[str, Any]) -> bytes:
+ return json.dumps(bundle, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+
+
+def _path(root: Path, goal_id: str, digest: str) -> Path:
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", goal_id) or not re.fullmatch(r"[a-f0-9]{64}", digest):
+ raise ValueError("Invalid report review identity")
+ return root / "goals" / goal_id / "periodic_reports" / "review-bundles" / (digest + ".json")
+
+
+def _validate(bundle: dict[str, Any], *, new: bool = False) -> dict[str, Any]:
+ checked = build_periodic_report_generation_bundle(document=bundle["document"], artifacts=bundle["artifacts"])
+ if checked != bundle or len(_encoded(bundle)) > MAX_BUNDLE_BYTES:
+ raise ValueError("Report bundle is invalid or exceeds its reading bound")
+ if new:
+ # Only LoopX's escaped report renderers enter this reader. Arbitrary
+ # supplied HTML cannot impersonate a built-in artifact by hashing it.
+ from ...presentation.renderers.periodic_report_html import render_periodic_report_html
+ from ...presentation.renderers.periodic_report_markdown import render_periodic_report_markdown
+ canonical = build_periodic_report_generation_bundle(document=bundle["document"], artifacts=[
+ render_periodic_report_markdown(bundle["document"]), render_periodic_report_html(bundle["document"])])
+ if canonical != bundle:
+ raise ValueError("Review requires matching built-in Markdown and HTML artifacts")
+ return checked
+
+
+def _review_facts(todos: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ fields = ("goal_id", "todo_id", "role", "task_class", "action_kind", "capability_binding_ref", "status",
+ "created_by", "bound_agent", "created_at", "updated_at", "completed_at", "superseded_by")
+ return [{**{key: todo.get(key) for key in fields},
+ "text": str(todo.get("title") or todo.get("text") or "")[:200]} for todo in todos]
+
+
+def request_report_review(*, registry_path: Path, runtime_root: Path, goal_id: str,
+ agent_id: str, bundle: dict[str, Any], execute: bool = False,
+ session_id: str | None = None, session_store: ChatSessionStore | None = None) -> dict[str, Any]:
+ from ...agent_registry import registered_agent_ids_from_registry
+ if agent_id not in registered_agent_ids_from_registry(registry_path, goal_id):
+ raise ValueError("Report producer must be registered for the original Goal")
+ from ...control_plane.coordination.local_authority import read_canonical_todos_if_promoted
+ source = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id)
+ if session_id is not None:
+ if not re.fullmatch(r"[a-f0-9]{32}", session_id):
+ raise ValueError("Select one original managed report Session")
+ from ...chat_store import ChatSessionStore
+ from ..native_chat.steward_managed_mode import registered_managed_session_identity
+ session = (session_store or ChatSessionStore(runtime_root, startup_maintenance=False)).load_session(session_id)
+ if not session or session.get("goal_id") != goal_id or session.get("managed_agent_id") != agent_id:
+ raise ValueError("Report feedback must return to its original Goal and Agent Session")
+ registered_managed_session_identity(registry_path=registry_path, goal_id=goal_id, agent_id=agent_id, session=session)
+ bundle = _validate(bundle)
+ material = {"bundle": bundle, "source_session_id": session_id}
+ if len(_encoded(material)) > MAX_BUNDLE_BYTES:
+ raise ValueError("Report material exceeds its reading bound")
+ digest = hashlib.sha256(_encoded(material)).hexdigest()
+ plan = effect_runtime_result("presentation.report_review.plan", {
+ "goal_id": goal_id, "agent_id": agent_id, "bundle_sha256": digest, "title": bundle["document"]["title"],
+ "todos": _review_facts(source["todos"])}, large_local_snapshot=True)
+ # A canonical binding already owns these exact bytes. Recovery can restore
+ # them after an interrupted write without reinterpreting an older renderer
+ # or reopening a human decision. First requests require current renderers.
+ if not plan.get("existing_todo_id"):
+ _validate(bundle, new=True)
+ if execute:
+ path = _path(runtime_root, goal_id, digest)
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ atomic_write_json(path, material)
+ # The normal writer verifies the registered producer, ownership and current
+ # Goal. Its stable operation recovers an unknown create without reopening
+ # an already accepted review. A new edition has a different binding.
+ result = {"ok": True, "todo_id": plan["existing_todo_id"]} if plan.get("existing_todo_id") else add_goal_todo(registry_path=registry_path, runtime_root_arg=str(runtime_root),
+ goal_id=goal_id, agent_id=agent_id, role=plan["role"], text=plan["text"], priority=plan["priority"],
+ task_class=plan["task_class"], action_kind=plan["action_kind"],
+ capability_binding_ref=plan["capability_binding_ref"], bound_agent=plan["bound_agent"],
+ operation_id=plan["operation_id"], dry_run=not execute)
+ if result.get("ok") is not True:
+ raise ValueError("The canonical report review request was not accepted")
+ return {"ok": True, "goal_id": goal_id, "agent_id": agent_id,
+ "todo_id": result.get("todo_id"), "bundle_sha256": digest,
+ "generation_id": bundle["generation_receipt"]["generation_id"], "dry_run": not execute,
+ "review_decision_recorded": False, "model_invoked": False, "external_writes_performed": False}
+
+
+def report_review_inventory(*, registry_path: Path, runtime_root: Path, goal_id: str) -> list[dict[str, Any]]:
+ from ...control_plane.coordination.local_authority import read_canonical_todos_if_promoted
+ source = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id)
+ return effect_runtime_result("presentation.report_review.project", {"goal_id": goal_id, "todos": _review_facts(source["todos"])},
+ large_local_snapshot=True)["items"]
+
+
+def read_report_review(*, registry_path: Path, runtime_root: Path, goal_id: str, todo_id: str) -> dict[str, Any]:
+ source = list_goal_todos(registry_path=registry_path, runtime_root_arg=str(runtime_root), goal_id=goal_id, todo_id=todo_id)
+ todo = source.get("todo")
+ rows = effect_runtime_result("presentation.report_review.project", {"goal_id": goal_id, "todos": [todo]})["items"]
+ if len(rows) != 1:
+ raise ValueError("This Todo does not own a report review")
+ row = rows[0]
+ path = _path(runtime_root, goal_id, row["bundle_sha256"])
+ with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)), "rb") as handle:
+ if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
+ raise ValueError("Report bundle must be a regular file")
+ data = handle.read(MAX_BUNDLE_BYTES + 1)
+ if len(data) > MAX_BUNDLE_BYTES:
+ raise ValueError("Report bundle exceeds its reading bound")
+ material = json.loads(data)
+ if hashlib.sha256(_encoded(material)).hexdigest() != row["bundle_sha256"]:
+ raise ValueError("Report edition changed; original review cannot be reused")
+ bundle = _validate(material["bundle"])
+ session_id = material["source_session_id"]
+ if session_id is not None and not re.fullmatch(r"[a-f0-9]{32}", session_id):
+ raise ValueError("Invalid original report Session")
+ return {"ok": True, "goal_id": goal_id, "review": row, "todo": todo,
+ "source_session_id": session_id,
+ "document": bundle["document"], "artifacts": bundle["artifacts"],
+ "generation_id": bundle["generation_receipt"]["generation_id"]}
diff --git a/loopx/chat/completed_todos.py b/loopx/chat/completed_todos.py
index bdba52074e..612c6c9e27 100644
--- a/loopx/chat/completed_todos.py
+++ b/loopx/chat/completed_todos.py
@@ -13,6 +13,7 @@
from ..paths import resolve_runtime_root
from ..capabilities.native_chat.project_context import coordination_runtime_root
from ..status_server import is_loopback_host
+from ..presentation.report_reviews_api import ReportReviewsRequestMixin
def _goal_result_candidates(*, runtime_root, goal_id):
@@ -121,7 +122,23 @@ def page(self, *, scope, cursor, load):
}
-class CompletedTodoRequestMixin:
+class CompletedTodoRequestMixin(ReportReviewsRequestMixin):
+ def _dispatch_goal_result_read(self, path: str) -> bool:
+ """Route sibling result readers; each retains its own scope and readback."""
+ parts = path.strip("/").split("/")
+ if len(parts) not in (3, 4) or parts[:2] != ["api", "chat"]:
+ return False
+ readers = {"goal-results": (self._goal_results, self._goal_result),
+ "report-reviews": (self._report_reviews, self._report_reviews)}
+ reader = readers.get(parts[2])
+ if reader is None:
+ return False
+ if len(parts) == 3:
+ reader[0]()
+ else:
+ reader[1](parts[3])
+ return True
+
def _goal_result_scope(self, goal_id):
if not is_loopback_host(str(self.server.server_address[0])):
self._send_error("Goal results require a loopback LoopX Chat server.", status=403)
diff --git a/loopx/chat/goal_configuration_api.py b/loopx/chat/goal_configuration_api.py
index 5bb47de3dc..6e892004a5 100644
--- a/loopx/chat/goal_configuration_api.py
+++ b/loopx/chat/goal_configuration_api.py
@@ -232,7 +232,7 @@ def _goal_capability_options(
"local_authority_shadow": {"enabled"},
"coordination_runtime_shadow": {"enabled"},
"lark_kanban_heartbeat_sync": {"enabled"},
- "periodic_report": {"enabled", "profile_preset", "route_ref", "timezone", "schedule", "task"},
+ "periodic_report": {"enabled", "profile_preset", "route_ref", "timezone", "schedule", "task", "review_required"},
"reward_memory": {"config_path", "enabled_agents", "agent_id", "enabled"},
}
if capability_id not in allowed:
diff --git a/loopx/chat/machine_configuration_api.py b/loopx/chat/machine_configuration_api.py
index 2e00ba36b0..e538e06a45 100644
--- a/loopx/chat/machine_configuration_api.py
+++ b/loopx/chat/machine_configuration_api.py
@@ -72,6 +72,11 @@ def _public_payload(
machine_namespaces=namespace_catalog["namespaces"],
goal_features=build_configuration_capability_descriptors(),
)
+ for capability in projected["capability_catalog"]["capabilities"]:
+ if capability["capability_id"] == "periodic_report":
+ # Human review is bound to an original Goal Session, not a device default.
+ editor = capability["configuration_editor"]
+ editor["fields"] = [field for field in editor["fields"] if field["key"] != "review_required"]
return projected
diff --git a/loopx/chat_server.py b/loopx/chat_server.py
index 037bce1aa7..c36db51033 100644
--- a/loopx/chat_server.py
+++ b/loopx/chat_server.py
@@ -1707,7 +1707,6 @@ def do_GET(self) -> None:
GOAL_SOURCE_LINKS_PATH: self._goal_source_links,
"/api/chat/todo/detail": self._todo_detail,
"/api/chat/completed-todos": self._completed_todos,
- "/api/chat/goal-results": self._goal_results,
"/api/chat/explore-results": self._explore_results,
RESEARCH_ANALYSIS_PATH: self._research_analysis_read,
RESEARCH_ANALYSIS_PATH + "/citation": self._research_analysis_read,
@@ -1729,9 +1728,8 @@ def do_GET(self) -> None:
}
if path in get_dispatch:
return get_dispatch[path]()
- result_parts = path.strip("/").split("/")
- if len(result_parts) == 4 and result_parts[:3] == ["api", "chat", "goal-results"]:
- return self._goal_result(result_parts[3])
+ if self._dispatch_goal_result_read(path):
+ return
setup_parts = path.strip("/").split("/")
if len(setup_parts) == 5 and setup_parts[:4] == ["api", "chat", "lark", "app-setups"]:
return self._lark_setup_snapshot(setup_parts[4])
diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts
index f9a1bc8d94..f9d7223893 100644
--- a/loopx/control_plane/effect_runtime_handlers.ts
+++ b/loopx/control_plane/effect_runtime_handlers.ts
@@ -439,6 +439,8 @@ export function createEffectRuntimeHandlers(
["presentation.global_summary.project", lazyHandler(() => import("./presentation/global_manager_read.ts"), ({projectGlobalSummary}) => projectGlobalSummary)],
["presentation.global_gates.project", lazyHandler(() => import("./presentation/global_manager_read.ts"), ({projectGlobalGates}) => projectGlobalGates)],
["presentation.goal_attention.project", lazyHandler(() => import("./presentation/goal_attention.ts"), ({projectGoalAttention}) => projectGoalAttention)],
+ ["presentation.report_review.plan", lazyHandler(() => import("./presentation/report_review.ts"), ({planReportReview}) => planReportReview)],
+ ["presentation.report_review.project", lazyHandler(() => import("./presentation/report_review.ts"), ({projectReportReviews}) => projectReportReviews)],
["presentation.goal_sources.project", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({projectGoalSourceLinks}) => projectGoalSourceLinks)],
["presentation.owner_attention.plan", lazyHandler(() => import("./presentation/owner_attention.ts"), ({planOwnerAttention}) => planOwnerAttention)],
["presentation.owner_attention.observe", lazyHandler(() => import("./presentation/owner_attention.ts"), ({observeOwnerAttention}) => observeOwnerAttention)],
diff --git a/loopx/control_plane/effect_runtime_snapshot_methods.json b/loopx/control_plane/effect_runtime_snapshot_methods.json
index 894cedb89f..d0f5c012a6 100644
--- a/loopx/control_plane/effect_runtime_snapshot_methods.json
+++ b/loopx/control_plane/effect_runtime_snapshot_methods.json
@@ -1,4 +1,6 @@
[
+ "presentation.report_review.project",
+ "presentation.report_review.plan",
"todo.read",
"todo.quota_planning.project",
"goal.vision_wait.project",
diff --git a/loopx/control_plane/presentation/report_review.ts b/loopx/control_plane/presentation/report_review.ts
new file mode 100644
index 0000000000..2c42993e2b
--- /dev/null
+++ b/loopx/control_plane/presentation/report_review.ts
@@ -0,0 +1,44 @@
+/** Human review is a canonical User action, never report generation or Goal acceptance. */
+import type {JsonObject} from "../effect_program.ts";
+import {requireNonEmptyString} from "../runtime_decode.ts";
+import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts";
+import {BARE_SHA256_PATTERN} from "../content_digest.ts";
+import {reportReviewDigest} from "./report_review_binding.ts";
+
+export function planReportReview(input: JsonObject): JsonObject {
+ const digest = requireNonEmptyString(input.bundle_sha256, "bundle_sha256");
+ if (!BARE_SHA256_PATTERN.test(digest)) throw new Error("Invalid report bundle digest");
+ const goal = requireNonEmptyString(input.goal_id, "goal_id");
+ const agent = requireNonEmptyString(input.agent_id, "agent_id");
+ const title = requireNonEmptyString(input.title, "report title");
+ if ([...title].length > 200) throw new Error("Report title exceeds its existing limit");
+ const existing = input.todos == null ? [] : (projectReportReviews({goal_id: goal, todos: input.todos}).items as JsonObject[])
+ .filter(row => row.bundle_sha256 === digest && row.created_by === agent);
+ if (existing.length > 1) throw new Error("Ambiguous report review identity");
+ return {role: "user", task_class: "user_action", action_kind: "review_periodic_report",
+ priority: "P1", text: title,
+ existing_todo_id: existing[0]?.todo_id ?? null,
+ capability_binding_ref: `periodic-report-review:r${digest}`, bound_agent: agent,
+ // Different producers and Goals never share a review decision.
+ operation_id: `report-review:${canonicalAuthoritySha256([goal, agent, digest])}`};
+}
+
+/** Only native bindings select rows. Titles and prose never classify review work. */
+export function projectReportReviews(input: JsonObject): JsonObject {
+ if (!Array.isArray(input.todos)) throw new Error("Report review Todo source is unavailable");
+ const goal = requireNonEmptyString(input.goal_id, "goal_id");
+ const items = input.todos.flatMap(raw => {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid Todo source");
+ const todo = raw as JsonObject;
+ const digest = reportReviewDigest(todo);
+ if (!digest) return [];
+ if (todo.goal_id != null && todo.goal_id !== goal) throw new Error("Foreign report review");
+ return [{todo_id: requireNonEmptyString(todo.todo_id, "todo_id"), bundle_sha256: digest,
+ title: requireNonEmptyString(todo.text, "report title"), status: todo.status,
+ created_by: todo.created_by ?? null,
+ bound_agent: todo.bound_agent ?? null, created_at: todo.created_at ?? null,
+ updated_at: todo.updated_at ?? null, completed_at: todo.completed_at ?? null,
+ superseded_by: todo.superseded_by ?? null}];
+ });
+ return {items: items.sort((a, b) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")))};
+}
diff --git a/loopx/control_plane/presentation/report_review_binding.ts b/loopx/control_plane/presentation/report_review_binding.ts
new file mode 100644
index 0000000000..c1dc702160
--- /dev/null
+++ b/loopx/control_plane/presentation/report_review_binding.ts
@@ -0,0 +1,8 @@
+/** One shared codec for the report capability's canonical User action binding. */
+const bindingPattern = /^periodic-report-review:r([a-f0-9]{64})$/;
+export function reportReviewDigest(todo: Record): string | null {
+ if (todo.role !== "user" || todo.task_class !== "user_action"
+ || todo.action_kind !== "review_periodic_report") return null;
+ return typeof todo.capability_binding_ref === "string"
+ ? bindingPattern.exec(todo.capability_binding_ref)?.[1] ?? null : null;
+}
diff --git a/loopx/control_plane/todos/authoring_scope.ts b/loopx/control_plane/todos/authoring_scope.ts
index 2c30b93870..f102b296e8 100644
--- a/loopx/control_plane/todos/authoring_scope.ts
+++ b/loopx/control_plane/todos/authoring_scope.ts
@@ -290,9 +290,13 @@ function planScope(command: string, role: string, taskClass: string | null, todo
}
if (role === "agent" && requestedBlocks) fail("blocks_agent is only valid for user gates; use excluded_agents for agent executor constraints");
if (role === "user" && intent.claimed_by) fail("claimed_by is execution ownership for agent todos, not a user-todo binding; use --bound-agent or --goal-bound");
- for (const field of ["task_repository", "task_domain", "capability_binding_ref"]) {
+ for (const field of ["task_repository", "task_domain"]) {
if (intent[field] && role !== "agent") fail(`${field} is only valid for agent todos`);
}
+ // A capability can bind an exact artifact to a human action. This reference
+ // is data, not blocking scope, approval authority or execution ownership.
+ if (intent.capability_binding_ref && role !== "agent" && taskClass !== "user_action")
+ fail("capability_binding_ref is only valid for agent or user_action todos");
const creating = command === "create";
let blocks = intent.clear_blocks_agent ? null : requestedBlocks || string(todo.blocks_agent, "blocks_agent");
const global = intent.clear_global_gate
diff --git a/loopx/control_plane/todos/read_projection.ts b/loopx/control_plane/todos/read_projection.ts
index e7dfa88de0..dda1c4bbcf 100644
--- a/loopx/control_plane/todos/read_projection.ts
+++ b/loopx/control_plane/todos/read_projection.ts
@@ -103,6 +103,73 @@ function handoffGates(items: JsonObject[], evaluations: JsonObject[]): JsonObjec
}).sort((a, b) => Number(a.index || 999999) - Number(b.index || 999999) || authorityUnicodeCompare(text(a.todo_id), text(b.todo_id)));
}
+const CAPABILITY_ACTION_ITEM_LIMIT = 3;
+const CAPABILITY_ACTION_BINDING = /^[a-z][a-z0-9_.-]{0,31}:[a-z][a-z0-9_.:-]{2,95}$/u;
+const CAPABILITY_ACTION_KIND = /^[a-z][a-z0-9_-]{0,63}$/;
+
+/** Capability-bound User actions grouped before the generic Todo display cap. */
+function projectCapabilityActions(
+ items: readonly JsonObject[], openIndices: readonly number[], compacts: readonly JsonObject[],
+): JsonObject {
+ const groups = new Map();
+ for (const index of openIndices) {
+ const item = items[index]!;
+ const status = stripPythonWhitespace(text(item.status || (item.done === true ? "done" : "open"))).toLowerCase();
+ if (item.done === true || item.archive_state === "archive" || id(item.superseded_by) !== null
+ || !["open", "blocked"].includes(status)) continue;
+ if (taskClass(item) !== "user_action") continue;
+ const actionKind = stripPythonWhitespace(text(item.action_kind)).toLowerCase();
+ const binding = stripPythonWhitespace(text(item.capability_binding_ref)).toLowerCase();
+ if (!CAPABILITY_ACTION_KIND.test(actionKind) || !CAPABILITY_ACTION_BINDING.test(binding)) continue;
+ const group = groups.get(actionKind) ?? [];
+ group.push(index);
+ groups.set(actionKind, group);
+ }
+ const byRecency = (left: number, right: number) => {
+ const l = compacts[left]!, r = compacts[right]!;
+ return authorityUnicodeCompare(text(r.updated_at || r.created_at), text(l.updated_at || l.created_at)) ||
+ authorityUnicodeCompare(text(l.todo_id), text(r.todo_id));
+ };
+ const projectedGroups = [...groups.entries()].map(([actionKind, indices]) => {
+ const ordered = [...indices].sort(byRecency);
+ return {
+ action_kind: actionKind,
+ count: ordered.length,
+ latest_at: ordered.length ? (compacts[ordered[0]!]!.updated_at ?? compacts[ordered[0]!]!.created_at ?? null) : null,
+ items: ordered.slice(0, CAPABILITY_ACTION_ITEM_LIMIT).flatMap(index => {
+ const item = items[index]!;
+ const summary = compacts[index]!;
+ const todoId = id(item.todo_id);
+ const title = compactTodoReadText(text(item.title || item.text), 200);
+ if (!todoId || !title) return [];
+ return [withoutNull({
+ index: item.index ?? null,
+ done: false,
+ text: title,
+ todo_id: todoId,
+ role: "user",
+ goal_id: item.goal_id ?? null,
+ status: text(item.status) || "open",
+ task_class: "user_action",
+ action_kind: actionKind,
+ capability_binding_ref: text(item.capability_binding_ref).toLowerCase(),
+ created_by: summary.created_by ?? null,
+ bound_agent: summary.bound_agent ?? null,
+ created_at: summary.created_at ?? null,
+ updated_at: summary.updated_at ?? null,
+ })];
+ }),
+ };
+ }).sort((left, right) =>
+ authorityUnicodeCompare(text(right.latest_at), text(left.latest_at)) ||
+ authorityUnicodeCompare(left.action_kind, right.action_kind));
+ return {
+ schema_version: "todo_capability_actions_v0",
+ total_count: projectedGroups.reduce((sum, group) => sum + group.count, 0),
+ groups: projectedGroups,
+ };
+}
+
export async function projectTodoReadGroup(input: JsonObject): Promise {
if (!Array.isArray(input.items) || !Array.isArray(input.lineage)) throw new EffectRuntimeRequestError("Todo read requires complete source arrays");
const items = input.items.map(value => ({...requireJsonObject(value, "Todo read item")}));
@@ -181,6 +248,9 @@ export async function projectTodoReadGroup(input: JsonObject): Promise items[index]!);
const chosenDecisions = projected.source_indices.map(index => decisions[index]!);
diff --git a/loopx/control_plane/work_items/project_asset.py b/loopx/control_plane/work_items/project_asset.py
index fd99924546..00136ab2bf 100644
--- a/loopx/control_plane/work_items/project_asset.py
+++ b/loopx/control_plane/work_items/project_asset.py
@@ -519,6 +519,7 @@ def project_asset_todo_projection_gap(
"source_section",
"task_class",
"action_kind",
+ "capability_binding_ref",
"task_domain",
"task_repository",
"required_write_scopes",
@@ -626,6 +627,12 @@ def lane_count(lane: str) -> int:
"total": total_count,
**metadata,
}
+ capability_actions = todos.get("capability_actions")
+ if todo_role == "user" and isinstance(capability_actions, dict):
+ # Derived from the full canonical User Todo source before its generic
+ # display items are capped. This is a bounded projection, not another
+ # review or decision authority.
+ summary["capability_actions"] = dict(capability_actions)
if todos.get("advancement_done_count") is not None:
summary["advancement_done_count"] = todos.get("advancement_done_count")
open_items = open_todo_items(todos, limit=item_limit)
diff --git a/loopx/presentation/report_reviews_api.py b/loopx/presentation/report_reviews_api.py
new file mode 100644
index 0000000000..d205c9e1ac
--- /dev/null
+++ b/loopx/presentation/report_reviews_api.py
@@ -0,0 +1,33 @@
+"""Owner-local report reading; all review writes use existing Todo actions."""
+from urllib.parse import parse_qs, urlparse
+
+from ..capabilities.periodic_report.review import report_review_inventory, read_report_review
+from ..control_plane.coordination.local_authority import CanonicalAuthorityRequired
+
+
+class ReportReviewsRequestMixin:
+ def _report_reviews(self, todo_id=None):
+ query = parse_qs(urlparse(self.path).query)
+ goal_id = query.get("goal_id", [""])[0]
+ try:
+ root = self._goal_result_scope(goal_id)
+ if root is None:
+ return
+ if todo_id:
+ result = read_report_review(registry_path=self.server.registry_path,
+ runtime_root=root, goal_id=goal_id, todo_id=todo_id)
+ else:
+ result = self.server.completed_todo_pages.page(scope=("report_reviews", goal_id),
+ cursor=query.get("cursor", [""])[0], load=lambda: report_review_inventory(
+ registry_path=self.server.registry_path, runtime_root=root, goal_id=goal_id))
+ self._send_json(result)
+ except CanonicalAuthorityRequired as exc:
+ if todo_id:
+ self._send_error(str(exc), status=409, error_code=exc.code)
+ else:
+ self._send_json({"ok": True, "available": False, "reason_code": exc.code,
+ "items": [], "total": 0, "next_cursor": None})
+ except (ValueError, KeyError, TypeError):
+ self._send_error("Report or review state changed. Refresh the original material.", status=409)
+ except (OSError, RuntimeError):
+ self._send_error("Report review is unavailable. Retry the original read.", status=503)
diff --git a/tests/capabilities/test_capability_configuration_ui.py b/tests/capabilities/test_capability_configuration_ui.py
index 8f7c16b472..c9eee87560 100644
--- a/tests/capabilities/test_capability_configuration_ui.py
+++ b/tests/capabilities/test_capability_configuration_ui.py
@@ -58,6 +58,7 @@ def test_periodic_report_editor_is_shared_across_machine_and_goal_scopes() -> No
assert editor["supported_scopes"] == ["machine", "goal"]
assert [field["key"] for field in editor["fields"]] == [
"enabled",
+ "review_required",
"profile_preset",
"route_ref",
"timezone",
diff --git a/tests/capabilities/test_periodic_report_review.py b/tests/capabilities/test_periodic_report_review.py
new file mode 100644
index 0000000000..e4ad7b92be
--- /dev/null
+++ b/tests/capabilities/test_periodic_report_review.py
@@ -0,0 +1,271 @@
+"""Real canonical Todos and HTTP reads; no live Goal, model or external sink."""
+from copy import deepcopy
+import json
+import threading
+from urllib.error import HTTPError
+from urllib.request import Request, urlopen
+
+import pytest
+
+from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime
+from tests.capabilities.test_periodic_report_bindings import _document
+from loopx.capabilities.periodic_report.bindings import build_periodic_report_generation_bundle
+from loopx.capabilities.periodic_report.review import request_report_review, read_report_review, report_review_inventory
+from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection
+from loopx.control_plane.todos.mutation_api import add_goal_todo, update_goal_todo
+from loopx.presentation.renderers.periodic_report_html import render_periodic_report_html
+from loopx.presentation.renderers.periodic_report_markdown import render_periodic_report_markdown
+
+
+def review_bundle(title="研究材料:结论、变化与待判断事项"):
+ document = _document()
+ document["title"] = title
+ return build_periodic_report_generation_bundle(document=document,
+ artifacts=[render_periodic_report_markdown(document), render_periodic_report_html(document)])
+
+
+def test_review_configuration_is_opt_in_and_only_on_original_report_conversation():
+ from loopx.capabilities.periodic_report.goal_configuration import normalize_configuration
+ from loopx.capabilities.configuration_ui import build_capability_configuration_catalog
+ from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry
+ from loopx.chat.machine_configuration_api import _public_payload
+ base = {"enabled": True, "profile_preset": "weekly-progress", "route_ref": "managed-session:" + "a" * 32}
+ assert "review_required" not in normalize_configuration(base)
+ assert normalize_configuration({**base, "review_required": False})["review_required"] is False
+ assert normalize_configuration({**base, "review_required": True})["review_required"] is True
+ for change in [{"route_ref": "another-channel"}, {"profile_preset": "session-task"}, {"review_required": "true"}]:
+ with pytest.raises((ValueError, TypeError)):
+ normalize_configuration({**base, "review_required": True, **change})
+ registry = build_builtin_machine_configuration_registry()
+ machine = _public_payload({}, registry=registry)["capability_catalog"]
+ report = next(row for row in machine["capabilities"] if row["capability_id"] == "periodic_report")
+ assert "review_required" not in {field["key"] for field in report["configuration_editor"]["fields"]}
+ goal = build_capability_configuration_catalog(goal_features=[{"feature_id": "periodic_report"}])
+ assert "review_required" in {field["key"] for field in goal["capabilities"][0]["configuration_editor"]["fields"]}
+
+
+def test_unconfigured_legacy_goal_stays_quiet_without_creating_material(tmp_path, monkeypatch):
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md"
+ state.write_text("# Legacy Goal\n\n## User Todo\n\n## Agent Todo\n")
+ registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{"id": "legacy", "repo": str(tmp_path),
+ "state_file": state.name, "coordination": {"registered_agents": ["writer"]}}]}))
+ scope = dict(registry_path=registry, runtime_root=runtime, goal_id="legacy")
+ before = state.read_bytes()
+ from loopx.control_plane.coordination.local_authority import CanonicalAuthorityRequired
+ with pytest.raises(CanonicalAuthorityRequired):
+ report_review_inventory(**scope)
+ with pytest.raises(CanonicalAuthorityRequired):
+ request_report_review(**scope, agent_id="writer", bundle=review_bundle(), execute=True)
+ assert state.read_bytes() == before and not (runtime / "goals/legacy/periodic_reports/review-bundles").exists()
+ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.registry_path, server.runtime_root_override, server.verbose = registry, str(runtime), False
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ with urlopen(f"http://127.0.0.1:{server.server_port}/api/chat/report-reviews?goal_id=legacy") as response:
+ unavailable = json.load(response)
+ assert unavailable["available"] is False and unavailable["reason_code"] == "canonical_authority_required"
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+@pytest.fixture(params=["file", "sqlite"])
+def review_goal(tmp_path, monkeypatch, request):
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json"
+ state.write_text("# Research\n\n## User Todo\n\n## Agent Todo\n", encoding="utf-8")
+ registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{"id": "research",
+ "status": "active", "goal_instance_id": "review-instance", "domain": "research", "adapter": {"kind": "generic"},
+ "repo": str(tmp_path), "state_file": state.name, "coordination": {"registered_agents": ["researcher", "peer"]}}]}))
+ initialize_canonical_authority(runtime, "research", build_todo_runtime_shadow_projection(
+ goal_id="research", handoff_mode="soft_claim", todos=[]), state_path=state, provider=request.param)
+ return {"registry_path": registry, "runtime_root": runtime, "goal_id": "research"}
+
+
+def test_read_is_passive_replay_does_not_reopen_and_new_editions_require_review(review_goal):
+ bundle = review_bundle()
+ dry = request_report_review(**review_goal, agent_id="researcher", bundle=bundle)
+ assert dry["dry_run"] and report_review_inventory(**review_goal) == []
+ first = request_report_review(**review_goal, agent_id="researcher", bundle=bundle, execute=True)
+ before = report_review_inventory(**review_goal)
+ for _ in range(2):
+ read = read_report_review(**review_goal, todo_id=first["todo_id"])
+ assert read["review"]["status"] == "open"
+ assert read["todo"]["task_class"] == "user_action" and not read["todo"].get("blocks_agent")
+ assert read["generation_id"] == first["generation_id"]
+ assert report_review_inventory(**review_goal) == before
+ completed = update_goal_todo(registry_path=review_goal["registry_path"], runtime_root_arg=str(review_goal["runtime_root"]),
+ goal_id="research", agent_id="researcher", todo_id=first["todo_id"], role="user", status="done", reason="Owner accepted this edition")
+ assert completed["ok"], completed
+ replay = request_report_review(**review_goal, agent_id="researcher", bundle=bundle, execute=True)
+ assert replay["todo_id"] == first["todo_id"]
+ assert read_report_review(**review_goal, todo_id=first["todo_id"])["review"]["status"] == "done"
+ next_edition = request_report_review(**review_goal, agent_id="researcher", bundle=review_bundle("修订后的研究材料"), execute=True)
+ peer = request_report_review(**review_goal, agent_id="peer", bundle=bundle, execute=True)
+ assert len({first["todo_id"], next_edition["todo_id"], peer["todo_id"]}) == 3
+ assert read_report_review(**review_goal, todo_id=next_edition["todo_id"])["review"]["status"] == "open"
+
+
+def test_home_summary_counts_reviews_before_generic_todo_caps(review_goal):
+ for index in range(7):
+ request_report_review(**review_goal, agent_id="researcher",
+ bundle=review_bundle(f"研究材料第 {index + 1} 版"), execute=True)
+ for index in range(3):
+ added = add_goal_todo(
+ registry_path=review_goal["registry_path"],
+ runtime_root_arg=str(review_goal["runtime_root"]),
+ goal_id="research",
+ agent_id="researcher",
+ role="user",
+ text=f"P0 owner action {index + 1}",
+ priority="P0",
+ task_class="user_action",
+ action_kind="manual_owner_action",
+ )
+ assert added["ok"], added
+ from loopx.status import collect_status
+ status = collect_status(
+ registry_path=review_goal["registry_path"],
+ runtime_root_override=str(review_goal["runtime_root"]),
+ goal_id="research",
+ limit=10,
+ )
+ goal = next(row for row in status["attention_queue"]["items"] if row["goal_id"] == "research")
+ for summary in (goal["user_todos"], goal["project_asset"]["user_todos"]):
+ actions = summary["capability_actions"]
+ assert actions["total_count"] == 7
+ review_group = next(group for group in actions["groups"]
+ if group["action_kind"] == "review_periodic_report")
+ assert review_group["count"] == 7
+ assert len(review_group["items"]) == 3
+ assert len(goal["project_asset"]["user_todos"]["items"]) <= 3
+
+
+def test_public_cli_can_prepare_then_retry_one_review_request(review_goal, capsys):
+ from loopx.entrypoint import main
+ path = review_goal["runtime_root"].parent / "generation-bundle.json"
+ path.write_text(json.dumps(review_bundle()))
+ args = ["--format", "json", "--registry", str(review_goal["registry_path"]),
+ "--runtime-root", str(review_goal["runtime_root"]), "periodic-report", "request-review",
+ "--goal-id", "research", "--agent-id", "researcher", "--bundle-json", str(path)]
+ assert main(args) == 0
+ assert json.loads(capsys.readouterr().out)["dry_run"] is True
+ assert report_review_inventory(**review_goal) == []
+ ids = []
+ for _ in range(2):
+ assert main([*args, "--execute"]) == 0
+ ids.append(json.loads(capsys.readouterr().out)["todo_id"])
+ assert ids[0] == ids[1] and len(report_review_inventory(**review_goal)) == 1
+
+
+def test_queued_edition_survives_renderer_upgrade_without_reopening(review_goal, monkeypatch):
+ from loopx.presentation.renderers import periodic_report_html
+ old, revised = review_bundle(), review_bundle("A new edition")
+ queued = request_report_review(**review_goal, agent_id="researcher", bundle=old, execute=True)
+ update_goal_todo(registry_path=review_goal["registry_path"], runtime_root_arg=str(review_goal["runtime_root"]),
+ goal_id="research", agent_id="researcher", todo_id=queued["todo_id"], status="done", reason="Owner handled the original")
+ def changed_renderer(_):
+ raise ValueError("Synthetic later renderer cannot reproduce old formatting")
+ monkeypatch.setattr(periodic_report_html, "render_periodic_report_html", changed_renderer)
+ replay = request_report_review(**review_goal, agent_id="researcher", bundle=old, execute=True)
+ assert replay["todo_id"] == queued["todo_id"]
+ assert read_report_review(**review_goal, todo_id=queued["todo_id"])["review"]["status"] == "done"
+ for bundle, agent in [(revised, "researcher"), (old, "peer")]:
+ with pytest.raises(ValueError, match="later renderer"):
+ request_report_review(**review_goal, agent_id=agent, bundle=bundle, execute=True)
+ assert len(report_review_inventory(**review_goal)) == 1
+
+
+def test_arbitrary_html_and_corrupt_or_foreign_material_fail_without_accepting(review_goal):
+ bundle = review_bundle()
+ forged = deepcopy(bundle)
+ html = next(a for a in forged["artifacts"] if a["renderer_id"] == "html_artifact_v0")
+ html["content"] = ''
+ # Rehashing arbitrary HTML still cannot impersonate the escaped renderer.
+ import hashlib
+ html["content_digest"] = "sha256:" + hashlib.sha256(html["content"].encode()).hexdigest()
+ with pytest.raises(ValueError):
+ request_report_review(**review_goal, agent_id="researcher", bundle=forged, execute=True)
+ assert report_review_inventory(**review_goal) == []
+ first = request_report_review(**review_goal, agent_id="researcher", bundle=bundle, execute=True)
+ path = review_goal["runtime_root"] / "goals/research/periodic_reports/review-bundles" / (first["bundle_sha256"] + ".json")
+ path.write_text("{}")
+ with pytest.raises((KeyError, ValueError)):
+ read_report_review(**review_goal, todo_id=first["todo_id"])
+ assert report_review_inventory(**review_goal)[0]["status"] == "open"
+ with pytest.raises((KeyError, ValueError, RuntimeError)):
+ read_report_review(**{**review_goal, "goal_id": "foreign"}, todo_id=first["todo_id"])
+
+
+def test_foreign_feedback_session_cannot_be_bound_to_report(review_goal):
+ from loopx.chat_store import ChatSessionStore
+ foreign = ChatSessionStore(review_goal["runtime_root"]).create_session(goal_id="research", agent_id="codex",
+ managed_agent_id="peer", managed_workspace_path=str(review_goal["runtime_root"].parent),
+ channel_id="agent.peer", adapter_kind="codex", upstream_thread_id="synthetic-peer")
+ with pytest.raises(ValueError, match="original Goal and Agent"):
+ request_report_review(**review_goal, agent_id="researcher", bundle=review_bundle(), session_id=foreign["session_id"], execute=True)
+ assert report_review_inventory(**review_goal) == []
+
+
+def test_original_chat_store_can_be_separate_from_goal_authority(review_goal):
+ from loopx.chat_store import ChatSessionStore
+ store = ChatSessionStore(review_goal["runtime_root"].parent / "separate-history")
+ session = store.create_session(goal_id="research", goal_instance_id="review-instance", agent_id="codex",
+ managed_agent_id="researcher", managed_workspace_path=str(review_goal["runtime_root"].parent),
+ channel_id="agent.researcher", adapter_kind="codex_app_server", upstream_thread_id="synthetic-original")
+ with pytest.raises(ValueError):
+ request_report_review(**review_goal, agent_id="researcher", bundle=review_bundle(), session_id=session["session_id"])
+ assert report_review_inventory(**review_goal) == []
+ queued = request_report_review(**review_goal, agent_id="researcher", bundle=review_bundle(),
+ session_id=session["session_id"], session_store=store, execute=True)
+ assert read_report_review(**review_goal, todo_id=queued["todo_id"])["source_session_id"] == session["session_id"]
+
+
+def test_real_http_material_reader_is_same_origin_and_keeps_canonical_state(review_goal, monkeypatch):
+ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+ first = request_report_review(**review_goal, agent_id="researcher", bundle=review_bundle(), execute=True)
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.registry_path = review_goal["registry_path"]
+ server.runtime_root_override = str(review_goal["runtime_root"])
+ server.selected_goal_id = None
+ server.scan_roots = []
+ server.limit = 10
+ server.verbose = False
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ url = f"http://127.0.0.1:{server.server_port}/api/chat/report-reviews"
+ try:
+ with urlopen(url + "?goal_id=research") as response:
+ page = json.load(response)
+ assert page["total"] == 1 and page["items"][0]["todo_id"] == first["todo_id"]
+ with urlopen(url + f'/{first["todo_id"]}?goal_id=research') as response:
+ document = json.load(response)
+ assert document["review"]["status"] == "open" and len(document["artifacts"]) == 2
+ # Home consumes the compact project-asset projection, not the rich row.
+ from loopx.status import collect_status
+ status = collect_status(registry_path=review_goal["registry_path"], runtime_root_override=str(review_goal["runtime_root"]),
+ goal_id="research", limit=10)
+ goal = next(row for row in status["attention_queue"]["items"] if row["goal_id"] == "research")
+ summary = next(row for row in goal["project_asset"]["user_todos"]["items"] if row["todo_id"] == first["todo_id"])
+ assert summary["capability_binding_ref"] == document["todo"]["capability_binding_ref"]
+ assert "resolved_path" not in json.dumps(document)
+ with pytest.raises(HTTPError) as denied:
+ urlopen(Request(url + f'/{first["todo_id"]}?goal_id=research', headers={"Origin": "https://unrelated.example"}))
+ assert denied.value.code == 403
+ from loopx.control_plane.coordination import local_authority
+ def unavailable(**_):
+ raise RuntimeError("Synthetic authority unavailable")
+ with monkeypatch.context() as failure:
+ failure.setattr(local_authority, "read_canonical_todos_if_promoted", unavailable)
+ with pytest.raises(HTTPError) as stopped:
+ urlopen(url + "?goal_id=research")
+ assert stopped.value.code == 503
+ assert report_review_inventory(**review_goal)[0]["status"] == "open"
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
diff --git a/tests/control_plane/test_todo_summary_projection.py b/tests/control_plane/test_todo_summary_projection.py
index c53c2b0bb2..f68f5da8bc 100644
--- a/tests/control_plane/test_todo_summary_projection.py
+++ b/tests/control_plane/test_todo_summary_projection.py
@@ -31,6 +31,33 @@ def test_user_request_creator_survives_summary_and_compact_workspace_projection(
assert "created_by" not in project_asset_todo_summary(legacy, role="user")["items"][0]
+def test_project_asset_keeps_full_source_capability_actions_beyond_item_caps():
+ from loopx.control_plane.todos.todo_summary import project_asset_todo_summary
+
+ actions = {
+ "schema_version": "todo_capability_actions_v0",
+ "total_count": 7,
+ "groups": [{
+ "action_kind": "review_periodic_report",
+ "count": 7,
+ "latest_at": "2026-11-06T00:00:00Z",
+ "items": [{"todo_id": "todo_report_7", "text": "Edition 7", "role": "user",
+ "task_class": "user_action", "action_kind": "review_periodic_report",
+ "capability_binding_ref": "periodic-report-review:r" + "a" * 64}],
+ }],
+ }
+ summary = compact_todo_group(
+ [row(1, role="user", task_class="user_action")],
+ role="user",
+ source_section="User Todo",
+ item_limit=1,
+ )
+ summary["capability_actions"] = actions
+ compact = project_asset_todo_summary(summary, role="user", item_limit=1)
+ assert compact["capability_actions"] == actions
+ assert len(compact["items"]) == 1
+
+
def test_summary_caps_do_not_change_work_counts_or_hide_a_peer():
items = [row(i, claimed_by="agent-a" if i < 24 else "agent-b") for i in range(32)]
result = summarize(items, item_limit=1)
diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts
index 90b2d1c931..e46f3ac106 100644
--- a/tests/control_plane_ts/content_digest_single_owner.test.ts
+++ b/tests/control_plane_ts/content_digest_single_owner.test.ts
@@ -153,6 +153,7 @@ const CANONICAL_CONSUMERS = [
"control_plane/governed_capability.ts",
"control_plane/presentation/action_review_plan.ts",
"control_plane/presentation/goal_source_links.ts",
+ "control_plane/presentation/report_review.ts",
"control_plane/quota/refresh_external_delivery.ts",
"control_plane/quota/turn_envelope.ts",
"control_plane/runtime/checkpoint_artifacts.ts",
diff --git a/tests/control_plane_ts/report_review.test.ts b/tests/control_plane_ts/report_review.test.ts
new file mode 100644
index 0000000000..b15b5f04d0
--- /dev/null
+++ b/tests/control_plane_ts/report_review.test.ts
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {planReportReview, projectReportReviews} from "../../loopx/control_plane/presentation/report_review.ts";
+import {reportReviewDigest} from "../../loopx/control_plane/presentation/report_review_binding.ts";
+
+const input = {goal_id: "research", agent_id: "researcher", bundle_sha256: "a".repeat(64), title: "研究审阅材料"};
+test("review identity belongs to the exact edition, Goal and producer", () => {
+ const original = planReportReview(input);
+ assert.deepEqual(planReportReview({...input}), original);
+ for (const change of [{goal_id: "other"}, {agent_id: "peer"}, {bundle_sha256: "b".repeat(64)}])
+ assert.notEqual(planReportReview({...input, ...change}).operation_id, original.operation_id);
+ assert.equal(original.task_class, "user_action");
+ assert.equal(original.blocks_agent, undefined);
+ assert.equal(original.global_gate, undefined);
+ assert.ok(String(planReportReview({...input, goal_id: "g".repeat(150), agent_id: "a".repeat(80)}).operation_id).length < 128);
+});
+test("only the registered binding selects review work; wording and done counts do not", () => {
+ const row = {...planReportReview(input), todo_id: "todo_review", status: "open", goal_id: input.goal_id};
+ assert.equal(reportReviewDigest(row), input.bundle_sha256);
+ for (const change of [{role: "agent"}, {task_class: "user_gate"}, {action_kind: "approve"}, {capability_binding_ref: "periodic-report-review:r../../other"}])
+ assert.equal(reportReviewDigest({...row, ...change}), null);
+ const projected = projectReportReviews({goal_id: input.goal_id, todos: [row,
+ {todo_id: "todo_not_a_result", text: "Please review and accept this report", status: "done"}]});
+ assert.equal((projected.items as unknown[]).length, 1);
+ assert.equal((projectReportReviews({goal_id: input.goal_id, todos: [{...row, status: "done"}]}).items as typeof row[])[0].status, "done");
+ assert.throws(() => projectReportReviews({goal_id: "foreign", todos: [row]}));
+ assert.throws(() => projectReportReviews({goal_id: input.goal_id}));
+});
+
+test("an exact producer's queued edition is replayed without interpreting a human decision", () => {
+ const row = {...planReportReview(input), todo_id: "todo_original", created_by: input.agent_id,
+ bound_agent: "new-response-owner", status: "done"};
+ assert.equal(planReportReview({...input, todos: [row]}).existing_todo_id, row.todo_id);
+ assert.equal(planReportReview({...input, agent_id: "peer", todos: [row]}).existing_todo_id, null);
+ assert.throws(() => planReportReview({...input, todos: [row, {...row, todo_id: "todo_duplicate"}]}), /Ambiguous/);
+});
+
+test("the existing 200-code-point title limit counts Unicode code points, not UTF-16 units", () => {
+ for (const unit of ["a", "研", "🧪"]) {
+ assert.equal(planReportReview({...input, title: unit.repeat(200)}).text, unit.repeat(200));
+ assert.throws(() => planReportReview({...input, title: unit.repeat(201)}), /title exceeds/);
+ }
+});
diff --git a/tests/control_plane_ts/todo_authoring_scope.test.ts b/tests/control_plane_ts/todo_authoring_scope.test.ts
index 54d29249e8..f925f32742 100644
--- a/tests/control_plane_ts/todo_authoring_scope.test.ts
+++ b/tests/control_plane_ts/todo_authoring_scope.test.ts
@@ -12,6 +12,17 @@ function plan(intent: JsonObject, overrides: JsonObject = {}): JsonObject {
registered_agents: ["agent-a", "agent-b"], intent, ...overrides});
}
+test("a human action can carry a capability binding without gaining gate scope", () => {
+ const action = {task_class: "user_action", actor_agent_id: "agent-a", capability_binding_ref: "report:edition-a"};
+ const scope = plan(action);
+ assert.equal(scope.bound_agent, "agent-a");
+ assert.equal(scope.blocks_agent, null);
+ assert.equal(scope.global_gate, null);
+ assert.throws(() => plan({...action, task_class: "user_gate"}), /capability_binding_ref/);
+ for (const field of ["task_repository", "task_domain"])
+ assert.throws(() => plan({...action, [field]: "example"}), /only valid for agent/);
+});
+
test("global blocking is never inferred from actor, goal binding, or missing scope", () => {
for (const actor of [undefined, "agent-a"]) for (const goal of [false, true]) {
const intent: JsonObject = {task_class: "user_gate", goal_bound: goal};
diff --git a/tests/control_plane_ts/todo_read.test.ts b/tests/control_plane_ts/todo_read.test.ts
index 2d1a2b8a06..a3c71e15e9 100644
--- a/tests/control_plane_ts/todo_read.test.ts
+++ b/tests/control_plane_ts/todo_read.test.ts
@@ -57,6 +57,38 @@ test("current acceptance checks survive full-source reads and selection without
assert.deepEqual((summaries.agent_todos as JsonObject).goal_acceptance_contract, acceptance);
});
+test("user summaries keep capability action counts and bounded titles beyond Todo display caps", async () => {
+ const reviews = Array.from({length: 5}, (_, index) => row(`todo_report_${index}`, {
+ index,
+ role: "user",
+ source_section: "User Todo",
+ task_class: "user_action",
+ action_kind: "review_periodic_report",
+ capability_binding_ref: `periodic-report-review:r${String(index).repeat(64)}`,
+ text: `Report edition ${index}`,
+ created_by: "report-agent",
+ updated_at: `2026-11-0${index + 1}T00:00:00Z`,
+ }));
+ const result = await group([...reviews,
+ row("todo_unbound_action", {index: 6, role: "user", task_class: "user_action", action_kind: "review_periodic_report"}),
+ row("todo_done_review", {index: 7, role: "user", task_class: "user_action", status: "done", done: true,
+ action_kind: "review_periodic_report", capability_binding_ref: `periodic-report-review:r${"f".repeat(64)}`}),
+ ], {role: "user", source_section: "User Todo", item_limit: 1});
+ const summary = result.summary as JsonObject;
+ assert.equal((summary.items as unknown[]).length, 1);
+ const actions = summary.capability_actions as JsonObject;
+ assert.equal(actions.schema_version, "todo_capability_actions_v0");
+ assert.equal(actions.total_count, 5);
+ const [reportGroup] = actions.groups as JsonObject[];
+ assert.equal(reportGroup!.action_kind, "review_periodic_report");
+ assert.equal(reportGroup!.count, 5);
+ assert.deepEqual((reportGroup!.items as JsonObject[]).map(item => item.todo_id),
+ ["todo_report_4", "todo_report_3", "todo_report_2"]);
+ assert.ok((reportGroup!.items as JsonObject[]).every(item =>
+ item.role === "user" && item.task_class === "user_action" &&
+ String(item.capability_binding_ref).startsWith("periodic-report-review:r")));
+});
+
for (const provider of ["file", "sqlite"] as const) {
test(`${provider} full read keeps archived dependencies and fresh provider results without Markdown`, async t => {
const root = await mkdtemp(join(tmpdir(), "loopx-todo-read-"));
diff --git a/tests/test_native_steward_reports.py b/tests/test_native_steward_reports.py
index 85df255170..9bca4a26c3 100644
--- a/tests/test_native_steward_reports.py
+++ b/tests/test_native_steward_reports.py
@@ -22,7 +22,7 @@
steward = steward_fixture
-def configured_local_report(steward, tmp_path, monkeypatch, provider="sqlite", start_date="2001-01-01"):
+def configured_local_report(steward, tmp_path, monkeypatch, provider="sqlite", start_date="2001-01-01", *, review_required=False):
"""An ordinary local Goal/Agent Session, with no report-enabled private binding."""
from loopx.agent_registry import load_goal_from_registry
from loopx.configure_goal import configure_goal
@@ -46,11 +46,62 @@ def configured_local_report(steward, tmp_path, monkeypatch, provider="sqlite", s
"timezone": "UTC", "rrule": "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "start_date": start_date}
config = {"enabled": True, "profile_preset": "weekly-progress", "route_ref": "managed-session:" + session["session_id"],
"timezone": "UTC", "schedule": schedule}
+ if review_required:
+ config["review_required"] = True
assert configure_goal(registry_path=runtime.registry_path, goal_id=actor["goal_id"],
periodic_report_configuration=config, execute=True)["ok"]
return session, ManagedSessionReports(runtime), config, task["todo_id"]
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+@pytest.mark.parametrize("lost_after_commit", [False, True])
+def test_review_ready_recovers_after_queue_failure_without_repeating_original_model(steward, tmp_path, monkeypatch, provider, lost_after_commit):
+ from loopx.capabilities.periodic_report import review
+ from loopx.chat_store import ChatSessionStore
+ from loopx.chat_runtime import ChatRuntimeController
+ from loopx.configure_goal import configure_goal
+ session, reports, config, _ = configured_local_report(steward, tmp_path, monkeypatch, provider, review_required=True)
+ store, runtime, bot, *_ = steward
+ # The existing calendar can start preparation before the user's review time.
+ # A synthetic clock/run proves dispatch mechanics, not a natural deadline SLA.
+ config["timezone"] = config["schedule"]["timezone"] = "Asia/Shanghai"
+ config["schedule"]["rrule"] = "FREQ=DAILY;BYHOUR=8;BYMINUTE=30"
+ assert configure_goal(registry_path=runtime.registry_path, goal_id=session["goal_id"],
+ periodic_report_configuration=config, execute=True)["ok"]
+ sid = session["session_id"]
+ install_model(steward, tmp_path, sid)
+ runtime.project_contexts.conversation_bindings = None
+ original_request = review.request_report_review
+ def interrupted_request(**kwargs):
+ if lost_after_commit:
+ original_request(**kwargs)
+ raise OSError("Synthetic review queue response lost")
+ with monkeypatch.context() as failure:
+ failure.setattr(review, "request_report_review", interrupted_request)
+ first = finish(runtime, pump_steward_monitors(runtime)[0])
+ assert first["status"] == "failed"
+ window = read_cadence_journal(runtime_root=reports.root, goal_id=session["goal_id"])["window"]
+ folder = attempt_directory(reports.root, window)
+ body = (folder / "report.md").read_text()
+ assert not any(row["text"] == body for row in store.messages(sid))
+ calls, writes = steward[5].read_bytes(), list(bot.writes)
+ runtime.close()
+ restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), codex_bin=runtime.codex_bin,
+ registry_path=runtime.registry_path, project_contexts=runtime.project_contexts)
+ try:
+ assert not pump_steward_monitors(restarted)
+ scope = dict(registry_path=runtime.registry_path, runtime_root=reports.root, goal_id=session["goal_id"])
+ rows = review.report_review_inventory(**scope)
+ assert len(rows) == 1 and rows[0]["status"] == "open"
+ readback = review.read_report_review(**scope, todo_id=rows[0]["todo_id"])
+ assert readback["generation_id"] == validate_private_generation(folder)["generation_id"]
+ assert readback["source_session_id"] == sid
+ assert len([row for row in store.messages(sid) if row["text"] == body]) == 1
+ assert steward[5].read_bytes() == calls and bot.writes == writes
+ finally:
+ restarted.close()
+
+
@pytest.mark.parametrize("provider", ["file", "sqlite"])
def test_local_report_rejects_source_substitution_before_original_model(steward, tmp_path, monkeypatch, provider):
from loopx.capabilities.periodic_report.pending_intent import _canonical_digest