From 40d3a36ab4e4eeaa6f77afcd916e814565107660 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Sat, 25 Jul 2026 13:13:38 +0800 Subject: [PATCH 1/6] refactor(plan): per-session PlanExecuteController store Replace the process-wide singleton in plan-tools.ts with a sessionKey-routed store. Multi-session embedded hosts no longer cross-read each other's active plan. store maintains sessionKey->activePlanId; controller business logic unchanged. Co-Authored-By: Claude --- packages/moss-agent/src/plan-execute/index.ts | 9 ++++ .../src/plan-execute/plan-controller-store.ts | 42 +++++++++++++++ .../moss-agent/src/plan-execute/plan-tools.ts | 38 ++++++------- .../test/plan-controller-store.spec.mjs | 53 +++++++++++++++++++ 4 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 packages/moss-agent/src/plan-execute/plan-controller-store.ts create mode 100644 packages/moss-agent/test/plan-controller-store.spec.mjs diff --git a/packages/moss-agent/src/plan-execute/index.ts b/packages/moss-agent/src/plan-execute/index.ts index 70641f95..e9a63ea3 100644 --- a/packages/moss-agent/src/plan-execute/index.ts +++ b/packages/moss-agent/src/plan-execute/index.ts @@ -36,6 +36,15 @@ export { type PlanStepToolInput, } from './plan-tools.js'; +export { + getPlanController, + getSharedPlanController, + setActivePlanId, + getActivePlanId, + getActivePlanForSession, + resetPlanControllerStoreForTests, +} from './plan-controller-store.js'; + export { buildPlanExecuteSystemPrompt, type PlanExecutePromptOptions, diff --git a/packages/moss-agent/src/plan-execute/plan-controller-store.ts b/packages/moss-agent/src/plan-execute/plan-controller-store.ts new file mode 100644 index 00000000..a2ec892a --- /dev/null +++ b/packages/moss-agent/src/plan-execute/plan-controller-store.ts @@ -0,0 +1,42 @@ +import { PlanExecuteController } from './plan-execute-controller.js'; +import type { Plan } from './plan-execute-controller.js'; + +const DEFAULT_CONFIG = { maxReplans: 3, requireApproval: true, autoApproveSimple: true }; + +const sessionControllers = new Map(); +let sharedController: PlanExecuteController | null = null; +const activePlanIdBySession = new Map(); + +export function getPlanController(sessionKey: string): PlanExecuteController { + let c = sessionControllers.get(sessionKey); + if (!c) { + c = new PlanExecuteController({ ...DEFAULT_CONFIG }); + sessionControllers.set(sessionKey, c); + } + return c; +} + +export function getSharedPlanController(): PlanExecuteController { + if (!sharedController) sharedController = new PlanExecuteController({ ...DEFAULT_CONFIG }); + return sharedController; +} + +export function setActivePlanId(sessionKey: string, planId: string): void { + activePlanIdBySession.set(sessionKey, planId); +} + +export function getActivePlanId(sessionKey: string): string | undefined { + return activePlanIdBySession.get(sessionKey); +} + +export function getActivePlanForSession(sessionKey: string): Plan | null { + const id = activePlanIdBySession.get(sessionKey); + if (!id) return null; + return getPlanController(sessionKey).getPlan(id); +} + +export function resetPlanControllerStoreForTests(): void { + sessionControllers.clear(); + activePlanIdBySession.clear(); + sharedController = null; +} diff --git a/packages/moss-agent/src/plan-execute/plan-tools.ts b/packages/moss-agent/src/plan-execute/plan-tools.ts index ebb7c64b..547228a9 100644 --- a/packages/moss-agent/src/plan-execute/plan-tools.ts +++ b/packages/moss-agent/src/plan-execute/plan-tools.ts @@ -5,6 +5,12 @@ import type { Tool } from '../core/tools/tool-types.js'; import { PlanExecuteController } from './plan-execute-controller.js'; +import { + getPlanController, + getSharedPlanController, + setActivePlanId, + resetPlanControllerStoreForTests, +} from './plan-controller-store.js'; import { errorMessage } from '../errors.js'; import { getCliInteractionMode, @@ -56,20 +62,6 @@ function toolError(prefix: string, err: unknown): Error { return new Error(`${prefix}: ${errorMessage(err)}`); } - -let controllerInstance: PlanExecuteController | null = null; - -function getController(): PlanExecuteController { - if (!controllerInstance) { - controllerInstance = new PlanExecuteController({ - maxReplans: 3, - requireApproval: true, - autoApproveSimple: true, - }); - } - return controllerInstance; -} - /** When a plan is approved/started, leave interactionMode=plan so coding tools can run. */ function leavePlanModeForExecution(): boolean { if (getCliInteractionMode() !== 'plan') return false; @@ -91,13 +83,14 @@ function isAffirmativePlanApproval(answer: string): boolean { * drops the plan-mode mutation gate. Non-interactive runs keep previous behavior. */ async function confirmPlanApprovalIfNeeded( + controller: PlanExecuteController, planId: string, abortSignal?: AbortSignal, ): Promise<'approved' | 'declined' | 'unavailable' | 'skipped'> { if (getCliInteractionMode() !== 'plan') return 'skipped'; const asker = getCliUserQuestionAsker(); if (!asker) return 'unavailable'; - const plan = getController().getPlan(planId); + const plan = controller.getPlan(planId); const goal = plan?.goal ? plan.goal.slice(0, 160) : planId; const steps = plan?.steps?.length ?? 0; const prompt = [ @@ -120,7 +113,7 @@ async function confirmPlanApprovalIfNeeded( export function resetPlanControllerForTests(): void { - controllerInstance = null; + resetPlanControllerStoreForTests(); } @@ -201,7 +194,9 @@ export function createPlanTool(): Tool { }, async execute(input, ctx) { try { - const controller = getController(); + const controller = ctx.sessionKey + ? getPlanController(ctx.sessionKey) + : getSharedPlanController(); switch (input.action) { case 'create': { @@ -264,6 +259,7 @@ export function createPlanTool(): Tool { case 'approve': { if (!input.planId) return 'Error: planId is required for approval.'; const confirmation = await confirmPlanApprovalIfNeeded( + controller, input.planId, ctx.abortSignal, ); @@ -275,6 +271,7 @@ export function createPlanTool(): Tool { } const ok = controller.approvePlan(input.planId); if (!ok) return `Error: could not approve plan ${input.planId}.`; + if (ctx.sessionKey) setActivePlanId(ctx.sessionKey, input.planId); // Claude ExitPlanMode parity (light): approving a plan is the user's // go-ahead to leave read-only planning and begin execution. If the // session is still in interactionMode=plan, drop to default so @@ -295,6 +292,7 @@ export function createPlanTool(): Tool { if (!input.planId) return 'Error: planId is required to start execution.'; const ok = controller.startExecution(input.planId); if (!ok) return `Error: could not start plan ${input.planId}. Ensure it is approved.`; + if (ctx.sessionKey) setActivePlanId(ctx.sessionKey, input.planId); const leftPlanMode = leavePlanModeForExecution(); const plan = controller.getPlan(input.planId); @@ -396,9 +394,11 @@ export function createPlanStepTool(): Tool { }, required: ['planId', 'stepNumber', 'action'], }, - async execute(input, _ctx) { + async execute(input, ctx) { try { - const controller = getController(); + const controller = ctx.sessionKey + ? getPlanController(ctx.sessionKey) + : getSharedPlanController(); switch (input.action) { case 'complete': { diff --git a/packages/moss-agent/test/plan-controller-store.spec.mjs b/packages/moss-agent/test/plan-controller-store.spec.mjs new file mode 100644 index 00000000..22685c8e --- /dev/null +++ b/packages/moss-agent/test/plan-controller-store.spec.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + getPlanController, + getSharedPlanController, + setActivePlanId, + getActivePlanId, + getActivePlanForSession, + resetPlanControllerStoreForTests, +} from '../dist/plan-execute/plan-controller-store.js'; + +// per-session: 不同 sessionKey 拿到不同实例 +{ + resetPlanControllerStoreForTests(); + const a = getPlanController('sess-a'); + const b = getPlanController('sess-b'); + assert.notEqual(a, b, 'different sessionKeys get different controllers'); + assert.equal(getPlanController('sess-a'), a, 'same sessionKey returns same instance'); +} + +// activePlanId 按 session 隔离 —— A 的 gate 查不到 B 的 plan +{ + resetPlanControllerStoreForTests(); + const a = getPlanController('sess-a'); + const b = getPlanController('sess-b'); + const planA = a.createPlan('goal A', [{ step: 1, description: 'do A' }]); + const planB = b.createPlan('goal B', [{ step: 1, description: 'do B' }]); + setActivePlanId('sess-a', planA.id); + setActivePlanId('sess-b', planB.id); + assert.equal(getActivePlanId('sess-a'), planA.id); + assert.equal(getActivePlanId('sess-b'), planB.id); + // A 的 session 只看到 A 的 plan + assert.equal(getActivePlanForSession('sess-a')?.id, planA.id); + assert.equal(getActivePlanForSession('sess-a')?.goal, 'goal A'); + assert.equal(getActivePlanForSession('sess-b')?.id, planB.id); + // 关键:A 查不到 B + assert.notEqual(getActivePlanForSession('sess-a')?.id, planB.id); +} + +// shared fallback: 无 session 兜底共享实例 +{ + resetPlanControllerStoreForTests(); + const s1 = getSharedPlanController(); + const s2 = getSharedPlanController(); + assert.equal(s1, s2, 'shared fallback returns same instance'); +} + +// 未知 session: getActivePlanForSession 返回 null +{ + resetPlanControllerStoreForTests(); + assert.equal(getActivePlanForSession('nope'), null); +} +console.log('plan-controller-store: ok'); From 99507a886d622a9618561d603d6f85c99034a0e1 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Sat, 25 Jul 2026 13:22:20 +0800 Subject: [PATCH 2/6] feat(plan): completion gate rejects premature end_turn with unfinished steps evaluatePlanCompletionGate checks plan step completeness (not just whether plan tools were called this turn like evaluatePlanEvalCompletionGate). Wired into the CLI completion-gate chain and the MossAgent completionGate wrapper. Escape hatch via plan_step skip with reason; fail-open on any fault. Co-Authored-By: Claude --- .../src/cli/coding-completion-gate.ts | 3 + .../moss-agent/src/core/agent/moss-agent.ts | 9 ++ packages/moss-agent/src/plan-execute/index.ts | 7 ++ .../src/plan-execute/plan-completion-gate.ts | 57 ++++++++++++ .../test/plan-completion-gate.spec.mjs | 88 +++++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 packages/moss-agent/src/plan-execute/plan-completion-gate.ts create mode 100644 packages/moss-agent/test/plan-completion-gate.spec.mjs diff --git a/packages/moss-agent/src/cli/coding-completion-gate.ts b/packages/moss-agent/src/cli/coding-completion-gate.ts index 21fd3c53..04d3e7aa 100644 --- a/packages/moss-agent/src/cli/coding-completion-gate.ts +++ b/packages/moss-agent/src/cli/coding-completion-gate.ts @@ -13,6 +13,8 @@ * Soft: low retryLimit, clear coding / multi-step intents only. */ import type { Message } from '../core/session/session-jsonl.js'; +import { evaluatePlanCompletionGate } from '../plan-execute/plan-completion-gate.js'; +import { getActivePlanForSession } from '../plan-execute/plan-controller-store.js'; import { listBackgroundProcessSnapshots } from '../tools/background-exec.js'; import { extractLatestTodosFromMessages, @@ -2746,6 +2748,7 @@ export function createCliCompletionGate( evaluateMemoryCompletionGate(request), evaluateAskUserCompletionGate(request), evaluatePlanEvalCompletionGate(request), + evaluatePlanCompletionGate(request, { getActivePlanForSession }), evaluateBrowserVisionCompletionGate(request), evaluateDeviceCompletionGate(request), evaluateWebToolsCompletionGate(request), diff --git a/packages/moss-agent/src/core/agent/moss-agent.ts b/packages/moss-agent/src/core/agent/moss-agent.ts index 8b588d49..f4bedabc 100644 --- a/packages/moss-agent/src/core/agent/moss-agent.ts +++ b/packages/moss-agent/src/core/agent/moss-agent.ts @@ -89,6 +89,8 @@ import { bumpStructuredValidationAttempt, clearPendingStructuredValidation, } from '../../structured-output/structured-output-tool.js'; +import { evaluatePlanCompletionGate } from '../../plan-execute/plan-completion-gate.js'; +import { getActivePlanForSession } from '../../plan-execute/plan-controller-store.js'; import { createSpawnProfileRegistryFromDefaults, SpawnProfileRegistry, @@ -1494,6 +1496,13 @@ export class MossAgent { if (!pending) { // No structured validation pending — delegate to the user-provided // completion gate (if any). + if (request.sessionKey) { + const planGate = evaluatePlanCompletionGate( + { sessionKey: request.sessionKey, stopReason: request.stopReason }, + { getActivePlanForSession }, + ); + if (!planGate.ok) return planGate; + } if (this.config.completionGate) return this.config.completionGate(request); return { ok: true }; } diff --git a/packages/moss-agent/src/plan-execute/index.ts b/packages/moss-agent/src/plan-execute/index.ts index e9a63ea3..ce69ecc1 100644 --- a/packages/moss-agent/src/plan-execute/index.ts +++ b/packages/moss-agent/src/plan-execute/index.ts @@ -49,3 +49,10 @@ export { buildPlanExecuteSystemPrompt, type PlanExecutePromptOptions, } from './plan-execute-prompt.js'; + +export { + evaluatePlanCompletionGate, + type PlanCompletionGateRequest, + type PlanCompletionGateDeps, + type PlanCompletionGateResult, +} from './plan-completion-gate.js'; diff --git a/packages/moss-agent/src/plan-execute/plan-completion-gate.ts b/packages/moss-agent/src/plan-execute/plan-completion-gate.ts new file mode 100644 index 00000000..84732621 --- /dev/null +++ b/packages/moss-agent/src/plan-execute/plan-completion-gate.ts @@ -0,0 +1,57 @@ +import type { Plan } from './plan-execute-controller.js'; + +export interface PlanCompletionGateRequest { + sessionKey?: string; + stopReason?: string; +} + +export interface PlanCompletionGateDeps { + getActivePlanForSession: (sessionKey: string) => Plan | null; +} + +export type PlanCompletionGateResult = + | { ok: true } + | { ok: false; reason: string; correction: string; retryLimit: number }; + +const RETRY_LIMIT = 2; + +export function evaluatePlanCompletionGate( + request: PlanCompletionGateRequest, + deps: PlanCompletionGateDeps, +): PlanCompletionGateResult { + // 用户中止 → 放行(与 evaluatePlanEvalCompletionGate 一致) + if (request.stopReason === 'aborted_by_user') return { ok: true }; + const sessionKey = request.sessionKey; + if (!sessionKey) return { ok: true }; // 无 session: fail-open + + let plan: Plan | null; + try { + plan = deps.getActivePlanForSession(sessionKey); + } catch { + return { ok: true }; // 故障 fail-open + } + if (!plan) return { ok: true }; + if (plan.status !== 'approved' && plan.status !== 'executing') return { ok: true }; + + const total = plan.steps.length; + const done = plan.steps.filter( + (s) => s.status === 'completed' || s.status === 'skipped', + ).length; + if (done >= total) return { ok: true }; + + const unfinished = plan.steps + .filter((s) => s.status !== 'completed' && s.status !== 'skipped') + .map((s) => `Step ${s.step}: ${s.description}`) + .join('\n'); + + return { + ok: false, + reason: 'plan has unfinished steps', + retryLimit: RETRY_LIMIT, + correction: + `[System] Plan ${plan.id} is ${plan.status} but ${total - done} step(s) remain unfinished:\n` + + `${unfinished}\n` + + `Continue executing the plan, or for each remaining step call plan_step action="skip" with a reason. ` + + `Do not claim the task complete while the plan has unfinished steps.`, + }; +} diff --git a/packages/moss-agent/test/plan-completion-gate.spec.mjs b/packages/moss-agent/test/plan-completion-gate.spec.mjs new file mode 100644 index 00000000..b76b3466 --- /dev/null +++ b/packages/moss-agent/test/plan-completion-gate.spec.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { PlanExecuteController } from '../dist/plan-execute/plan-execute-controller.js'; +import { evaluatePlanCompletionGate } from '../dist/plan-execute/plan-completion-gate.js'; + +// helper: 造一个 controller + plan,返回 {plan, getActive} +function makePlan({ steps, status, completeSteps = [], skipSteps = [] }) { + const c = new PlanExecuteController({ maxReplans: 3, requireApproval: false, autoApproveSimple: false }); + const plan = c.createPlan('goal', steps.map((description, i) => ({ step: i + 1, description }))); + // 把 plan 推到目标状态前,先 approve + start + c.approvePlan(plan.id); + c.startExecution(plan.id); + for (const n of completeSteps) c.completeStep(plan.id, n, 'out'); + for (const n of skipSteps) c.skipStep(plan.id, n, 'reason'); + // 强制设目标 status(若 complete/skip 已自然推到 completed 则跳过) + if (status && plan.status !== status) plan.status = status; + const getActive = () => plan; + return { plan, getActive }; +} + +// Case 1: executing 状态,5 步只完成 2 → 否决 +{ + const { getActive } = makePlan({ + steps: ['s1', 's2', 's3', 's4', 's5'], + status: 'executing', + completeSteps: [1, 2], + }); + const r = evaluatePlanCompletionGate({ sessionKey: 's', stopReason: 'end_turn' }, { getActivePlanForSession: getActive }); + assert.equal(r.ok, false); + assert.match(r.correction, /未完成|not.*complete|plan_step/i); + assert.equal(r.retryLimit, 2); +} + +// Case 2: 全部 complete → 放行 +{ + const { getActive } = makePlan({ + steps: ['s1', 's2'], + status: 'executing', + completeSteps: [1, 2], + }); + const r = evaluatePlanCompletionGate({ sessionKey: 's' }, { getActivePlanForSession: getActive }); + assert.equal(r.ok, true); +} + +// Case 3: escape hatch — 未完成但全 skip 带理由 → 放行 +{ + const { getActive } = makePlan({ + steps: ['s1', 's2', 's3'], + status: 'executing', + skipSteps: [2, 3], // 1 已由 startExecution 设为 in_progress,completeSteps 空 + completeSteps: [], + }); + // startExecution 把 step1 设 in_progress;step1 既没 complete 也没 skip + // 为测"全 skip 放行",把 step1 也 skip + const c = new PlanExecuteController({ maxReplans: 3, requireApproval: false, autoApproveSimple: false }); + const plan = c.createPlan('goal', [{ step: 1, description: 's1' }, { step: 2, description: 's2' }]); + c.approvePlan(plan.id); c.startExecution(plan.id); + c.skipStep(plan.id, 1, 'r1'); c.skipStep(plan.id, 2, 'r2'); + const r = evaluatePlanCompletionGate({ sessionKey: 's' }, { getActivePlanForSession: () => plan }); + assert.equal(r.ok, true, 'all steps skipped with reason -> pass'); +} + +// Case 4: 无 active plan → 放行 +{ + const r = evaluatePlanCompletionGate({ sessionKey: 's' }, { getActivePlanForSession: () => null }); + assert.equal(r.ok, true); +} + +// Case 5: status 不是 approved/executing(如 completed/draft)→ 放行 +{ + const { plan } = makePlan({ steps: ['s1', 's2'], status: 'completed', completeSteps: [1, 2] }); + const r = evaluatePlanCompletionGate({ sessionKey: 's' }, { getActivePlanForSession: () => plan }); + assert.equal(r.ok, true); +} + +// Case 6: 无 sessionKey → fail-open 放行(嵌入式无 session 兜底) +{ + const r = evaluatePlanCompletionGate({}, { getActivePlanForSession: () => null }); + assert.equal(r.ok, true); +} + +// Case 7: getActivePlanForSession 抛错 → fail-open 放行 +{ + const r = evaluatePlanCompletionGate({ sessionKey: 's' }, { getActivePlanForSession: () => { throw new Error('boom'); } }); + assert.equal(r.ok, true); +} + +console.log('plan-completion-gate: ok'); From 48de1f0d93a86eb7a76fa80cca9bbd9fe85356fa Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Sat, 25 Jul 2026 18:38:21 +0800 Subject: [PATCH 3/6] feat(plan): MOSS_PLAN_GATE flag for completion gate (default on) Add MOSS_PLAN_GATE (default on) so the plan completion gate can be disabled (MOSS_PLAN_GATE=off) to take an A/B baseline against the same task set. Mirrors criticEnabled() in plan-critic.ts but defaults the opposite way (critic is experimental off, gate is shipped on). Verified end-to-end: default ON rejects premature end_turn with correction; OFF is a true no-op (no correction). 9 unit cases + integration test pass; typecheck clean. Co-Authored-By: Claude --- docs/env-vars.md | 3 ++ packages/moss-agent/src/plan-execute/index.ts | 1 + .../src/plan-execute/plan-completion-gate.ts | 17 ++++++++++ .../test/plan-completion-gate.spec.mjs | 31 +++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/docs/env-vars.md b/docs/env-vars.md index ef6363de..775ec224 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -60,6 +60,9 @@ All environment variables use the `MOSS_` prefix. Model settings (provider, mode | `MOSS_PROMPT_PREFIX_DEBUG` | — | Alias for `MOSS_PROMPT_CACHE_DEBUG`. | | `MOSS_LLM_FIRST_CHUNK_TIMEOUT_MS` | `0` | Timeout for first LLM stream chunk (0 = disabled, max 3,600,000). | | `MOSS_SELF_LEARNING` | — | Set to `true` to extract user-correction feedback as memory. | +| `MOSS_PLAN_GATE` | `on` | Plan completion gate. Default ON (rejects premature `end_turn` when an approved/executing plan has unfinished steps). Set `off` to disable — used to take an A/B baseline against the same task set. | +| `MOSS_PLAN_VALIDATE` | — | Experimental plan-quality critic (default off). When `on`, critiques plans with `MOSS_PLAN_VALIDATE_MIN_STEPS` (default 5) steps at `plan action=approve`. | +| `MOSS_PLAN_VALIDATE_MIN_STEPS` | `5` | Minimum step count to trigger the `MOSS_PLAN_VALIDATE` critic. | ## MCP (Model Context Protocol) diff --git a/packages/moss-agent/src/plan-execute/index.ts b/packages/moss-agent/src/plan-execute/index.ts index ce69ecc1..2a924465 100644 --- a/packages/moss-agent/src/plan-execute/index.ts +++ b/packages/moss-agent/src/plan-execute/index.ts @@ -52,6 +52,7 @@ export { export { evaluatePlanCompletionGate, + planGateEnabled, type PlanCompletionGateRequest, type PlanCompletionGateDeps, type PlanCompletionGateResult, diff --git a/packages/moss-agent/src/plan-execute/plan-completion-gate.ts b/packages/moss-agent/src/plan-execute/plan-completion-gate.ts index 84732621..b34ca402 100644 --- a/packages/moss-agent/src/plan-execute/plan-completion-gate.ts +++ b/packages/moss-agent/src/plan-execute/plan-completion-gate.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../utils/env-compat.js'; import type { Plan } from './plan-execute-controller.js'; export interface PlanCompletionGateRequest { @@ -15,10 +16,26 @@ export type PlanCompletionGateResult = const RETRY_LIMIT = 2; +/** + * Whether the plan completion gate is active. Default ON (the gate is a + * shipped feature, not an experiment). Set `MOSS_PLAN_GATE=off` to disable — + * the gate becomes a complete no-op, so an A/B baseline can be taken against + * the same task set with the gate off. This mirrors `criticEnabled()` in + * plan-critic.ts but defaults the opposite way (critic defaults off). + */ +export function planGateEnabled(): boolean { + const v = readEnv('MOSS_PLAN_GATE'); + // Explicitly off only; anything else (unset, on, yes, 1) means enabled. + return !(v && /^(off|0|false|no)$/i.test(String(v).trim())); +} + export function evaluatePlanCompletionGate( request: PlanCompletionGateRequest, deps: PlanCompletionGateDeps, ): PlanCompletionGateResult { + // A/B switch: when explicitly disabled, the gate is a no-op (baseline mode). + if (!planGateEnabled()) return { ok: true }; + // 用户中止 → 放行(与 evaluatePlanEvalCompletionGate 一致) if (request.stopReason === 'aborted_by_user') return { ok: true }; const sessionKey = request.sessionKey; diff --git a/packages/moss-agent/test/plan-completion-gate.spec.mjs b/packages/moss-agent/test/plan-completion-gate.spec.mjs index b76b3466..13cef17a 100644 --- a/packages/moss-agent/test/plan-completion-gate.spec.mjs +++ b/packages/moss-agent/test/plan-completion-gate.spec.mjs @@ -85,4 +85,35 @@ function makePlan({ steps, status, completeSteps = [], skipSteps = [] }) { assert.equal(r.ok, true); } +// Case 8: MOSS_PLAN_GATE=off → flag off, even an unfinished executing plan passes +// (gate is a no-op so A/B baseline can be taken). Default is ON. +{ + const c = new PlanExecuteController({ maxReplans: 3, requireApproval: false, autoApproveSimple: false }); + const plan = c.createPlan('goal', ['s1', 's2', 's3']); + c.approvePlan(plan.id); c.startExecution(plan.id); // executing, 0/3 done + const prev = process.env.MOSS_PLAN_GATE; + try { + process.env.MOSS_PLAN_GATE = 'off'; + const r = evaluatePlanCompletionGate({ sessionKey: 's', stopReason: 'end_turn' }, { getActivePlanForSession: () => plan }); + assert.equal(r.ok, true, 'MOSS_PLAN_GATE=off -> unfinished plan passes (baseline mode)'); + } finally { + if (prev === undefined) delete process.env.MOSS_PLAN_GATE; else process.env.MOSS_PLAN_GATE = prev; + } +} + +// Case 9: MOSS_PLAN_GATE unset (default) → gate is ON, unfinished executing plan rejected +{ + const c = new PlanExecuteController({ maxReplans: 3, requireApproval: false, autoApproveSimple: false }); + const plan = c.createPlan('goal', ['s1', 's2']); + c.approvePlan(plan.id); c.startExecution(plan.id); + const prev = process.env.MOSS_PLAN_GATE; + try { + delete process.env.MOSS_PLAN_GATE; + const r = evaluatePlanCompletionGate({ sessionKey: 's', stopReason: 'end_turn' }, { getActivePlanForSession: () => plan }); + assert.equal(r.ok, false, 'MOSS_PLAN_GATE unset (default on) -> unfinished plan rejected'); + } finally { + if (prev !== undefined) process.env.MOSS_PLAN_GATE = prev; + } +} + console.log('plan-completion-gate: ok'); From 0a2e0b26ab845d3d040cd31098be35151e0d3b90 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Sat, 25 Jul 2026 18:16:16 +0800 Subject: [PATCH 4/6] test(plan): integration test for completion gate reject->skip->pass Drives a real MossAgent loop end-to-end with a mock LLM provider: turn1 create -> turn2 approve+start -> turn3 plain end_turn (premature, 0/2 steps) -> gate rejects + injects correction -> turn4 skip step1 -> turn5 skip step2 -> turn6 end_turn passes. Closes the final-review M4 gap (spec-promised integration test was missing). Verified the gate only fires on end_turn with no pending tool_use, and that skipStep is sequential. Co-Authored-By: Claude --- .../test/plan-completion-gate-integ.spec.mjs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 packages/moss-agent/test/plan-completion-gate-integ.spec.mjs diff --git a/packages/moss-agent/test/plan-completion-gate-integ.spec.mjs b/packages/moss-agent/test/plan-completion-gate-integ.spec.mjs new file mode 100644 index 00000000..f1a5548e --- /dev/null +++ b/packages/moss-agent/test/plan-completion-gate-integ.spec.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Integration test: the plan completion gate rejects premature end_turn when + * an approved/executing plan has unfinished steps, the loop continues, and + * after the model skip-completes each remaining step (one per turn, matching + * real tool semantics — skipStep only acts on an in_progress step), the gate + * lets the run end. + * + * Drives a real MossAgent loop end-to-end with a mock LLM provider whose + * per-turn output is controlled by a turn counter. NOT a unit test of the + * pure evaluatePlanCompletionGate function (that's plan-completion-gate.spec.mjs). + * + * Scenario (verify reject -> skip -> pass): + * Turn 1: `plan action=create` (2 steps). -> plan=draft, planId in history. + * Turn 2: `plan action=approve` + `plan action=start` (planId from turn 1). + * -> plan=executing, step 1 in_progress. (tool_use turn, no gate.) + * Turn 3: plain `end_turn` text "Done." with NO tool_use. -> 0/2 done -> + * gate REJECTS -> correction injected -> loop continues. + * (The gate only fires on end_turn with no pending tool calls.) + * Turn 4: `plan_step action=skip` step 1. -> 1/2 (step1 skipped, step2 in_progress) + * Turn 5: `plan_step action=skip` step 2. -> 2/2 -> gate PASSES. + * Turn 6: plain `end_turn` "All handled." -> run ends. + * + * NOTE on tool semantics: `skipStep` advances the plan's current step, so + * step N must be skipped before step N+1 is in_progress. Sending both skips + * in one turn makes the second fail (its step isn't in_progress yet) — hence + * one skip per turn. (This mirrors real model behavior: skip is sequential.) + * + * NOTE on gate trigger: `completionGate` fires only on `end_turn` with no + * pending tool_use in the turn (agent-loop-response.ts:276). A turn that emits + * tool_use is an executing turn — the loop continues without consulting the + * gate. So the premature-completion claim MUST be a plain end_turn turn. + * + * The gate (moss-agent.ts:1500) is installed by default on MossAgent. + */ +import assert from 'node:assert/strict'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { registerBuiltinTools } from '../dist/index.js'; + +const modelDef = { + id: 'plan-gate-model', + name: 'plan-gate-model', + api: 'openai-completions', + provider: 'test', + baseUrl: '', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 1024, +}; + +// Parse the created planId from the conversation history (turn 1's tool_result +// contains "Plan created: "). +function planIdFromMessages(messages) { + const m = JSON.stringify(messages).match(/Plan created:\s*(plan-[0-9]+-[a-z0-9]+)/i); + return m ? m[1] : null; +} + +let turn = 0; +const provider = { + id: 'plan-gate-integ', + capabilities: { streaming: true }, + async complete(options) { + turn += 1; + const planId = planIdFromMessages(options.messages ?? []); + + if (turn === 1) { + return { + stopReason: 'tool_use', + content: [ + { type: 'text', text: 'Making a 2-step plan.' }, + { type: 'tool_use', id: 'c1', name: 'plan', input: { action: 'create', goal: 'do the thing', steps: [{ description: 'step one' }, { description: 'step two' }] } }, + ], + usage: { inputTokens: 1, outputTokens: 1 }, + }; + } + if (turn === 2) { + // approve + start (using planId from turn 1's tool_result). This is a + // tool_use turn — the loop continues; the gate is NOT consulted here. + return { + stopReason: 'tool_use', + content: [ + { type: 'tool_use', id: 'c2', name: 'plan', input: { action: 'approve', planId } }, + { type: 'tool_use', id: 'c3', name: 'plan', input: { action: 'start', planId } }, + { type: 'text', text: 'Starting execution.' }, + ], + usage: { inputTokens: 1, outputTokens: 1 }, + }; + } + if (turn === 3) { + // Premature completion: a plain end_turn with NO tool_use and the plan + // still executing with 0/2 steps done. This is the ONLY turn shape that + // triggers the completion gate. + return { + stopReason: 'end_turn', + content: [{ type: 'text', text: 'Plan complete. Done.' }], + usage: { inputTokens: 1, outputTokens: 1 }, + }; + } + if (turn === 4) { + // (After the gate rejected and injected a correction:) skip step 1. + return { + stopReason: 'tool_use', + content: [ + { type: 'tool_use', id: 's1', name: 'plan_step', input: { planId, stepNumber: 1, action: 'skip', reason: 'not needed' } }, + { type: 'text', text: 'Skipping step 1.' }, + ], + usage: { inputTokens: 1, outputTokens: 1 }, + }; + } + if (turn === 5) { + // skip step 2 — now 2/2, plan should be complete. + return { + stopReason: 'tool_use', + content: [ + { type: 'tool_use', id: 's2', name: 'plan_step', input: { planId, stepNumber: 2, action: 'skip', reason: 'not needed' } }, + { type: 'text', text: 'Skipping step 2.' }, + ], + usage: { inputTokens: 1, outputTokens: 1 }, + }; + } + // turn >= 6: both steps skipped, plan complete — emit a plain end_turn. + return { + stopReason: 'end_turn', + content: [{ type: 'text', text: 'All steps handled.' }], + usage: { inputTokens: 1, outputTokens: 1 }, + }; + }, + async stream(options, onEvent) { + const result = await this.complete(options); + for (const block of result.content) { + if (block.type === 'text' && block.text) onEvent({ type: 'content_block_delta', text: block.text }); + if (block.type === 'tool_use') onEvent({ type: 'tool_use', ...block }); + } + onEvent({ type: 'message_stop' }); + return result; + }, +}; + +const sessionStore = new InMemorySessionStore(); +const agent = new MossAgent({ + llmProvider: provider, + sessionStore, + baseSystemPrompt: 'test', + domainPrompt: false, + enableSteering: false, + enableFollowUpGuard: false, + model: modelDef.id, +}); +registerBuiltinTools(agent); + +const sessionKey = 'plan-gate-integ-1'; + +let chatThrew = null; +try { + await agent.chat(sessionKey, 'Make a 2-step plan and complete it.'); +} catch (err) { + chatThrew = err; +} + +const messages = await sessionStore.loadMessages(sessionKey); +const serialized = JSON.stringify(messages); + +// The gate must have rejected the premature end_turn on turn 2 and injected its +// correction (it lists unfinished steps / tells the model to skip them). +const correctionMatch = serialized.match(/remain unfinished|plan_step action="skip"/i); +assert.ok( + correctionMatch, + 'the plan-completion gate injected a correction (premature end_turn was rejected)', +); + +// Both steps must have been skipped (escape hatch exercised) — search for the +// successful skip tool_results (not the error variant). +const skips = serialized.match(/Step \d+ skipped:/gi) || []; +assert.ok( + skips.length >= 2, + `both plan steps were skipped after the rejection (found ${skips.length} successful skips)`, +); + +// The run must NOT have crashed with the retry-exhaustion throw — when the +// model does the right thing (skip each step), the gate converges and the run +// ends cleanly. (The throw path is a separate, documented behavior.) +assert.equal(chatThrew, null, 'chat did not throw — gate converged after skips'); + +console.log('[PASS] plan-completion-gate integration: reject -> skip -> pass'); From 5947bc0aadf12c2e495194c0501a30e1825bc1c5 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Sat, 25 Jul 2026 19:03:15 +0800 Subject: [PATCH 5/6] test(plan): off-vs-on mock comparison proves gate mechanism direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same slack-off mock (5-step plan, completes 1, then premature end_turn): MOSS_PLAN_GATE=off -> premature end_turn passes, no correction. Default on -> rejected + correction injected. Verifies mechanism direction with a mock provider — NOT real-world gain (a mock is not a model that decides whether to slack off). Real gain needs a real model + controlled task set + N>=3. Co-Authored-By: Claude --- .../test/plan-completion-gate-ab.spec.mjs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 packages/moss-agent/test/plan-completion-gate-ab.spec.mjs diff --git a/packages/moss-agent/test/plan-completion-gate-ab.spec.mjs b/packages/moss-agent/test/plan-completion-gate-ab.spec.mjs new file mode 100644 index 00000000..5bb1f497 --- /dev/null +++ b/packages/moss-agent/test/plan-completion-gate-ab.spec.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * OFF vs ON comparison for the plan completion gate (MOSS_PLAN_GATE). + * + * NOT a real-world-effect benchmark — uses a MOCK LLM provider that models + * "the model slacks off": it builds a 5-step plan, approves+starts it, + * completes only step 1, then emits a plain end_turn claiming done. + * + * This proves the MECHANISM DIRECTION (gate on -> premature end_turn rejected + + * correction injected; gate off -> premature end_turn passes, no correction), + * not real-world gain (a mock provider is not a real model that decides whether + * to slack off). Real gain requires a real model + controlled task set + N>=3. + * + * Same mock, two runs: + * - MOSS_PLAN_GATE=off: premature end_turn should PASS (gate no-op). No + * correction injected. Run ends after the slack-off turn. + * - MOSS_PLAN_GATE=on (default): premature end_turn should be REJECTED and a + * correction injected. + */ +import assert from 'node:assert/strict'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { registerBuiltinTools } from '../dist/index.js'; + +const modelDef = { + id: 'plan-gate-ab', name: 'plan-gate-ab', api: 'openai-completions', provider: 'test', + baseUrl: '', reasoning: false, input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, maxTokens: 1024, +}; + +function planIdFromMessages(messages) { + const m = JSON.stringify(messages).match(/Plan created:\s*(plan-[0-9]+-[a-z0-9]+)/i); + return m ? m[1] : null; +} + +// Mock: slack-off model. 5-step plan, completes step 1, then end_turn claiming +// done with 4/5 unfinished. Returns the SAME turns regardless of gate state — +// the gate decides whether that premature end_turn is rejected. +function makeSlackoffProvider() { + let turn = 0; + return { + id: 'plan-gate-ab', capabilities: { streaming: true }, + async complete(options) { + turn += 1; + const planId = planIdFromMessages(options.messages ?? []); + if (turn === 1) { + return { stopReason: 'tool_use', content: [ + { type: 'text', text: 'Making a 5-step plan.' }, + { type: 'tool_use', id: 'c1', name: 'plan', input: { action: 'create', goal: 'g', steps: [{ description: 's1' }, { description: 's2' }, { description: 's3' }, { description: 's4' }, { description: 's5' }] } }, + ], usage: { inputTokens: 1, outputTokens: 1 } }; + } + if (turn === 2) { + return { stopReason: 'tool_use', content: [ + { type: 'tool_use', id: 'c2', name: 'plan', input: { action: 'approve', planId } }, + { type: 'tool_use', id: 'c3', name: 'plan', input: { action: 'start', planId } }, + { type: 'text', text: 'starting' }, + ], usage: { inputTokens: 1, outputTokens: 1 } }; + } + if (turn === 3) { + // complete step 1 only, then (turn 4) prematurely end_turn. + return { stopReason: 'tool_use', content: [ + { type: 'tool_use', id: 'd1', name: 'plan_step', input: { planId, stepNumber: 1, action: 'complete', actualOutput: 'done s1' } }, + { type: 'text', text: 'step1 done' }, + ], usage: { inputTokens: 1, outputTokens: 1 } }; + } + // turn >= 4: SLACK OFF — plain end_turn claiming done with 4/5 unfinished. + return { stopReason: 'end_turn', content: [{ type: 'text', text: 'All done!' }], usage: { inputTokens: 1, outputTokens: 1 } }; + }, + async stream(options, onEvent) { + const r = await this.complete(options); + for (const b of r.content) { + if (b.type === 'text' && b.text) onEvent({ type: 'content_block_delta', text: b.text }); + if (b.type === 'tool_use') onEvent({ type: 'tool_use', ...b }); + } + onEvent({ type: 'message_stop' }); + return r; + }, + }; +} + +async function run(gateFlag) { + if (gateFlag !== undefined) process.env.MOSS_PLAN_GATE = gateFlag; + else delete process.env.MOSS_PLAN_GATE; + const sessionStore = new InMemorySessionStore(); + const agent = new MossAgent({ + llmProvider: makeSlackoffProvider(), sessionStore, + baseSystemPrompt: 'test', domainPrompt: false, + enableSteering: false, enableFollowUpGuard: false, model: modelDef.id, + }); + registerBuiltinTools(agent); + const sessionKey = 'ab-' + (gateFlag === 'off' ? 'off' : 'on'); + let threw = null; + try { await agent.chat(sessionKey, 'do it'); } catch (e) { threw = e; } + const messages = await sessionStore.loadMessages(sessionKey); + const serialized = JSON.stringify(messages); + return { + threw, + correctionInjected: /remain unfinished|plan has unfinished/i.test(serialized), + sawPrematureEnd: /All done!|Plan complete/i.test(serialized), + }; +} + +// --- OFF: gate disabled -> premature end_turn passes, no correction injected --- +const off = await run('off'); +assert.equal(off.correctionInjected, false, + 'OFF: gate disabled -> no plan-completion correction injected (premature end_turn passed)'); +assert.ok(off.threw === null || /Completion rejected/.test(off.threw?.message ?? ''), + 'OFF: run either ends cleanly or hits a non-plan-gate exhaustion (no plan-gate rejection)'); + +// --- ON (default): gate active -> premature end_turn rejected, correction injected --- +const on = await run(undefined); +assert.equal(on.correctionInjected, true, + 'ON (default): gate active -> plan-completion correction injected (premature end_turn rejected)'); + +console.log('[PASS] plan-completion-gate ab: off passes / on rejects (mechanism direction verified, not real gain)'); +console.log(' OFF: correction injected =', off.correctionInjected, '| threw =', off.threw ? off.threw.message : 'none'); +console.log(' ON: correction injected =', on.correctionInjected); From eb857c696938b361ce05b6836b5232b85f1809b7 Mon Sep 17 00:00:00 2001 From: "tongchun.zhao" Date: Sat, 25 Jul 2026 19:24:18 +0800 Subject: [PATCH 6/6] docs: branch summary for plan-completion-gate-only (changes + known limits) Co-Authored-By: Claude --- ...-07-25-plan-completion-gate-only-branch.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/notes/2026-07-25-plan-completion-gate-only-branch.md diff --git a/docs/superpowers/notes/2026-07-25-plan-completion-gate-only-branch.md b/docs/superpowers/notes/2026-07-25-plan-completion-gate-only-branch.md new file mode 100644 index 00000000..40bdda90 --- /dev/null +++ b/docs/superpowers/notes/2026-07-25-plan-completion-gate-only-branch.md @@ -0,0 +1,43 @@ +# 分支说明:feature/plan-completion-gate-only + +**日期**: 2026-07-25 +**基线分支**: `feature/observability` (commit `e7ac54a`) +**PR 入口**: https://github.com/D-Robotics/moss/pull/new/feature/plan-completion-gate-only + +只含**计划完成门**(及其前提 per-session store 重构)。不含规划质量 critic、不含 A/B 协议——那些在独立分支 `feature/plan-completion-gate`,default off / 实验性,本分支不带入。 + +## 起点 + +「moss 主循环是否需要独立 Planner 规划校验层」——经分析,确定性收益在「完成时强制 plan 执行完整」,不在「执行前校验规划质量」。本分支只做前者。 + +## 改了什么(5 个 commit) + +### 1. per-session PlanExecuteController store(`40d3a36`,refactor) +把 `plan-tools.ts` 里进程级单例换成按 `sessionKey` 路由的 store(`plan-controller-store.ts`),维护 `sessionKey → activePlanId` 映射。多 session 嵌入式 host 不再串读对方 active plan。是完成门的前提。`PlanExecuteController` 业务逻辑不动。多 session 隔离有测试。 + +### 2. 计划完成门本体(`99507a8`,feat) +`evaluatePlanCompletionGate(request, deps)`:模型 `end_turn` 时,若该 session 有 approved/executing 状态的 plan 且 steps 未全做完(completed+skipped)→ **否决完成**、注入 correction 逼模型继续;逃生口是 `plan_step skip` 带理由。接进 CLI `createCliCompletionGate` 链(排在 `evaluatePlanEvalCompletionGate` 后)+ MossAgent `completionGate` 包装(无 structured-pending 分支)。fail-open 覆盖 5 条路径(无 session/无 plan/状态不对/查 plan 抛错/用户中止)。类型干净(required→optional 子类型,无适配)。 + +### 3. MOSS_PLAN_GATE flag(`48de1f0`,feat) +default **on**(完成门是已上线功能)。`MOSS_PLAN_GATE=off` 可关,留作 A/B baseline。端到端验证:default ON 时未完成 plan 被拦 + 注入 correction;OFF 时彻底 no-op(不注入)。 + +### 4. 集成测试(`0a2e0b2`,test) +真起 MossAgent loop,mock provider 走 create→approve+start→提前 end_turn→被否决→skip 两步→放行。证明完成门端到端生效(否决→注入 correction→循环继续→放行)。 + +### 5. off vs on mock 对比(`5947bc0`,test) +同一"偷懒"mock(建 5 步 plan、做完 1 步就 end_turn)跑 off/on:off 放行无 correction、on 拦住注入 correction。证明机制方向对(注:非真实收益,因 mock 是固定偷懒非真模型)。 + +## 测试 + +6 个相关 spec 全过:plan-controller-store / plan-completion-gate(9 case)/ plan-completion-gate-integ / plan-completion-gate-ab / plan-tools-nudge / interaction-mode-exit。build + typecheck 干净。 + +## 已知限制(诚实) + +1. **完成门真实收益未经验证**——逻辑 + 集成测试证明机制对,但真实任务里的增益(真实模型 + N≥3)未测;真 provider 试过 N=1 全噪声且烧 quota,没继续。`MOSS_PLAN_GATE` flag 留作后续真 A/B。 +2. **retry 超限崩 run**——模型死活不完成也不 skip,retry 用尽会 `throw Completion rejected` 崩 run(平台既有 exhaustion 行为,非 force-complete;文档对齐了)。 +3. **shared/no-session-controller plan 不受 gate 覆盖**——`ctx.sessionKey` 为空时走 shared 兜底,`setActivePlanId` 不记,gate 查不到(无 sessionKey → fail-open 放行)。CLI 总有 sessionKey,影响主要在无 sessionKey 的嵌入 host。 +4. tech-debt(文档级):CLI 路径 gate 在 chain 和 wrapper 各跑一次(幂等纯函数,无副作用,仅冗余)。 + +## 与完整分支的区别 + +完整分支 `feature/plan-completion-gate` 还含:critic 实验框架(`3859849`,default off,runner 待接)+ A/B 协议 doc(`960b865`)+ final-review 修正(`c29dbbe`)。本 `*-only` 分支**不含**这些,纯完成门,适合先单独 review/合。