diff --git a/apps/presentation/dashboard/smoke/action-review-plan-smoke.ts b/apps/presentation/dashboard/smoke/action-review-plan-smoke.ts index 6e6164426f..4aba00ff8b 100644 --- a/apps/presentation/dashboard/smoke/action-review-plan-smoke.ts +++ b/apps/presentation/dashboard/smoke/action-review-plan-smoke.ts @@ -66,10 +66,11 @@ check(isStaleActionFailure({ error_code: "action_conflict" }), "Typed conflicts check(isStaleActionFailure({ proposal: { status: "stale" } }), "Typed stale proposal survives error wrapping"); check(!isStaleActionFailure({ error_code: "canonical_action_failed", error: "conflict with unrelated external service" }), "Error wording cannot classify source state"); -for (const [action_kind, operation] of [["todo.update", "complete"], ["monitor.update", "stop"]] as const) { +for (const [action_kind, operation] of [["todo.update", "complete"], ["monitor.update", "stop"], ["gate.resolve", "approve"]] as const) { for (const status of ["applying", "failed"] as const) { + const parameters = action_kind === "gate.resolve" ? {decision: operation} : {operation}; const terminal = typedActionProposalSchema.parse({...proposal, action_kind, status, - normalized_parameters: {goal_id: "sample-goal", todo_id: "todo_work", operation}, + normalized_parameters: {goal_id: "sample-goal", todo_id: "todo_work", ...parameters}, canonical_update_basis: {schema_version: "loopx_chat_canonical_terminal_basis_v0", provider_revision: "revision-1", registry_sha256: "a".repeat(64), source_authority: "file_v0"}, failure: {error_code: "canonical_update_projection_pending", message: "Display pending", retry_safe: true}}); @@ -77,13 +78,36 @@ for (const [action_kind, operation] of [["todo.update", "complete"], ["monitor.u check(plan.canApply && plan.retryOriginal === true, "Terminal recovery retries the original proposal"); check(plan.reason === "canonical_update_projection_pending", "Pending display is distinct from failed business mutation"); check(compileActionReviewPlan({...terminal, status: "stale"}).canApply === false, "A stale terminal preview must be regenerated"); - check(compileActionReviewPlan({...terminal, normalized_parameters: {...terminal.normalized_parameters, operation: "edit"}}).canApply === false, + check(compileActionReviewPlan({...terminal, normalized_parameters: {...terminal.normalized_parameters, + ...(action_kind === "gate.resolve" ? {decision: "defer"} : {operation: "edit"})}}).canApply === false, "A terminal review basis cannot enable retries of unrelated operations"); check(compileActionReviewPlan({...terminal, status: "applied", receipt: {projection_verified: true}}).interaction === "completed", "Only current display readback completes terminal presentation"); } } +{ + const decision = typedActionProposalSchema.parse({...proposal, action_kind: "gate.resolve", status: "preview_ready", + normalized_parameters: {goal_id: "sample-goal", todo_id: "todo_gate", decision: "approve"}}); + const applied = (receipt: Record) => compileActionReviewPlan({...decision, status: "applied", receipt}).decisionFrame; + const verified = {projection_verified: true, outcome: "gate_resolved", decision_outcome: "approve"}; + check(JSON.stringify(compileActionReviewPlan(decision).decisionFrame) === JSON.stringify({decision: "approve"}), + "A decision preview names the decision and claims no effect yet"); + check(applied({...verified, unblock_resume_state: "resumed"})?.dependentEffect === "resumed", "A verified resume is reported"); + check(applied({...verified, unblock_resume_state: "decision_requirements_remaining"})?.dependentEffect === "still_waiting", + "Remaining requirements are not reported as resumed work"); + check(applied({...verified, decision_outcome: "reject", unblock_resume_state: "resumed"})?.dependentEffect === "unknown", + "A receipt for another decision cannot confirm this one"); + check(applied({...verified, unblock_resume_state: "a_future_state"})?.dependentEffect === "unknown", "Unrecognized effects stay unknown"); + check(applied({...verified})?.dependentEffect === "unknown", "A receipt without the effect fact makes no claim"); + check(applied({...verified, unblock_resume_state: null})?.dependentEffect === "unknown", "An absent resume receipt does not prove no waiting work"); + check(applied({...verified, unblock_resume_state: "target_or_decision_scope_not_found"})?.dependentEffect === "unknown", "A missing decision scope does not prove no waiting work"); + check(applied({...verified, projection_verified: false, unblock_resume_state: "resumed"})?.dependentEffect === undefined, + "An unverified readback reports no effect"); + check(compileActionReviewPlan({...decision, normalized_parameters: {...decision.normalized_parameters, decision: "defer"}}).decisionFrame === undefined + && compileActionReviewPlan(proposal).decisionFrame === undefined, "Only recorded decision outcomes compile a decision frame"); +} + const operationProposal = typedActionProposalSchema.parse({ ...proposal, proposal_id: "operation-1", 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 ba6b8b03b6..6a2df68d79 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts @@ -78,3 +78,9 @@ export function canReviewAttention(item: WorkspaceAttention): boolean { // not an authorization grant; only known inactive or missing rows are fenced. return !["closed", "deferred", "superseded", "unavailable"].includes(item.details?.lifecycle ?? "unknown"); } + +/** Approve/reject/withdraw exist only for a User gate Todo; the owner still validates each preview. */ +export function canDecideAttention(item: WorkspaceAttention): boolean { + return canReviewAttention(item) && item.details?.interaction === "decision" + && item.decisionSource !== "run_operator_gate"; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 5eec5251cb..f6917d0f3c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1,6 +1,7 @@ import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; import { AttentionDetailCard } from "./attention-detail-card"; -import { attentionSuccessor, canReviewAttention } from "./attention-details"; +import type {DecisionOutcome} from "../../../../../../loopx/control_plane/todos/user_completion_types.js"; +import { attentionSuccessor, canDecideAttention, canReviewAttention } from "./attention-details"; import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowLeft, @@ -81,11 +82,6 @@ const todoTransitions = [ { key: "drawer.taskSuccessor", operation: "successor_create" }, ] as const satisfies readonly { key: "drawer.taskBlock" | "drawer.taskSuccessor"; operation: TodoOperation }[]; -const decisionTransitions = [ - { key: "drawer.decisionReject", resolution: "reject" }, - { key: "drawer.decisionDefer", resolution: "defer" }, -] as const; - const subagentChildLimits = Array.from({ length: 32 }, (_, index) => index + 1); const subagentDomainPattern = /^[a-z][a-z0-9_.-]{0,63}$/u; @@ -291,6 +287,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const operationUnknown = selection.kind === "proposal" && selection.item.reviewPlan?.operationFrame?.kind === "result" && selection.item.reviewPlan.operationFrame.resultKind === "unknown"; + const decisionFrame = selection.kind === "proposal" ? selection.item.reviewPlan?.decisionFrame : undefined; const title = selection.kind === "attention" ? t("drawer.titleAttention") : selection.kind === "todo" ? t("drawer.taskDetails") : selection.kind === "run" ? t("drawer.runDetails") @@ -399,18 +396,20 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention }); } - async function previewDecision(attention: WorkspaceAttention, decision: "approve" | typeof decisionTransitions[number]["resolution"], label: string) { - if (readOnly || !canReviewAttention(attention)) return; + async function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) { + if (readOnly || !canDecideAttention(attention)) return; await callbacks.onPreviewAction?.({ actionKind: "gate.resolve", context: { goal_id: attention.goalId, kind: "todo", todo_id: attention.todoId }, idempotencyKey: `workspace-decision-${attention.todoId}-${decision}-${Date.now().toString(36)}`, normalizedParameters: { + // Hard-lease Goals attribute the decision to the Agent it unblocks. + agent_id: attention.details?.blocksAgent ?? todoAgentId, goal_id: attention.goalId, decision, todo_id: attention.todoId, }, - summary: `${label}:${attention.text}`, + summary: attention.text, }); } @@ -606,17 +605,21 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.kind === "attention" ? ( <> - {!readOnly && canReviewAttention(selection.item) ? <> - + {!readOnly && canDecideAttention(selection.item) ? <> +
+ + +
{t("drawer.decisionMore")}
- {decisionTransitions.map((transition) => ( - - ))} +
+ : !readOnly && canReviewAttention(selection.item) ? <> +

{t(selection.item.decisionSource === "run_operator_gate" ? "drawer.decisionRunGate" : "drawer.decisionNotGate")}

+ : null} ) : null} @@ -1110,10 +1113,11 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.actionKind === "team.plan" && selection.item.status === "applied" ? :
{selection.item.reviewPlan?.operationFrame?.kind === "pending" ? t(`proposal.kind.${selection.item.actionKind}`) - : `${selection.item.actionKind} · ${selection.item.status}`} + : `${t(`proposal.kind.${selection.item.actionKind}`)} · ${t(`proposal.status.${selection.item.status}`)}`} + {decisionFrame ? {t(`proposal.decision.${decisionFrame.decision}`)} : null}

{selection.item.title}

{selection.item.impact ?

{selection.item.impact}

: null} - {selection.item.reviewPlan && !selection.item.reviewPlan.retryOriginal && selection.item.actionKind !== "team.plan" ?

{operationUnknown + {selection.item.reviewPlan && !selection.item.reviewPlan.retryOriginal && selection.item.actionKind !== "team.plan" && !(decisionFrame && ["ready", "applied"].includes(selection.item.status)) ?

{operationUnknown ? t("actionReview.operation_reconcile_original") : selection.item.reviewPlan.operationFrame?.kind === "inactive" ? t(`actionReview.${selection.item.reviewPlan.operationFrame.reason}`) @@ -1124,10 +1128,11 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : selection.item.actionKind === "operation.execute" && selection.item.reviewPlan.reason === "readback_unverified" ? t("actionReview.operation_result_delivery_pending") : t(`actionReview.${selection.item.reviewPlan.reason}`)}

: null} - {selection.item.status === "ready" && selection.item.actionKind !== "team.plan" ?

{t("drawer.proposalExplainer")}

: null} -
{selection.item.fields.map((field) =>
{field.label}
{field.value}
)}
+ {selection.item.status === "ready" && selection.item.actionKind !== "team.plan" && !decisionFrame ?

{t("drawer.proposalExplainer")}

: null} + {selection.item.fields.length ?
{selection.item.fields.map((field) =>
{field.label}
{field.value}
)}
: null}
} - {selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ?

{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.proposalApplied")}

: null} + {selection.item.status === "applied" && decisionFrame ?

{t(`proposal.applied.gate.${decisionFrame.decision}`)}{decisionFrame.dependentEffect ? {t(`proposal.dependentEffect.${decisionFrame.dependentEffect}`)} : null}

+ : selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ?

{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.proposalApplied")}

: null} {selection.item.status === "applied" && selection.item.actionKind !== "operation.execute" && selection.item.goalId ? : null} {selection.item.status === "stale" ?

{t("drawer.proposalStale")}

: null} {selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ?
{operationUnknown ? t("proposal.operationState.submission_unknown") : selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}{selection.item.errorMessage ? {selection.item.errorMessage} : null}{t(operationUnknown ? "actionReview.operation_reconcile_original" : selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryHint" : "drawer.proposalApplyFailedHint")}
: null} @@ -1136,24 +1141,12 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention && selection.item.reviewPlan.operationFrame.resultKind === "cancelled" ?

{t("proposal.primary.operationCancelled")}

: null} {selection.item.status === "deferred" ?

{t("drawer.proposalDeferred")}

: null} - {selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} - {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ? (() => { - const fieldValue = (key: string) => selection.item.fields.find((field) => field.key === key)?.value; - const gateGoalId = fieldValue("goal_id"); - const gateTodoId = fieldValue("todo_id"); - if (!gateGoalId || !gateTodoId) return null; - return ( -
- {t("drawer.gateApproveHint")} - loopx todo complete --goal-id {gateGoalId} --todo-id {gateTodoId} --decision-outcome approve - {t("drawer.gateRejectHint")} -
- ); - })() : null} + {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ?
{t("drawer.gateDecisionRecheckTitle")}{t("drawer.gateDecisionRecheck")}
+ : selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} {!readOnly && selection.item.workspaceCandidates?.length ?
{selection.item.workspaceCandidates.map((candidate) => )}
: null} - {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(selection.item.actionKind === "team.plan" && selection.item.status === "applied") ? : null} + {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? : null} {!readOnly && selection.item.actionKind !== "operation.execute" && (["stale", "gated", "rejected"].includes(selection.item.status) || (selection.item.status === "ready" && selection.item.reviewPlan?.canApply === false)) ? : null} - {!readOnly && selection.item.actionKind !== "operation.execute" && ["ready", "gated"].includes(selection.item.status) ?
: null} + {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ?
: null} {!["applied", "applying"].includes(selection.item.status) ? : null} ) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 88d5791f1f..e8069593d8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -179,13 +179,18 @@ const en = { "attentionDetail.scope": "Declared decision scope", "attentionDetail.replacement": "Replacement Todo", "attentionDetail.openReplacement": "Open replacement", - "attentionDetail.boundary": "Reading this detail does not resolve a gate or grant authority. Available decisions require a fresh preview.", - "drawer.decisionDefaultEvidence": "No additional public-safe evidence is attached. The next step will still show a Preview first.", + "attentionDetail.boundary": "Reading records no decision and grants nothing. Every decision asks you to confirm once before it is saved.", + "drawer.decisionDefaultEvidence": "No evidence is attached to this request.", "drawer.decisionDefaultReason": "This decision affects the next step of the current Todo.", - "drawer.decisionDefer": "Decide later", "drawer.decisionMore": "More decisions", "drawer.decisionReject": "Reject", - "drawer.decisionReview": "Review impact and decide", + "drawer.decisionApprove": "Approve", + "drawer.decisionCancel": "Withdraw this request", + "drawer.decisionGroup": "Your decision", + "drawer.decisionNotGate": "This request asks for a reply or an action, not an approval. Ask the responsible Agent in the conversation.", + "drawer.decisionRunGate": "This run is paused for an operator decision outside a Todo. Ask the responsible Agent in the conversation.", + "drawer.gateDecisionRecheckTitle": "This preview is out of date", + "drawer.gateDecisionRecheck": "It was prepared before decisions could be recorded here. Recheck it, then decide directly in this panel.", "drawer.dependencies": "Dependencies", "drawer.detailsAndActions": "Details & actions", "drawer.duration": "Duration 24h / 7d", @@ -194,8 +199,6 @@ const en = { "drawer.executionRecord": "Run record", "drawer.executionRecordAndResult": "Execution & result", "drawer.explainDecision": "Explain this decision", - "drawer.gateApproveHint": "Run one command in the terminal to complete approval:", - "drawer.gateRejectHint": "Replace approve with reject at the end to decline. This notice disappears after the command runs.", "drawer.gateRequiresHost": "Host confirmation required", "drawer.gateRequiresHostDescription": "This page cannot approve this protected permission change. Nothing was written by your click.", "drawer.goalAutoRun": "Automatic runs for this Goal", @@ -747,6 +750,23 @@ const en = { "proposal.impact.operationConsumed": "The authorization has been consumed. Wait for original external evidence; a retry or lost response must not grant another submission.", "proposal.impact.operationUnknown": "Submission may have had an external effect. Reconcile the original operation using its evidence; do not resubmit or treat card delivery as execution completion.", "proposal.primary.apply": "Confirm and apply", + "proposal.decision.approve": "Approve", + "proposal.decision.reject": "Reject", + "proposal.decision.cancel": "Withdraw", + "proposal.impact.gate.approve": "LoopX records your approval. Work waiting on this request continues unless other confirmations are still open.", + "proposal.impact.gate.reject": "LoopX records your rejection. Work waiting on this request stays paused, and the responsible Agent sees the decision.", + "proposal.impact.gate.cancel": "The request closes without approving or rejecting it. Work waiting on it stays paused.", + "proposal.primary.gate.approve": "Confirm approval", + "proposal.primary.gate.reject": "Confirm rejection", + "proposal.primary.gate.cancel": "Confirm withdrawal", + "proposal.applied.gate.approve": "Approved.", + "proposal.applied.gate.reject": "Rejected.", + "proposal.applied.gate.cancel": "Withdrawn.", + "proposal.dependentEffect.resumed": "The work waiting on it has resumed.", + "proposal.dependentEffect.still_waiting": "The work is still waiting on other confirmations.", + "proposal.dependentEffect.paused": "The work waiting on it stays paused.", + "proposal.dependentEffect.no_waiting_work": "No paused work was waiting on this request.", + "proposal.dependentEffect.unknown": "The decision is recorded; refresh to see its effect on the waiting work.", "proposal.primary.goalCreate": "Create Goal and start first run", "proposal.primary.lifecycleDelete": "Delete Goal", "proposal.primary.lifecycleResume": "Resume Goal", @@ -1403,13 +1423,18 @@ const zhCN: Record = { "attentionDetail.scope": "声明的决策范围", "attentionDetail.replacement": "替代 Todo", "attentionDetail.openReplacement": "打开替代事项", - "attentionDetail.boundary": "阅读详情不会关闭 gate 或授予权限;作出决定前仍需新的操作预览。", - "drawer.decisionDefaultEvidence": "当前状态没有附加公开安全证据;下一步仍会先展示 Preview。", + "attentionDetail.boundary": "阅读不会做出决定,也不授予任何权限;每个决定保存前都会再请你确认一次。", + "drawer.decisionDefaultEvidence": "这项请求没有附带证据。", "drawer.decisionDefaultReason": "该决定会影响当前 Todo 的下一步执行。", - "drawer.decisionDefer": "稍后决定", "drawer.decisionMore": "更多决定", "drawer.decisionReject": "拒绝", - "drawer.decisionReview": "查看影响并决定", + "drawer.decisionApprove": "批准", + "drawer.decisionCancel": "撤回这项请求", + "drawer.decisionGroup": "你的决定", + "drawer.decisionNotGate": "这项事项需要你回复或处理,而不是批准。可以在对话里问负责的 Agent。", + "drawer.decisionRunGate": "这次运行在等一个不属于任何 Todo 的操作员决定。可以在对话里问负责的 Agent。", + "drawer.gateDecisionRecheckTitle": "这份预览已过时", + "drawer.gateDecisionRecheck": "它生成于还不能在这里记录决定的时候。重新检查后,就能直接在这个面板里决定。", "drawer.dependencies": "依赖", "drawer.detailsAndActions": "详情与操作", "drawer.duration": "运行时长 24h / 7d", @@ -1418,8 +1443,6 @@ const zhCN: Record = { "drawer.executionRecord": "运行记录", "drawer.executionRecordAndResult": "执行过程与结果", "drawer.explainDecision": "解释此决定", - "drawer.gateApproveHint": "在终端执行一条命令即可完成审批:", - "drawer.gateRejectHint": "不同意就把末尾的 approve 换成 reject。执行后这条提醒会自动消失。", "drawer.gateRequiresHost": "需要宿主确认", "drawer.gateRequiresHostDescription": "页面无权直接批准这类权限变更,你的点击没有写入任何内容。", "drawer.goalAutoRun": "当前 Goal 的自动运行", @@ -1971,6 +1994,23 @@ const zhCN: Record = { "proposal.impact.operationConsumed": "授权已消费。等待原始外部证据;重试或响应丢失均不得重新授予提交许可。", "proposal.impact.operationUnknown": "提交可能已产生外部副作用。须以原始证据核对原操作,不可重提,也不能把卡片投递当作执行完成。", "proposal.primary.apply": "确认并应用", + "proposal.decision.approve": "批准", + "proposal.decision.reject": "拒绝", + "proposal.decision.cancel": "撤回", + "proposal.impact.gate.approve": "LoopX 会记录你的批准。等待这项请求的工作,如果没有其他待确认项,会直接继续。", + "proposal.impact.gate.reject": "LoopX 会记录你的拒绝。等待这项请求的工作保持暂停,负责的 Agent 会看到这个决定。", + "proposal.impact.gate.cancel": "这项请求会关闭,不算批准也不算拒绝。等待它的工作保持暂停。", + "proposal.primary.gate.approve": "确认批准", + "proposal.primary.gate.reject": "确认拒绝", + "proposal.primary.gate.cancel": "确认撤回", + "proposal.applied.gate.approve": "已批准。", + "proposal.applied.gate.reject": "已拒绝。", + "proposal.applied.gate.cancel": "已撤回。", + "proposal.dependentEffect.resumed": "等待它的工作已恢复。", + "proposal.dependentEffect.still_waiting": "这项工作还在等其他确认。", + "proposal.dependentEffect.paused": "等待它的工作保持暂停。", + "proposal.dependentEffect.no_waiting_work": "没有暂停中的工作在等这项请求。", + "proposal.dependentEffect.unknown": "决定已记录;刷新后可查看它对等待中工作的影响。", "proposal.primary.goalCreate": "创建 Goal 并开始首轮", "proposal.primary.lifecycleDelete": "删除 Goal", "proposal.primary.lifecycleResume": "恢复 Goal", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 33f345d741..a9f1ead2b4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -53,10 +53,11 @@ assert.match(drawer, /actionKind: "todo\.create"/, "Todo successor uses the cano for (const field of ["evidence", "explanation"]) { assert.match(model, new RegExp(`${field}\\??:`), `Decision exposes ${field}`); } -for (const decision of ["reject", "defer"]) { - assert.match(drawer, new RegExp(`resolution:\\s*"${decision}"`), `Decision previews ${decision}`); +for (const decision of ["approve", "reject", "cancel"]) { + assert.match(drawer, new RegExp(`previewDecision\\(selection\\.item, "${decision}"`), `Decision ${decision} uses a typed preview`); } -assert.match(drawer, /previewDecision\(selection\.item, "approve"/, "Decision approval uses a typed preview"); +assert.doesNotMatch(drawer, /previewDecision\([^)]*"defer"/, "Deferring records no decision, so the drawer does not offer it as one"); +assert.match(drawer, /canDecideAttention\(attention\)/, "Only a typed User gate can be decided from the drawer"); for (const callback of ["onRetryResumeRun", "onStartNewRunSession", "onCloseRunSession"]) { assert.match(model, new RegExp(`${callback}\\??:`), `Run exposes ${callback}`); @@ -191,7 +192,7 @@ assert.match(tasks, /disabled=\{quickCompletingTodoIds\?\.has\(todo\.todoId\)\}/ assert.match(page, /callbacks\.onGoalActivationStateChange\?\.\(lifecycleChange\.goalId, lifecycleChange\.next\)/, "Goal lifecycle apply projects the requested state before the server responds"); assert.match(page, /model\.goals\.find\(\(goal\) => goal\.goalId === proposal\.goalId\)\?\.activationState/, "Goal lifecycle rollback captures the rendered state instead of assuming the operation inverse"); assert.match(page, /callbacks\.onGoalActivationStateChange\?\.\(lifecycleChange\.goalId, lifecycleChange\.previous\)/, "Rejected Goal lifecycle apply rolls back the optimistic projection"); -assert.match(page, /if \(applied\.actionKind === "goal\.lifecycle"\) \{\s*void reconcileStatus\(applied\.goalId \? \[applied\.goalId\] : undefined\)/, "Successful Goal lifecycle apply reconciles the affected Goal without blocking the sidebar"); +assert.match(page, /if \(applied\.actionKind === "goal\.lifecycle" \|\| applied\.actionKind === "gate\.resolve"\) \{\s*void reconcileStatus\(applied\.goalId \? \[applied\.goalId\] : undefined\)/, "Successful Goal lifecycle or decision apply reconciles the affected Goal without blocking the sidebar"); assert.match(dashboard, /onReconcileStatus=\{\(options\) => loadFromUrl\([\s\S]*\{ background: true, invalidateGoalIds: options\?\.invalidateGoalIds, readScope: "missing" \}/, "Lifecycle reconciliation uses the non-fatal background status path"); assert.match(dashboard, /statusRequestCanCommit\(statusRequestFenceRef\.current, request\)/, "A stale background response cannot overwrite a newer optimistic transition"); assert.match(sidebar, /Trash2/, "Stopped Goals expose a delete icon"); @@ -293,7 +294,7 @@ assert.doesNotMatch(header, /切换到野兽主题|切换到默认主题/, "Work assert.match(workspaceTheme, /workspaceThemeStorageKey = "loopx-pw-theme"/, "Theme preference persists across reloads"); assert.doesNotMatch(dashboard, /isManagerProjectionQuestion/, "Ordinary manager questions do not silently bypass the selected model by matching phrases"); assert.match(dashboard, /if \(selectedRoute\.agentId === "status-only" \|\| \(!targetGoal && targetContextId !== "manager"\)\)/, "Projection answers require the explicit status-only route or a missing Goal fallback"); -assert.match(drawer, /t\("drawer\.decisionReview"\)/, "Blocked items preview their decision boundary before any write"); +assert.match(drawer, /role="group" aria-label=\{t\("drawer\.decisionGroup"\)\}/, "Blocked items expose their decisions as one labelled group that previews before any write"); assert.match(drawer, /const hasProjectedRunActivity = selection\.kind === "run"[\s\S]*selection\.item\.completedSteps > 0/, "Session empty-state copy distinguishes projected progress from a truly idle run"); assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started"); assert.match(drawer, /t\("drawer\.runRecordEmpty"\)/, "A truly empty Session still explains why there is no timeline yet"); 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 384d18c897..d1805d5b55 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 @@ -141,6 +141,8 @@ export type WorkspaceGoal = { export type WorkspaceAttention = { details?: AttentionDetails; + /** A run-level operator gate has no User Todo to record a decision on. */ + decisionSource?: "todo" | "run_operator_gate"; sourceId?: string; blocking: boolean; evidence?: string | null; 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 ed002bf011..a85a75ed57 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 @@ -635,6 +635,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? proposal.normalized_parameters.target : ""; const operationFrame = reviewPlan.operationFrame; + const decision = reviewPlan.decisionFrame?.decision; const operationTitle = operationFrame?.content.title ?? proposal.summary; const localizedSummary = proposal.action_kind === "operation.execute" ? operationTitle @@ -665,6 +666,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? operationProposalFields(proposal, reviewPlan, t) : proposal.action_kind === "team.plan" ? teamPlanFields(proposal.normalized_parameters, t) + : decision ? [] : proposalFields(proposal.normalized_parameters, t), goalId: typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : undefined, impact: reviewPlan.retryOriginal ? t(`actionReview.${reviewPlan.reason}`) : proposal.action_kind === "operation.execute" @@ -682,6 +684,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? t("proposal.impact.operationDeliveryPending") : t("proposal.impact.operation") : proposal.action_kind === "team.plan" ? proposal.status === "applied" ? t("proposal.teamPlan.assignedHint") : t("proposal.impact.teamPlan") + : decision ? proposal.status === "applied" ? "" : t(`proposal.impact.gate.${decision}`) : proposal.action_kind === "goal.create" ? t("proposal.impact.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" @@ -722,6 +725,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) : operationFrame?.kind === "confirmation" && operationFrame.confirmationDeliveryVerified ? t("proposal.primary.operationGroup") : t("proposal.primary.operationDeliveryPending") : proposal.action_kind === "team.plan" ? t(proposal.status === "applied" ? "proposal.teamPlan.viewResult" : "proposal.primary.teamPlan") + : decision ? t(`proposal.primary.gate.${decision}`) : proposal.action_kind === "goal.create" ? t("proposal.primary.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" ? t("proposal.primary.lifecycleStop") @@ -1504,7 +1508,7 @@ export function PersonalWorkspacePage({ if (applied.actionKind === "goal.lifecycle" && applied.lifecycleOperation === "delete" && applied.goalId) { callbacks.onGoalDeleted?.(applied.goalId); } - if (applied.actionKind === "goal.lifecycle") { + if (applied.actionKind === "goal.lifecycle" || applied.actionKind === "gate.resolve") { void reconcileStatus(applied.goalId ? [applied.goalId] : undefined); } } catch (error) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 932db8a6d2..1ac7ccd71c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1233,6 +1233,12 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-danger-action:hover { background: var(--pw-red-bg); } .personal-primary-action:disabled, .personal-secondary-action:disabled, .personal-danger-action:disabled { opacity: .46; cursor: default; box-shadow: none; } .personal-drawer-action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.personal-decision-bar { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 8px; } +.personal-decision-bar > button { margin: 0; } +.personal-decision-chip { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--pw-line-strong); font-size: 11.5px; font-weight: 650; } +.personal-decision-chip.is-approve { border-color: transparent; background: var(--pw-green-bg); color: var(--pw-green); } +.personal-decision-chip.is-reject { border-color: transparent; background: var(--pw-red-bg); color: var(--pw-red); } +.personal-proposal-state.is-applied > span { display: grid; gap: 2px; } .personal-correction-panel { margin-top: 12px; } .personal-correction-panel header { display: flex; justify-content: space-between; align-items: center; } .personal-correction-panel header span { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; } @@ -1813,9 +1819,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-proposal-state.is-error:has(small) { display: grid; gap: 4px; align-items: start; } .personal-proposal-state.is-error small { line-height: 1.5; word-break: break-all; } -.personal-gate-cli-hint { display: grid; gap: 6px; margin-top: 10px; } -.personal-gate-cli-hint code { display: block; padding: 8px 10px; border: 1px solid var(--pw-line); border-radius: 7px; background: var(--pw-bg, #f7f6f2); font-size: 11.5px; line-height: 1.5; user-select: all; word-break: break-all; } -.personal-gate-cli-hint small { color: var(--pw-muted); line-height: 1.5; } .personal-notification-list { display: grid; gap: 0; margin: 12px 0 0; padding: 0; list-style: none; } .personal-notification-row { display: grid; gap: 8px; padding: 12px 0; border-top: 1px solid var(--pw-line); } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index aa3b32c140..443af5fb54 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -493,6 +493,7 @@ type PersonalGoalItem = { type PersonalNeedsYouItem = { details?: AttentionDetails; + decisionSource?: "todo" | "run_operator_gate"; actionKind?: string | null; blocking: boolean; goalId: string; @@ -1032,6 +1033,7 @@ function buildPersonalHomeModel( details: attentionDetails({ task_class: "user_gate", status: "open", note: row.latestRun?.operator_gate?.reason_summary }), actionKind: "gate.resolve", blocking: true, + decisionSource: "run_operator_gate", goalId: row.goal.id, sourceOrder: payload.attention_queue.items.length + rowOrder, taskClass: "user_gate", diff --git a/demo/workspace/README.md b/demo/workspace/README.md index e88c328c88..5500161526 100644 --- a/demo/workspace/README.md +++ b/demo/workspace/README.md @@ -36,7 +36,7 @@ python -m demo.workspace advance --root /tmp/workspace-stories \ --story research-brief --decision assumptions ``` -Refresh the UI afterward. Only that decision and its direct blocked successors are advanced. Other owner decisions and downstream blockers remain intact. The command does not purchase, publish, deploy or start an Agent. +Refresh the UI afterward. Only that decision and its direct blocked successor are advanced through the canonical User completion owner. The App's Approve, Reject and Withdraw controls use the same decision relationship; reject and withdraw leave the dependent blocked. Other owner decisions and downstream blockers remain intact. The command does not purchase, publish, deploy or start an Agent. Use a fresh directory after updating from demo manifest v2; existing replays are preserved rather than migrated. ## Data and isolation diff --git a/demo/workspace/__main__.py b/demo/workspace/__main__.py index c778186d78..9964da9a91 100644 --- a/demo/workspace/__main__.py +++ b/demo/workspace/__main__.py @@ -172,6 +172,27 @@ def seed_story(root: Path, story: dict, notice: str) -> dict: "agent": decision["agent"], } todos = seed_delivery_tasks(story, gates, registry, runtime) + # The App and CLI replay share the canonical User completion relationship. + # Do not teach the demo a second writer that opens the dependent afterward. + for key, gate in gates.items(): + targets = [todo for todo in todos if todo.get("after") == "gate:" + key] + if len(targets) != 1: + raise ValueError("Each demo decision must have one direct dependent") + target = targets[0] + scope = {"schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "action", "scope_key": f"{story['id']}:{key}"} + checked(update_goal_todo( + registry_path=registry, runtime_root_arg=str(runtime), + goal_id=story["id"], todo_id=gate["todo_id"], + unblocks_todo_id=target["todo_id"], decision_scope=scope, + agent_id=gate["agent"], reason="Bind the demo decision to its dependent.", + )) + checked(update_goal_todo( + registry_path=registry, runtime_root_arg=str(runtime), + goal_id=story["id"], todo_id=target["todo_id"], + required_decision_scopes=[scope], agent_id=target["agent"], + reason="Wait for the demo owner decision.", + )) monitors = [] for owner, title, cadence, target in story["monitors"]: monitor = checked( @@ -227,7 +248,7 @@ def prepare(root: Path) -> dict: marker = root / MARKER if marker.exists(): manifest = json.loads(marker.read_text()) - if manifest.get("schema_version") != "workspace_story_demo_v2": + if manifest.get("schema_version") != "workspace_story_demo_v3": raise ValueError("Unrecognized demo manifest") if manifest.get("root") != str(root) or manifest.get("registry") != str( root / REGISTRY_NAME @@ -244,7 +265,7 @@ def prepare(root: Path) -> dict: registry = root / REGISTRY_NAME catalog = json.loads((HERE / "stories.json").read_text()) manifest = { - "schema_version": "workspace_story_demo_v2", + "schema_version": "workspace_story_demo_v3", "notice": catalog["notice"], "root": str(root), "registry": str(registry), @@ -279,19 +300,6 @@ def advance( no_followup=True, ) ) - for todo in story["todos"]: - if todo["status"] == "blocked" and todo.get("after") == "gate:" + decision_key: - checked( - update_goal_todo( - registry_path=registry, - runtime_root_arg=str(root / "runtime"), - goal_id=story_id, - todo_id=todo["todo_id"], - status="open", - agent_id=todo["agent"], - reason="Decision replay resolved this local blocker.", - ) - ) print( json.dumps( { diff --git a/examples/dashboard-attention-details-browser-smoke.mjs b/examples/dashboard-attention-details-browser-smoke.mjs index 8dec49b17b..12026f0344 100644 --- a/examples/dashboard-attention-details-browser-smoke.mjs +++ b/examples/dashboard-attention-details-browser-smoke.mjs @@ -70,7 +70,7 @@ try { const original = { index: 1, todo_id: "todo_original", role: "user", task_class: "user_gate", done: state === "superseded", status: state === "superseded" ? "done" : "open", - title: "Release review", text: requestBody, note: reason, + title: "Release review", text: requestBody, note: reason, updated_at: "2026-08-13T00:30:00Z", evidence, blocks_agent: "worker-one", unblocks_todo_id: "todo_target", decision_scope: { schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "route-one" }, ...(state === "superseded" ? { superseded_by: "todo_replacement" } : {}), @@ -116,11 +116,13 @@ try { const more = drawer.locator(".personal-compact-menu > summary"); await more.focus(); await more.press("Tab"); assert.ok(await page.locator(".personal-drawer-close").evaluate(element => element === document.activeElement), "Closed menus do not strand keyboard focus"); - await drawer.getByRole("button", { name: /查看影响并决定|Review impact and decide/ }).click(); + await drawer.getByRole("button", { name: /^(批准|Approve)$/ }).click(); await page.locator('[data-context-kind="proposal"]').waitFor({ state: "visible" }); assert.equal(preview.action_kind, "gate.resolve"); assert.equal(preview.normalized_parameters.goal_id, "loopx-meta"); assert.equal(preview.normalized_parameters.todo_id, "todo_original"); + assert.equal(preview.normalized_parameters.decision, "approve"); + assert.equal(preview.normalized_parameters.agent_id, "worker-one", "The decision is recorded for the Agent the request blocks"); await page.locator(".personal-drawer-close").press("Escape"); await entry.click(); state = "unsafe"; diff --git a/examples/loopx-chat-actions-smoke.py b/examples/loopx-chat-actions-smoke.py index b80b43dd62..45a45fdeae 100644 --- a/examples/loopx-chat-actions-smoke.py +++ b/examples/loopx-chat-actions-smoke.py @@ -960,16 +960,30 @@ def assert_http_action_api(root: Path) -> None: ) assert code == 201, transition_preview - for index, (action_kind, params) in enumerate( + # A decision outcome is only valid on a User gate; the canonical owner + # rejects it before any proposal is stored. + code, misdirected_decision = request_json( + f"{base_url}/api/actions/preview", + method="POST", + body={ + "action_kind": "gate.resolve", + "summary": "Approve an Agent Todo", + "normalized_parameters": {"goal_id": "goal-one", "todo_id": current_todo_id, "decision": "approve"}, + "context": {"kind": "goal", "goal_id": "goal-one"}, + "idempotency_key": "http-misdirected-decision", + }, + ) + assert code == 400, misdirected_decision + assert "decision_outcome is only valid" in misdirected_decision["error"], misdirected_decision + + for index, (action_kind, params, gate_kind) in enumerate( [ - ("goal.update", {"goal_id": "goal-one", "objective": "A revised objective"}), - ( - "gate.resolve", - {"goal_id": "goal-one", "todo_id": current_todo_id, "decision": "approve"}, - ), + ("goal.update", {"goal_id": "goal-one", "objective": "A revised objective"}, + "canonical_authority_required"), ( "gate.resolve", {"goal_id": "goal-one", "todo_id": current_todo_id, "decision": "defer"}, + "decision_outcome_required", ), ] ): @@ -991,7 +1005,7 @@ def assert_http_action_api(root: Path) -> None: body={}, ) assert code == 409, protected_gate - assert protected_gate["gate"]["kind"] == "canonical_authority_required", protected_gate + assert protected_gate["gate"]["kind"] == gate_kind, protected_gate persisted_payload = action_store.path.read_text(encoding="utf-8") assert str(root) not in persisted_payload, persisted_payload diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 9dcd613b86..629ac7021c 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -453,6 +453,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true }, operatorCredentialWrites: [], turnRequests: [], + decidedGateTodoIds: new Set(), hostThreadActivity: {}, answerForMessage: null, loopxModeRequests: [], @@ -531,9 +532,10 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const first = fixture.attention_queue?.items?.[0]; if (first) { first.waiting_on = "user_or_controller"; + const gateDecided = state.decidedGateTodoIds.has("todo-browser-user-gate"); first.user_todos = { - items: [{ done: false, goal_id: first.goal_id, index: 0, role: "user", text: "确认本轮独立审查范围", todo_id: "todo-browser-user-gate" }], - open_count: 1, + items: [{ done: gateDecided, status: gateDecided ? "done" : "open", goal_id: first.goal_id, index: 0, role: "user", task_class: "user_gate", blocks_agent: "codex", text: "确认本轮独立审查范围", todo_id: "todo-browser-user-gate" }], + open_count: gateDecided ? 0 : 1, source_section: "User Todo", total_count: 1, }; @@ -1869,11 +1871,17 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true // previews, so the plan a confirmed card carries has to be read there. const teamPlanReceipt = teamPlanApplyReceipt(actionProposals.get(apply[1])); if (teamPlanReceipt && replay) teamPlanReceipt.outcome = "team_plan_commit_recovered"; + // Mirrors ChatActionService's gate.resolve receipt (tests/control_plane/test_chat_gate_decisions.py). + const decisionParameters = actionKind === "gate.resolve" ? preview?.normalized_parameters : null; + if (decisionParameters) state.decidedGateTodoIds.add(decisionParameters.todo_id); + const decisionReceipt = decisionParameters ? { projection_verified: true, receipt_id: "fixture-receipt", outcome: "gate_resolved", + decision_outcome: decisionParameters.decision, + unblock_resume_state: { approve: "resumed", reject: "decision_rejected", cancel: "decision_cancelled" }[decisionParameters.decision] ?? null } : null; const proposal = { schema_version: "loopx_chat_action_proposal_v1", proposal_id: apply[1], action_kind: actionKind, summary: "已应用", normalized_parameters: preview?.normalized_parameters ?? actionProposals.get(apply[1])?.normalized_parameters ?? {}, context: preview?.context ?? actionProposals.get(apply[1])?.context ?? {}, expected_state_fingerprint: "fixture-r1", permission_classification: "durable_write", validation_evidence: [], available_transitions: ["apply", "cancel"], - status: "applied", receipt: teamPlanReceipt ?? { projection_verified: true, receipt_id: "fixture-receipt" }, stale: null, created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:01Z", + status: "applied", receipt: teamPlanReceipt ?? decisionReceipt ?? { projection_verified: true, receipt_id: "fixture-receipt" }, stale: null, created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:01Z", }; actionProposals.set(apply[1], proposal); if (actionKind === "team.plan" && state.loseNextTeamPlanResponse) { diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 175ccf4113..880f78e529 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1912,14 +1912,23 @@ export const typedActionsScenario = { await page.locator(".personal-object-list").first().getByRole("button").first().click(); await page.getByText("需要你", { exact: true }).last().waitFor({ state: "visible" }); await page.getByText("更多决定").click(); - await page.getByRole("button", { name: "稍后决定", exact: true }).click(); - await page.getByText("确认执行").waitFor({ state: "visible" }); - const deferredDecision = api.actionPreviews.find((preview) => preview.action_kind === "gate.resolve" && preview.normalized_parameters.decision === "defer"); - if (!deferredDecision) throw new Error("Decision defer did not create a Gate preview"); - await page.getByRole("button", { name: "稍后", exact: true }).click(); - await page.getByText(/已暂缓/).waitFor({ state: "visible" }); - if (!api.actionTransitions.some((transition) => transition.transition === "defer")) throw new Error("Proposal defer transition was not sent"); - await page.getByRole("button", { name: "关闭", exact: true }).click(); + const writesBeforeDecision = api.durableWriteCount; + await page.getByRole("button", { name: "撤回这项请求", exact: true }).click(); + await page.getByText("确认执行", { exact: true }).waitFor({ state: "visible" }); + const cancelledDecision = api.actionPreviews.find((preview) => preview.action_kind === "gate.resolve" && preview.normalized_parameters.decision === "cancel"); + if (!cancelledDecision) throw new Error("Withdrawing a request did not preview its canonical cancel decision"); + if (api.durableWriteCount !== writesBeforeDecision) throw new Error("Decision preview wrote before owner confirmation"); + const review = page.getByRole("dialog"); + if (await review.getByRole("button", { name: "稍后", exact: true }).count()) throw new Error("A decision review still exposes proposal defer"); + if (await review.getByRole("button", { name: "拒绝", exact: true }).count()) throw new Error("A decision review still exposes proposal reject"); + if (/loopx todo complete|todo_[a-z0-9]+/u.test(await review.innerText())) throw new Error("Decision review requires raw protocol or CLI information"); + await review.getByRole("button", { name: "确认撤回", exact: true }).click(); + await review.getByText("已撤回。", { exact: true }).waitFor({ state: "visible" }); + if (api.durableWriteCount !== writesBeforeDecision + 1) throw new Error("A confirmed decision must write exactly once"); + if (!api.actionApplies.includes(cancelledDecision.proposalId)) throw new Error("Confirm did not apply the previewed decision"); + if (await review.getByRole("button", { name: "确认撤回", exact: true }).count()) throw new Error("Applied decision still offers confirm"); + await review.getByRole("button", { name: "查看更新后的 Goal", exact: true }).click(); + await review.waitFor({ state: "hidden" }); await page.locator(".personal-manager-link").first().click(); const sourceGoalCard = page.locator(".personal-home-goal-card").first(); await sourceGoalCard.click(); diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 948108a3c4..3246f725d4 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -24,6 +24,8 @@ ) from .control_plane.runtime.time import now_utc, parse_timestamp from .control_plane.scheduler.monitor_todo import monitor_next_due_at +from .control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable +from .control_plane.todos.contract import TODO_DECISION_OUTCOME_VALUES from .history import load_registry from .host_loop_activation import build_host_loop_activation_packet from .kiro_cli_goal_mode import KIRO_CLI_CHAT_AGENT_ID @@ -1176,6 +1178,22 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]: else "The monitor execution request is bound to the current Goal state." ] permission = "durable_write" + elif action_kind == "gate.resolve" and normalized["decision"] in TODO_DECISION_OUTCOME_VALUES: + canonical_update_basis = self._canonical_update_basis( + normalized["goal_id"], completion_todo_id=normalized["todo_id"], decision=True) + try: + canonical_preview = self._run_gate_resolve( + normalized, dry_run=True, basis=canonical_update_basis) + except LocalCoordinationAuthorityUnavailable as error: + raise ValueError(str(error)) from error + if canonical_preview.get("ok") is not True: + raise ValueError(str(canonical_preview.get("error") + or "Decision failed canonical dry-run validation")) + fingerprint = (_digest({"goal_id": normalized["goal_id"], + "canonical_update_basis": canonical_update_basis}) if canonical_update_basis is not None + else self._goal_state_fingerprint(normalized["goal_id"])) + evidence = ["Canonical LoopX User completion dry-run validated this decision."] + permission = "durable_write" elif action_kind == "agent.bind": binding = read_goal_agent_binding_with_source_route( registry_path=self.registry_path, @@ -1320,11 +1338,21 @@ def apply(self, proposal_id: str) -> dict[str, Any]: return self._apply_monitor_create(proposal_id, proposal, parameters) if action_kind == "team.plan": return self._apply_team_plan(proposal_id, proposal, parameters) - if action_kind == "todo.update": + if action_kind == "todo.update" or ( + action_kind == "gate.resolve" and parameters.get("decision") in TODO_DECISION_OUTCOME_VALUES): return self._apply_todo_update(proposal_id, proposal, parameters) if action_kind == "monitor.update": return self._apply_monitor_update(proposal_id, proposal, parameters) - if action_kind in {"goal.update", "gate.resolve"}: + if action_kind == "gate.resolve": + raise ProtectedActionGate( + action_kind, + gate={ + "kind": "decision_outcome_required", + "summary": "Deferring records no decision; the request stays open.", + "next_action": "Approve, reject or cancel the request when ready.", + }, + ) + if action_kind == "goal.update": raise ProtectedActionGate( action_kind, gate={ diff --git a/loopx/chat_todo_actions.py b/loopx/chat_todo_actions.py index abe7c46a58..c61b3bbf7e 100644 --- a/loopx/chat_todo_actions.py +++ b/loopx/chat_todo_actions.py @@ -12,6 +12,15 @@ from .control_plane.todos.provider_projection import projection_delivery_requires_ack +def _decision_readback(result: dict[str, Any]) -> dict[str, Any]: + """Carry the canonical outcome and dependent effect, never a local guess.""" + resume = result.get("unblock_resume") + return { + "decision_outcome": result.get("decision_outcome"), + "unblock_resume_state": resume.get("state") if isinstance(resume, dict) else None, + } + + class ChatTodoActionMixin: """Keep Todo preview/apply parity separate from general orchestration.""" @@ -70,6 +79,26 @@ def _run_todo_update( **self._reviewed_update_options(basis, operation_id), ) + def _run_gate_resolve( + self, parameters: dict[str, Any], *, dry_run: bool, + basis: dict[str, Any] | None = None, operation_id: str | None = None, + ) -> dict[str, Any]: + # The typed User completion owner decides whether this source may carry + # the outcome and what it does to the dependent; the App only records it. + return complete_goal_todo( + registry_path=self.registry_path, + goal_id=str(parameters["goal_id"]), + todo_id=str(parameters["todo_id"]), + role="user", + decision_outcome=str(parameters["decision"]), + evidence=parameters.get("note"), + no_followup=True, + agent_id=parameters.get("agent_id"), + authority_reason="owner-confirmed typed Chat decision", + dry_run=dry_run, + **self._reviewed_terminal_options(basis, operation_id), + ) + @staticmethod def _reviewed_terminal_options( basis: dict[str, Any] | None, operation_id: str | None, @@ -98,6 +127,7 @@ def _reviewed_update_options( def _canonical_update_basis( self, goal_id: str, *, completion_todo_id: str | None = None, + decision: bool = False, ) -> dict[str, Any] | None: registry_sha256 = self._registry_fingerprint() authority = read_canonical_todos_if_promoted( @@ -109,8 +139,9 @@ def _canonical_update_basis( if authority is None: return None # User updates retain their combined edit/completion contract. Agent - # completion and Monitor stop bind the dedicated terminal transaction. - terminal = completion_todo_id is not None and not any( + # completion, Monitor stop and a User decision outcome bind the + # dedicated terminal transaction, the only owner of decision_outcome. + terminal = decision or completion_todo_id is not None and not any( todo.get("todo_id") == completion_todo_id and todo.get("role") == "user" for todo in authority["todos"]) return { @@ -133,11 +164,14 @@ def _apply_reviewed_todo_edit( operation_id = f"chat-update:{proposal_id}" basis = proposal["canonical_update_basis"] - run = self._run_monitor_update if proposal["action_kind"] == "monitor.update" else self._run_todo_update + action_kind = proposal["action_kind"] + run = {"monitor.update": self._run_monitor_update, + "gate.resolve": self._run_gate_resolve}.get(action_kind, self._run_todo_update) + terminal = action_kind == "gate.resolve" or parameters.get("operation") in {"complete", "stop"} try: result = run(parameters, dry_run=False, basis=basis, operation_id=operation_id) except LocalCoordinationAuthorityUnavailable as error: - if (parameters.get("operation") in {"complete", "stop"} and error.code == "authority_source_changed" + if (terminal and error.code == "authority_source_changed" and error.payload.get("completion_validation_executed") is True): self.store.mark_failed( proposal_id, error_code="canonical_update_validation_source_changed", @@ -180,8 +214,9 @@ def _apply_reviewed_todo_edit( ) return {"proposal": failed, "turn": None} operation = parameters.get("operation", "edit") - outcome = ({"pause": "monitor_paused", "resume": "monitor_resumed", "edit": "monitor_updated", "stop": "monitor_stopped"}[operation] - if proposal["action_kind"] == "monitor.update" else + outcome = ("gate_resolved" if action_kind == "gate.resolve" else + {"pause": "monitor_paused", "resume": "monitor_resumed", "edit": "monitor_updated", "stop": "monitor_stopped"}[operation] + if action_kind == "monitor.update" else "todo_completed" if operation == "complete" else "todo_updated" if original.get("changed") else "todo_unchanged") stored = self.store.apply(proposal_id, @@ -192,6 +227,7 @@ def _apply_reviewed_todo_edit( "operation_id": operation_id, "canonical_status": result.get("provider_status", result["status"]), "resource_ids": {"goal_id": str(parameters["goal_id"]), "todo_id": todo_id}, + **(_decision_readback(result) if action_kind == "gate.resolve" else {}), }) return {"proposal": stored, "turn": None} @@ -210,19 +246,23 @@ def _apply_todo_update( ) return {"proposal": stale, "turn": None} operation = str(parameters.get("operation") or "edit") - result = self._run_todo_update(parameters, dry_run=False) + decision = proposal.get("action_kind") == "gate.resolve" + result = (self._run_gate_resolve if decision else self._run_todo_update)(parameters, dry_run=False) todo_id = _opaque(result.get("todo_id"), field="todo_id") receipt = { "receipt_id": _digest({"proposal_id": proposal_id, "todo_id": todo_id})[ :32 ], "outcome": ( - "todo_completed" + "gate_resolved" + if decision + else "todo_completed" if operation == "complete" else "todo_updated" if result.get("changed") else "todo_unchanged" ), "projection_verified": True, "resource_ids": {"goal_id": goal_id, "todo_id": todo_id}, + **(_decision_readback(result) if decision else {}), } stored = self.store.apply( proposal_id, current_state_fingerprint=current_fingerprint, receipt=receipt diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index 83f4c48214..1083e63728 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -1,3 +1,5 @@ +import type {DecisionOutcome, ResumeState} from "../todos/user_completion_types.js"; + export type ActionReviewIdentity = { schemaVersion: "action_review_plan_v0"; proposalId: string; @@ -72,9 +74,21 @@ type ActionReviewState = canApply: false; }; +/** An owner decision on one User request, recorded by the canonical User completion owner. */ +export type DecisionReviewFrame = { + decision: DecisionOutcome; + /** + * What the canonical receipt says happened to the work waiting on it. Only + * present after a verified apply; an unrecognized or mismatched receipt is + * `unknown`, never a guessed success. + */ + dependentEffect?: "resumed" | "still_waiting" | "paused" | "no_waiting_work" | "unknown"; +}; + export type ActionReviewPlan = ActionReviewIdentity & ActionReviewState & { operationFrame?: OperationReviewFrame; reviewCardFrame?: ReviewCardFrame; + decisionFrame?: DecisionReviewFrame; /** Recover this exact canonical command; generating a new preview loses its receipt identity. */ retryOriginal?: true; }; @@ -386,6 +400,33 @@ export function compileOperationReviewFrame(proposalValue: unknown, nowMs?: numb }; } +// Presentation buckets over `ResumeState` in control_plane/todos/user_completion.ts. +const DEPENDENT_EFFECTS = { + resumed: "resumed", + decision_requirements_remaining: "still_waiting", + other_user_blockers_active: "still_waiting", + explicit_blocker_repair_required: "still_waiting", + decision_rejected: "paused", + decision_cancelled: "paused", + target_not_blocked: "no_waiting_work", + target_not_active: "no_waiting_work", + target_not_found: "no_waiting_work", + target_or_decision_scope_not_found: "unknown", +} as const satisfies Readonly>>; + +function compileDecisionReviewFrame(proposal: Record): DecisionReviewFrame | undefined { + if (proposal.action_kind !== "gate.resolve") return undefined; + const decision = objectValue(proposal.normalized_parameters)?.decision; + if (decision !== "approve" && decision !== "reject" && decision !== "cancel") return undefined; + const receipt = objectValue(proposal.receipt); + if (proposal.status !== "applied" || receipt?.projection_verified !== true) return { decision }; + if (receipt.outcome !== "gate_resolved" || receipt.decision_outcome !== decision + || !Object.hasOwn(receipt, "unblock_resume_state")) return { decision, dependentEffect: "unknown" }; + const state = receipt.unblock_resume_state; + return { decision, dependentEffect: typeof state === "string" && Object.hasOwn(DEPENDENT_EFFECTS, state) + ? DEPENDENT_EFFECTS[state as keyof typeof DEPENDENT_EFFECTS] : "unknown" }; +} + /** * Compile provider-neutral presentation semantics from a typed action proposal. * This reducer owns no action authority and performs no external effects. @@ -401,11 +442,13 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): }; const operationFrame = compileOperationReviewFrame(proposal, nowMs); const reviewCardFrame = compileReviewCardFrame(proposal); + const decisionFrame = compileDecisionReviewFrame(proposal); const finish = (state: ActionReviewState): ActionReviewPlan => ({ ...identity, ...state, ...(operationFrame ? { operationFrame } : {}), ...(reviewCardFrame ? { reviewCardFrame } : {}), + ...(decisionFrame ? { decisionFrame } : {}), }); const held = ( interaction: "gated" | "refresh" | "repair" | "pending" | "completed" | "inactive", @@ -436,7 +479,8 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): const isCanonicalTerminal = basis?.schema_version === "loopx_chat_canonical_terminal_basis_v0" && textValue(basis.provider_revision) !== null && textValue(basis.registry_sha256) !== null && ((proposal.action_kind === "todo.update" && parameters?.operation === "complete") - || (proposal.action_kind === "monitor.update" && parameters?.operation === "stop")); + || (proposal.action_kind === "monitor.update" && parameters?.operation === "stop") + || (proposal.action_kind === "gate.resolve" && ["approve", "reject", "cancel"].includes(String(parameters?.decision)))); if ((isCanonicalUpdate || isCanonicalTerminal) && (proposal.status === "applying" || proposal.status === "failed")) { const failure = objectValue(proposal.failure); return {...finish({interaction: "review", canApply: true, diff --git a/loopx/control_plane/todos/user_completion.ts b/loopx/control_plane/todos/user_completion.ts index 090cf384d3..95743d9dce 100644 --- a/loopx/control_plane/todos/user_completion.ts +++ b/loopx/control_plane/todos/user_completion.ts @@ -7,11 +7,8 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtime_decode.ts"; import {decisionScopeCovers} from "./decision_scope.ts"; import {normalizeTodoDecisionScope, normalizeTodoRequiredDecisionScopes} from "./decision_metadata.ts"; +import type {DecisionOutcome, ResumeState} from "./user_completion_types.ts"; -type DecisionOutcome = "approve" | "reject" | "cancel"; -type ResumeState = "target_not_found" | "target_or_decision_scope_not_found" | "target_not_active" | - "target_not_blocked" | "explicit_blocker_repair_required" | "other_user_blockers_active" | - "decision_requirements_remaining" | "resumed" | "decision_rejected" | "decision_cancelled"; interface ResumeReceipt extends JsonObject { readonly state: ResumeState; } export interface UserCompletionPlan extends JsonObject { diff --git a/loopx/control_plane/todos/user_completion_types.ts b/loopx/control_plane/todos/user_completion_types.ts new file mode 100644 index 0000000000..0971c2b730 --- /dev/null +++ b/loopx/control_plane/todos/user_completion_types.ts @@ -0,0 +1,5 @@ +/** Shared vocabulary for canonical completion and its effect-free presentation. */ +export type DecisionOutcome = "approve" | "reject" | "cancel"; +export type ResumeState = "target_not_found" | "target_or_decision_scope_not_found" | "target_not_active" | + "target_not_blocked" | "explicit_blocker_repair_required" | "other_user_blockers_active" | + "decision_requirements_remaining" | "resumed" | "decision_rejected" | "decision_cancelled"; diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index ccb3ea6e01..ba3300a8d1 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -375,7 +375,7 @@ }, { "site": "loopx/chat_actions.py::.ChatActionService._registry::codec_read:load_registry#1", - "line": 245, + "line": 247, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_chat_gate_decisions.py b/tests/control_plane/test_chat_gate_decisions.py new file mode 100644 index 0000000000..6f8e57dd11 --- /dev/null +++ b/tests/control_plane/test_chat_gate_decisions.py @@ -0,0 +1,167 @@ +"""Owner decisions recorded from the App use the canonical User completion owner.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from test_todo_decision_scope_lifecycle import ( + AGENT_ID, GOAL_ID, PUBLISH_SCOPE, _add_target_and_gate, _write_fixture, +) +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService, ProtectedActionGate +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.todos import add_goal_todo, list_goal_todos + +RESUME_STATE = {"approve": "resumed", "reject": "decision_rejected", "cancel": "decision_cancelled"} + + +def _goal(tmp_path: Path, monkeypatch, provider: str, handoff_mode: str = "soft_claim"): + isolate_sqlite_runtime(tmp_path, monkeypatch) + _, state, registry = _write_fixture(tmp_path) + config = json.loads(registry.read_text()) + config["common_runtime_root"] = str(tmp_path / "runtime") + registry.write_text(json.dumps(config)) + target, gate = _add_target_and_gate(registry, required_scopes=[PUBLISH_SCOPE], target_status="blocked") + if provider != "legacy": + config["goals"][0]["coordination"]["handoff_mode"] = handoff_mode + registry.write_text(json.dumps(config)) + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode=handoff_mode, + todos=list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"]) + initialize_canonical_authority(tmp_path / "runtime", GOAL_ID, projection, + state_path=state, provider=provider) + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), registry_path=registry) + return registry, service, target["todo_id"], gate["todo_id"] + + +def _preview(service: ChatActionService, todo_id: str, decision: str, key: str = "decide", + agent_id: str | None = AGENT_ID) -> dict: + parameters = {"goal_id": GOAL_ID, "todo_id": todo_id, "decision": decision} + if agent_id: + parameters["agent_id"] = agent_id + return service.preview({"action_kind": "gate.resolve", "summary": "Decide the release request", + "normalized_parameters": parameters, "context": {}, "idempotency_key": f"{key}-{decision}"}) + + +def _rows(registry: Path) -> dict[str, dict]: + return {row["todo_id"]: row for row in list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"]} + + +@pytest.mark.parametrize(("provider", "handoff_mode"), + [("legacy", "soft_claim"), ("file", "hard_lease"), ("sqlite", "soft_claim")]) +@pytest.mark.parametrize("decision", ["approve", "reject", "cancel"]) +def test_app_decision_commits_through_user_completion(tmp_path, monkeypatch, provider, handoff_mode, decision): + registry, service, target_id, gate_id = _goal(tmp_path, monkeypatch, provider, handoff_mode) + proposal = _preview(service, gate_id, decision) + assert proposal["status"] == "preview_ready" + basis = proposal.get("canonical_update_basis") + assert (basis or {}).get("schema_version") == ( + None if provider == "legacy" else "loopx_chat_canonical_terminal_basis_v0") + assert _rows(registry)[gate_id]["status"] != "done", "preview must not write" + + applied = service.apply(proposal["proposal_id"])["proposal"] + assert applied["status"] == "applied", applied + receipt = applied["receipt"] + assert (receipt["outcome"], receipt["decision_outcome"], receipt["unblock_resume_state"]) == ( + "gate_resolved", decision, RESUME_STATE[decision]) + rows = _rows(registry) + assert rows[gate_id]["status"] == "done" + assert rows[target_id]["status"] == ("open" if decision == "approve" else "blocked") + assert rows[target_id]["claimed_by"] == AGENT_ID + assert service.apply(proposal["proposal_id"])["proposal"]["receipt"] == receipt + assert _rows(registry) == rows + + +@pytest.mark.parametrize("provider", ["legacy", "sqlite"]) +def test_changed_goal_turns_decision_stale_without_writing(tmp_path, monkeypatch, provider): + registry, service, target_id, gate_id = _goal(tmp_path, monkeypatch, provider) + proposal = _preview(service, gate_id, "approve") + add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Unrelated concurrent work", claimed_by=AGENT_ID) + result = service.apply(proposal["proposal_id"])["proposal"] + assert result["status"] == "stale" + assert result["receipt"] in (None, {}) + rows = _rows(registry) + assert (rows[gate_id]["status"], rows[target_id]["status"]) == ("open", "blocked") + regenerated = service.regenerate(proposal["proposal_id"]) + assert service.apply(regenerated["proposal_id"])["proposal"]["status"] == "applied" + assert _rows(registry)[target_id]["status"] == "open" + + +def test_decision_preview_rejects_requests_that_carry_no_decision(tmp_path, monkeypatch): + registry, service, target_id, _gate_id = _goal(tmp_path, monkeypatch, "sqlite") + action = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="user", + text="Read the observation", task_class="user_action", bound_agent=AGENT_ID, + unblocks_todo_id=target_id) + with pytest.raises(ValueError, match="decision_outcome is only valid"): + _preview(service, action["todo_id"], "approve") + with pytest.raises(ValueError): + _preview(service, "todo_missing_request", "approve") + assert service.store.list(goal_id=GOAL_ID) == [] + + +def test_hard_lease_decision_requires_an_attributed_agent(tmp_path, monkeypatch): + registry, service, target_id, gate_id = _goal(tmp_path, monkeypatch, "file", "hard_lease") + with pytest.raises(Exception, match="handoff_mode_requires_lease"): + _preview(service, gate_id, "approve", agent_id=None) + with pytest.raises(Exception, match="handoff_mode_requires_lease"): + _preview(service, gate_id, "approve", key="stranger", agent_id="codex-unregistered") + rows = _rows(registry) + assert (rows[gate_id]["status"], rows[target_id]["status"]) == ("open", "blocked") + + +def test_defer_and_pre_upgrade_proposals_never_record_a_decision(tmp_path, monkeypatch): + registry, service, target_id, gate_id = _goal(tmp_path, monkeypatch, "sqlite") + deferred = _preview(service, gate_id, "defer") + with pytest.raises(ProtectedActionGate) as held: + service.apply(deferred["proposal_id"]) + assert held.value.gate["kind"] == "decision_outcome_required" + # A proposal stored before this contract carried no canonical basis. + legacy = service.store.create_preview( + action_kind="gate.resolve", summary="Older decision", context={}, + normalized_parameters={"goal_id": GOAL_ID, "todo_id": gate_id, "decision": "approve"}, + expected_state_fingerprint=service._registry_fingerprint(), permission_classification="durable_write", + validation_evidence=["older"], available_transitions=["apply", "cancel"], idempotency_key="older") + assert service.apply(legacy["proposal_id"])["proposal"]["status"] == "stale" + rows = _rows(registry) + assert (rows[gate_id]["status"], rows[target_id]["status"]) == ("open", "blocked") + + +def test_packaged_chat_http_records_decision(tmp_path, monkeypatch): + from http.client import HTTPConnection + from threading import Thread + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler, default_chat_assets_dir + + registry, service, target_id, gate_id = _goal(tmp_path, monkeypatch, "sqlite") + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.verbose = False + server.assets_dir = default_chat_assets_dir() + server.action_store = service.store + server.action_service = service + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = HTTPConnection("127.0.0.1", server.server_address[1], timeout=45) + headers = {"Content-Type": "application/json"} + try: + body = {"action_kind": "gate.resolve", "summary": "Approve the release", "context": {}, + "idempotency_key": "http-decision", + "normalized_parameters": {"goal_id": GOAL_ID, "todo_id": gate_id, "decision": "approve"}} + connection.request("POST", "/api/actions/preview", body=json.dumps(body), headers=headers) + response = connection.getresponse() + preview = json.loads(response.read()) + assert response.status == 201, preview + proposal_id = preview["proposal"]["proposal_id"] + connection.request("POST", f"/api/actions/{proposal_id}/apply", body="{}", headers=headers) + response = connection.getresponse() + result = json.loads(response.read()) + assert response.status == 200, result + assert result["proposal"]["receipt"]["unblock_resume_state"] == "resumed" + assert _rows(registry)[target_id]["status"] == "open" + finally: + connection.close() + server.shutdown() + thread.join(timeout=5) + server.server_close() diff --git a/tests/test_workspace_story_demo.py b/tests/test_workspace_story_demo.py index 6a5790ab51..9ad7fdd7ef 100644 --- a/tests/test_workspace_story_demo.py +++ b/tests/test_workspace_story_demo.py @@ -53,6 +53,11 @@ def todos(goal_id): assert sum(t["status"] == "deferred" for t in agents) == 2 assert sum(t["status"] == "blocked" for t in agents) == 4 assert len([t for t in rows if t["role"] == "user" and not t["done"]]) == 2 + by_id = {t["todo_id"]: t for t in rows} + for gate in (t for t in rows if t["role"] == "user"): + target = by_id[gate["unblocks_todo_id"]] + assert target["status"] == "blocked" + assert target["required_decision_scopes"] == [gate["decision_scope"]] artifact = json.loads( (root / "projects/community-day/calculations.json").read_text() )