Merge origin/master into task/command-feedback-master
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
|
||||
README.md: e3a98115d2d9f14fa0bb46e4d867f6b79cbf269d
|
||||
README.zh.md: f8481cff12992e83af39498908c5ca2624a4f974
|
||||
README.md: b22e218d1a0350d9dfb6e64fcaed426deaceb33e
|
||||
README.zh.md: 57ac85577a690d5c560bec39bef851692be03efd
|
||||
|
||||
@@ -8,15 +8,17 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
|
||||
|
||||
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
|
||||
|
||||
`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
|
||||
`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
|
||||
|
||||
## Model and human surfaces
|
||||
|
||||
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`.
|
||||
|
||||
The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message.
|
||||
|
||||
When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request.
|
||||
|
||||
The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
|
||||
The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
|
||||
|
||||
## Session projection
|
||||
|
||||
@@ -77,7 +79,7 @@ The user block is append-only conversation growth. Entering or leaving plan mode
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback.
|
||||
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -92,4 +94,4 @@ Mode transitions do not change the tool catalog; plan arguments and review resul
|
||||
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
|
||||
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
|
||||
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
|
||||
- The `exit_plan_mode` review arc (submit → human review → approved flip or rejected feedback) is covered by package tests only; its assembled-application snapshot left with the retired ACP UI scenarios ([automation-only ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)) and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.
|
||||
- Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow.
|
||||
|
||||
@@ -2,25 +2,27 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
按 agent(智能体)分开记录到日志的 plan 协作状态,提供部署拥有的引导内容、直接 `/plan [message]` 进入命令、`/plan off` 退出命令,以及经评审的 `exit_plan_mode` 退出。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行轴。
|
||||
按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行维度。
|
||||
|
||||
## 持久状态
|
||||
|
||||
`plan/mode`(`{ active: boolean }`)是一个仅写日志、整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。
|
||||
`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。
|
||||
|
||||
`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。提示词提交、常规续行和请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
|
||||
`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个被接受的轮内 pre-step;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
|
||||
|
||||
## 模型与人类界面
|
||||
## 模型与人类交互
|
||||
|
||||
激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。
|
||||
激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得用户明确批准后才退出。
|
||||
|
||||
组合 `ctx.commands` 时,该包(package)会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode;任何其他非空参数都会先选择 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。
|
||||
评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。
|
||||
|
||||
TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
|
||||
组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。
|
||||
|
||||
Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
|
||||
|
||||
## 会话投影
|
||||
|
||||
当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 且带有已记录 `args` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。
|
||||
当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -33,7 +35,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一
|
||||
plan through exit_plan_mode.
|
||||
```
|
||||
|
||||
`section` 必填且非空。未知键会在加载时失败。该包不接受任意具名 mode、工具过滤器、沙箱设置或批准策略。
|
||||
`section` 必填且非空。出现未知键时,插件会加载失败。该包不接受任意命名的 mode、工具过滤器、沙箱设置或批准策略。
|
||||
|
||||
设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。
|
||||
|
||||
@@ -43,7 +45,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
Plan mode 激活时,模型会在提示词顺序 50 处看到部署所提供的精确 `section` 文本;未激活 mode 不贡献文本。
|
||||
Plan mode 激活时,模型会在提示词顺序 50 处看到部署方提供的原样 `section` 文本;未激活 mode 不贡献文本。
|
||||
|
||||
##### 配置示例
|
||||
|
||||
@@ -53,7 +55,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr
|
||||
|
||||
#### Token 影响
|
||||
|
||||
未激活 mode 不增加 token;已激活 mode 会在每个请求中添加已配置段。
|
||||
未激活 mode 不增加 token;mode 激活时,每个请求都会加入已配置的段落。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -63,33 +65,33 @@ You are in plan mode. Explore and design before presenting the complete plan thr
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
`/plan`、`/plan off` 及其终端结果留在模型历史之外。除精确 `off` 参数以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个去除首尾空白的用户文本块。只有在最后一个请求头描述了 plan mode 时,已激活的 `/plan off` 选择才会贡献标准已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。
|
||||
`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个已去除首尾空白的用户文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。经叙述的激活退出会添加一条短小且保留的切换通知。
|
||||
可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。退出已激活的 plan mode 时,如果记录了该转换,还会追加一条简短且会保留的切换通知。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
用户块是仅追加的对话增长。进入或退出 plan mode 会改变更早的策略段;经叙述的退出通知追加在可复用请求前缀之后。
|
||||
用户块是仅追加的对话增长。进入或退出 plan mode 会改变更早的策略段;退出转换的记录通知会追加在可复用请求前缀之后。
|
||||
|
||||
### 退出工具 schema 与评审交换
|
||||
### 退出工具 schema 与评审交互
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。
|
||||
[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
稳定 schema 的成本取决于 ToolRegistry mode,每个 plan 参数与评审结果都保留在对话历史中。
|
||||
稳定 schema 的成本取决于 ToolRegistry mode,每次传入的 plan 参数和评审结果都会保留在对话历史中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
Mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。
|
||||
mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。
|
||||
|
||||
## 已知限制与延后工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。
|
||||
- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。
|
||||
- Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。
|
||||
- `exit_plan_mode` 评审弧(提交 → 人类评审 → 已批准切换或已拒绝反馈)仅由包测试覆盖;其组装应用快照随已退役 ACP UI 场景一起离开([仅面向自动化的 ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)),TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。
|
||||
- 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。
|
||||
|
||||
@@ -30,9 +30,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
*
|
||||
* The state in force is folded from the session log (`plan/mode`, last one
|
||||
* wins), so resume and fork restore it without a live mirror. User selections
|
||||
* are held as pending intent until an in-turn request boundary because every
|
||||
* session event is turn-enclosed. The service flushes at `agent/step` before
|
||||
* the affected request assembly, including retry turns.
|
||||
* are held as pending intent until an in-turn step boundary. The service
|
||||
* projects pending intent into the proposed step assembly, then flushes it
|
||||
* from `agent/pre-step` only when the step is accepted. Same-step request
|
||||
* retries reuse their assembly.
|
||||
*
|
||||
* The exit tool remains registered while plan mode is inactive so crossing a
|
||||
* boundary changes only the prompt section, not the request tool catalog.
|
||||
@@ -24,12 +25,12 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { z as zod } from 'zod'
|
||||
import type { ZodType } from 'zod'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-user-interaction'
|
||||
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
// Type-only edge: resolves `ctx.commands` for the optional command child.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
||||
@@ -70,6 +71,9 @@ export interface PlanModeConfig {
|
||||
section: string
|
||||
}
|
||||
|
||||
/** The review question's id, echoed in the answer this tool reads. */
|
||||
const REVIEW_ID = 'plan-review'
|
||||
|
||||
/** The review question's approve option label. */
|
||||
const APPROVE_LABEL = 'Approve'
|
||||
|
||||
@@ -193,30 +197,40 @@ export class PlanModeService extends Service {
|
||||
super(ctx, 'planMode')
|
||||
this.section = resolveConfig(config).section
|
||||
let disposed = false
|
||||
|
||||
// The boundary flush uses the loop's `agent/step` interception seam, not
|
||||
// post-commit `session/event` observation. `agent/step` runs inside the
|
||||
// open turn before every request derivation (including turn 1 step 1), so
|
||||
// it is the sole flush point: prompt admission happens pre-turn, where a
|
||||
// `plan/mode` append would land outside any open turn. Failures are
|
||||
// contained so policy cannot block a turn; a failed append remains
|
||||
// pending for a later boundary.
|
||||
ctx.on('agent/step', (agent) => {
|
||||
if (disposed) return
|
||||
// Pre-step is outside Session.append publication, so its log-only mode
|
||||
// event can land between turns or inside an open turn without re-entering
|
||||
// the session. A failed append remains pending for a later boundary, and
|
||||
// policy cannot block the step.
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent,
|
||||
_messages,
|
||||
{ signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
const pending = this.pendingIntents.get(agent.session)
|
||||
if (decision.kind === 'reject' || signal.aborted || pending === undefined) return decision
|
||||
const narration = this.narration(agent.session, pending.active)
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
this.onBoundary(agent.session)
|
||||
} catch (error) {
|
||||
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
|
||||
return decision
|
||||
}
|
||||
}, { prepend: true })
|
||||
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
|
||||
return !pending.narrate || narration === undefined
|
||||
? decision
|
||||
: { ...decision, messages: [...decision.messages, narration] }
|
||||
})
|
||||
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime')
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'plan:policy',
|
||||
order: 50,
|
||||
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
|
||||
? this.section
|
||||
: '',
|
||||
text: (context) => {
|
||||
if (context.agent === undefined) return ''
|
||||
const pending = this.pendingIntents.get(context.agent.session)
|
||||
return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : ''
|
||||
},
|
||||
})
|
||||
|
||||
// The plan projection unit (session-projection RFC): a pure double-event
|
||||
@@ -318,7 +332,7 @@ export class PlanModeService extends Service {
|
||||
}
|
||||
const answer = await interaction.ask({
|
||||
questions: [{
|
||||
id: 'plan-review',
|
||||
id: REVIEW_ID,
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: args.plan,
|
||||
@@ -326,16 +340,31 @@ export class PlanModeService extends Service {
|
||||
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
// Presentation only: a capable UI renders the plan as a review
|
||||
// decision instead of a generic question, and answers with one of
|
||||
// the labels above either way.
|
||||
intent: { kind: 'plan-review', approve: APPROVE_LABEL },
|
||||
}],
|
||||
agent,
|
||||
signal: exec.signal,
|
||||
}).catch((cause: unknown) => {
|
||||
// A dismissed review is not a failed one: the user took the turn back
|
||||
// to say something the two options do not cover. Say so, because the
|
||||
// generic channel message names ask_user_question, which the model
|
||||
// never called. An abort (turn cancel, provider teardown) keeps its
|
||||
// own message — there is no user to wait for.
|
||||
if (cause instanceof UserInteractionError && cause.code === 'ASK_CANCELLED') {
|
||||
throw new Error('The user dismissed the plan review to speak instead; '
|
||||
+ 'stay in plan mode, stop here, and wait for their message.')
|
||||
}
|
||||
throw cause
|
||||
})
|
||||
// A review may outlive this plugin fiber. Without boundary listeners,
|
||||
// an approved result could never land, so fail and keep planning.
|
||||
if (disposed) {
|
||||
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
|
||||
}
|
||||
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
|
||||
const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID)
|
||||
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
|
||||
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
|
||||
const feedback = item?.custom ?? ''
|
||||
@@ -407,13 +436,13 @@ export class PlanModeService extends Service {
|
||||
}
|
||||
session.append('plan/mode', { active })
|
||||
this.pendingIntents.delete(session)
|
||||
this.narrate(session, active)
|
||||
const narration = this.narration(session, active)
|
||||
if (narration !== undefined) agent.inject(narration)
|
||||
return 'committed'
|
||||
}
|
||||
|
||||
/** Flush one pending selection before the next request assembly. */
|
||||
private onBoundary(agent: Agent): void {
|
||||
const session = agent.session
|
||||
private onBoundary(session: Session): void {
|
||||
const pending = this.pendingIntents.get(session)
|
||||
if (pending === undefined) return
|
||||
const target = pending.active
|
||||
@@ -425,20 +454,20 @@ export class PlanModeService extends Service {
|
||||
// Delete only after append succeeds so a later boundary can retry a failed
|
||||
// durable write.
|
||||
this.pendingIntents.delete(session)
|
||||
if (pending.narrate) this.narrate(session, target)
|
||||
}
|
||||
|
||||
/** Tell the model about a user switch when the last logged header described the other mode. */
|
||||
private narrate(session: Session, target: boolean): void {
|
||||
/** Build a user-switch notice when the last logged header described the other mode. */
|
||||
private narration(session: Session, target: boolean): UserMessage | undefined {
|
||||
const told = planModeAtLastHeader(session.events)
|
||||
if (told === undefined || told === target) return
|
||||
const text = target
|
||||
? 'The user switched this session to plan mode.'
|
||||
: 'The user switched this session back to the default mode.'
|
||||
session.append('user/message', createUserMessage({
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'plan-mode' },
|
||||
}), { surfaceOp: 'append' })
|
||||
// The narration is already one sentence, so it is its own summary.
|
||||
source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
|
||||
* through the agent loop — the pending-intent flush at the request boundary, the
|
||||
* through the agent loop — the pending-intent flush at the step boundary, the
|
||||
* assembly the soft layer shapes (the exit tool + mode section), and the
|
||||
* `request/header` snapshots every transition leaves.
|
||||
* Only the model is mocked; the loop, the session log, and the plugin are
|
||||
@@ -71,8 +71,7 @@ describe('plan mode through the agent loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
|
||||
// Selected while idle: the pending intent flushes at the first
|
||||
// in-turn agent/step seam, before the first assembly.
|
||||
// Selected while idle: the mode commits immediately, before the first assembly.
|
||||
ctx.planMode.set(agent, true)
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } }))
|
||||
@@ -128,17 +127,19 @@ describe('plan mode through the agent loop', () => {
|
||||
expect(second.data.header.system).toContain('plan mode')
|
||||
})
|
||||
|
||||
it('a mode flip at error settlement shapes the retry before its assembly', async () => {
|
||||
it('a mode flip at error settlement waits until the step after a same-step retry', async () => {
|
||||
const failedRequest = [{
|
||||
type: 'finish',
|
||||
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
|
||||
}] satisfies StreamChunk[]
|
||||
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
|
||||
const adapter = new MockAdapter([
|
||||
failedRequest,
|
||||
textResponse('Recovered with the original step assembly.'),
|
||||
textResponse('Entered plan mode on the next step.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (
|
||||
subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, _signal, next,
|
||||
) => {
|
||||
ctx.on('agent/request-error', async (subject, _context, _signal, next) => {
|
||||
if (subject !== agent) return next()
|
||||
ctx.planMode.set(agent, true)
|
||||
return { kind: 'retry' }
|
||||
@@ -150,16 +151,26 @@ describe('plan mode through the agent loop', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section)
|
||||
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section)
|
||||
expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section)
|
||||
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
|
||||
const nextIdle = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } }))
|
||||
await nextIdle
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section)
|
||||
expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools)
|
||||
const log = agent.session.events
|
||||
const planMode = findEvent(log, 'plan/mode')
|
||||
const firstEnd = log.find(event => event.type === 'step/end'
|
||||
&& event.data.turn === 1 && event.data.step === 1)
|
||||
const retryStart = log.find(event => event.type === 'step/start'
|
||||
const nextStart = log.find(event => event.type === 'step/start'
|
||||
&& event.data.turn === 2 && event.data.step === 1)
|
||||
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
|
||||
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
|
||||
expect(planMode.seq).toBeLessThan(nextStart?.seq ?? 0)
|
||||
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
|
||||
const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(notice?.type === 'user/message' && notice.data.content).toEqual([
|
||||
|
||||
@@ -19,26 +19,25 @@ function event(active: unknown): SessionEvent {
|
||||
function emitTurnStart(ctx: Context, session: Session): void {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
}
|
||||
|
||||
describe('plan-mode stream invariants', () => {
|
||||
it('accepts either boolean state', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('plan-state'))
|
||||
const session = Session.create(SessionId('plan-state'))
|
||||
emitTurnStart(ctx, session)
|
||||
expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow()
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/end', seq: 3, time: 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId(`invalid-${String(active)}`))
|
||||
const session = Session.create(SessionId(`invalid-${String(active)}`))
|
||||
emitTurnStart(ctx, session)
|
||||
expect(() => { ctx.emit('session/event', session, event(active)) })
|
||||
.toThrow(/expected a boolean/)
|
||||
@@ -52,11 +51,11 @@ describe('plan-mode stream invariants', () => {
|
||||
|
||||
it('ignores unrelated dispatches and session events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('unrelated'))
|
||||
const session = Session.create(SessionId('unrelated'))
|
||||
expect(() => {
|
||||
ctx.emit('tools/change')
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1 },
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
@@ -65,7 +64,7 @@ describe('plan-mode stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('plan/mode', { active: 'plan' as unknown as boolean })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
@@ -77,7 +76,7 @@ describe('plan-mode stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserInteractionService, {
|
||||
UserInteractionError, type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
|
||||
@@ -19,15 +21,22 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
|
||||
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
|
||||
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
|
||||
* real scoped `agent.ctx` minted through `createScope`.
|
||||
* Request boundaries are simulated by dispatching the real prompt-admission
|
||||
* and between-step seams used by the loop.
|
||||
* Request boundaries are simulated by dispatching the real pre-step waterfall
|
||||
* and the following `step/start` session event used by the loop.
|
||||
*/
|
||||
|
||||
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
|
||||
// A live store session when a store is mounted (the command executor logs
|
||||
// lifecycle events through it); bare otherwise (fold/tool-only benches).
|
||||
const session = new Session(SessionId(id))
|
||||
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
|
||||
const session = Session.create(SessionId(id))
|
||||
const agent = {
|
||||
id: SessionId(id),
|
||||
session,
|
||||
options: {},
|
||||
inject(message: UserMessage) {
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
},
|
||||
} as unknown as Agent & { session: Session }
|
||||
let scoped!: Context
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
|
||||
inject: ['tools'],
|
||||
@@ -54,28 +63,35 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch either prompt admission or the between-step checkpoint.
|
||||
* Dispatch pre-step processing and optionally its following step-start commit.
|
||||
*/
|
||||
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
|
||||
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'pre-step' | 'step-start'): Promise<void> {
|
||||
const events = agentEvents(ctx, agent)
|
||||
if (type === 'turn/start') {
|
||||
await events.waterfall(
|
||||
'agent/prompt-submit',
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: 'boundary probe' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve({ kind: 'allow' }),
|
||||
)
|
||||
return
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: 'boundary probe' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
const decision = await events.waterfall(
|
||||
'agent/pre-step',
|
||||
[message],
|
||||
{ turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [message] }),
|
||||
)
|
||||
if (decision.kind === 'enter') {
|
||||
for (const message of decision.messages.slice(1)) {
|
||||
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
if (type === 'step-start') {
|
||||
const event = agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.emit('session/event', agent.session, event)
|
||||
}
|
||||
await events.serial('agent/step', 1, 2, new AbortController().signal)
|
||||
}
|
||||
|
||||
/** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */
|
||||
function openTurn(session: Session, turn = 0): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
}
|
||||
|
||||
/** Close the open turn (the between-turns shape: selections commit immediately). */
|
||||
@@ -151,7 +167,7 @@ describe('resolveConfig', () => {
|
||||
|
||||
describe('foldPlanMode', () => {
|
||||
it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
|
||||
const session = new Session(SessionId('fold'))
|
||||
const session = Session.create(SessionId('fold'))
|
||||
expect(foldPlanMode(session.events)).toBe(false)
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('plan/mode', { active: false })
|
||||
@@ -160,7 +176,7 @@ describe('foldPlanMode', () => {
|
||||
})
|
||||
|
||||
it('folds a prefix when `end` is given', () => {
|
||||
const session = new Session(SessionId('fold-prefix'))
|
||||
const session = Session.create(SessionId('fold-prefix'))
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('plan/mode', { active: false })
|
||||
expect(foldPlanMode(session.events, 1)).toBe(true)
|
||||
@@ -207,7 +223,7 @@ describe('ctx.planMode: get/set', () => {
|
||||
expect(ctx.planMode.set(agent, false)).toBe('committed')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(false)
|
||||
// A later boundary finds nothing pending — no double append.
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -233,23 +249,26 @@ describe('ctx.planMode: get/set', () => {
|
||||
})
|
||||
|
||||
describe('the boundary flush', () => {
|
||||
it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => {
|
||||
it('is inert when no selection is pending', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
const service = ctx.planMode as unknown as { onBoundary(session: Session): void }
|
||||
|
||||
expect(() => { service.onBoundary(agent.session) }).not.toThrow()
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
|
||||
it('flushes from pre-step before the following step/start', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
openTurn(agent.session)
|
||||
ctx.planMode.set(agent, true)
|
||||
// Prompt admission runs before any turn opens; a plan/mode appended there
|
||||
// would sit outside the turn. The pending intent survives admission and
|
||||
// the in-turn agent/step boundary flushes it before the request derives.
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'pre-step')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true })
|
||||
})
|
||||
|
||||
it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
|
||||
it('removes the pre-step flush when the plugin fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -257,32 +276,8 @@ describe('the boundary flush', () => {
|
||||
const agent = await agentWithSession(ctx)
|
||||
openTurn(agent.session)
|
||||
ctx.planMode.set(agent, true)
|
||||
// A listener captured in the same dispatch snapshot keeps the plan-mode
|
||||
// callback alive across the unload; the resumed wrapper must not append
|
||||
// through the disposed service. Registered prepended AFTER the plugin so
|
||||
// it runs before plan-mode's own prepended flush.
|
||||
ctx.on('agent/step', async () => {
|
||||
await fiber.dispose()
|
||||
}, { prepend: true })
|
||||
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
const agent = await agentWithSession(ctx)
|
||||
openTurn(agent.session)
|
||||
ctx.planMode.set(agent, true)
|
||||
// Serial dispatch captures its listener list up front; prepending after
|
||||
// the plugin puts this listener ahead of the plugin's own prepended one,
|
||||
// so the plugin's captured callback still runs after the disposal below.
|
||||
ctx.on('agent/step', async () => {
|
||||
await fiber.dispose()
|
||||
}, { prepend: true })
|
||||
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
await fiber.dispose()
|
||||
await boundary(ctx, agent, 'pre-step')
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -290,7 +285,7 @@ describe('the boundary flush', () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -301,7 +296,7 @@ describe('the boundary flush', () => {
|
||||
openTurn(agent.session)
|
||||
ctx.planMode.set(agent, true)
|
||||
ctx.planMode.set(agent, false)
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
await boundary(ctx, agent, 'pre-step')
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
@@ -310,7 +305,7 @@ describe('the boundary flush', () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
await boundary(ctx, agent, 'pre-step')
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -319,9 +314,9 @@ describe('the boundary flush', () => {
|
||||
const agent = await agentWithSession(ctx)
|
||||
header(agent.session)
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
|
||||
})
|
||||
|
||||
@@ -331,7 +326,7 @@ describe('the boundary flush', () => {
|
||||
agent.session.append('plan/mode', { active: true })
|
||||
header(agent.session)
|
||||
ctx.planMode.set(agent, false)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
|
||||
})
|
||||
|
||||
@@ -342,7 +337,7 @@ describe('the boundary flush', () => {
|
||||
header(agent.session)
|
||||
agent.session.append('plan/mode', { active: false })
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
@@ -362,19 +357,19 @@ describe('the boundary flush', () => {
|
||||
if (type === 'plan/mode') throw new Error('backend gone')
|
||||
return (original as (...args: unknown[]) => unknown)(type, ...rest)
|
||||
}) as unknown) as typeof agent.session.append
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
// The failed flush re-parks the intent (cleared only after a landed
|
||||
// append), so the next healthy boundary converges the log with the
|
||||
// picker's optimistic state instead of dropping the switch forever.
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
agent.session.append = original
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(ctx.planMode.get(agent).pending).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => {
|
||||
it('contains a pre-step append failure and keeps the intent pending', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
@@ -386,9 +381,7 @@ describe('the boundary flush', () => {
|
||||
if (type === 'plan/mode') throw new Error('backend gone')
|
||||
return (original as (...args: unknown[]) => unknown)(type, ...rest)
|
||||
}) as unknown) as typeof agent.session.append
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'pre-step')
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
@@ -610,7 +603,7 @@ describe('/plan', () => {
|
||||
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
|
||||
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
|
||||
expect(enteringSteer).not.toHaveBeenCalled()
|
||||
await boundary(ctx, entering, 'step/end')
|
||||
await boundary(ctx, entering, 'step-start')
|
||||
expect(ctx.planMode.get(entering)).toEqual({ active: false })
|
||||
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
|
||||
@@ -624,7 +617,7 @@ describe('/plan', () => {
|
||||
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
|
||||
expect(activeSteer).not.toHaveBeenCalled()
|
||||
await boundary(ctx, active, 'step/end')
|
||||
await boundary(ctx, active, 'step-start')
|
||||
expect(ctx.planMode.get(active)).toEqual({ active: false })
|
||||
})
|
||||
|
||||
@@ -749,7 +742,7 @@ describe('exit_plan_mode', () => {
|
||||
// step's end, so the plan policy covers any remaining call of the SAME batch.
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(false)
|
||||
expect(asked).toHaveLength(1)
|
||||
expect(asked[0]?.agent).toBe(agent)
|
||||
@@ -806,18 +799,18 @@ describe('exit_plan_mode', () => {
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
|
||||
})
|
||||
|
||||
it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
|
||||
it('an approved exit projects the next assembly before the boundary and never removes the tool', async () => {
|
||||
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
|
||||
const approved = await callExit(ctx, agent)
|
||||
expect(approved.isError).toBe(false)
|
||||
// Calls of the SAME assistant response (no boundary between) were
|
||||
// requested under the plan-shaped header — the fold stays plan for that
|
||||
// whole batch; the boundary flush is what flips the next step.
|
||||
// Calls of the SAME assistant response were requested under the existing
|
||||
// plan-shaped header. Pending state shapes only the proposed next
|
||||
// assembly; the accepted boundary then commits the matching durable fold.
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
|
||||
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(false)
|
||||
const afterExit = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(afterExit.tools).toEqual(assembly.tools)
|
||||
@@ -828,7 +821,7 @@ describe('exit_plan_mode', () => {
|
||||
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
|
||||
header(agent.session)
|
||||
await callExit(ctx, agent)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(false)
|
||||
expect(noticeTexts(agent.session)).toEqual([])
|
||||
})
|
||||
@@ -894,6 +887,40 @@ describe('exit_plan_mode', () => {
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
|
||||
})
|
||||
|
||||
it('declares the plan-review presentation intent naming its approve option', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
await callExit(ctx, agent)
|
||||
const question = asked[0]?.questions[0]
|
||||
expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' })
|
||||
// The named label is one this same question offers, so a UI honouring the
|
||||
// intent answers a choice this tool accepts.
|
||||
expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)
|
||||
})
|
||||
|
||||
it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
ctx.userInteraction.registerProvider({
|
||||
ask: () => Promise.reject(new UserInteractionError(
|
||||
'the user cancelled ask_user_question', 'ASK_CANCELLED')),
|
||||
})
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }])
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves every other review failure its own message', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
ctx.userInteraction.registerProvider({
|
||||
ask: () => Promise.reject(new UserInteractionError(
|
||||
'ask_user_question was aborted before the user answered', 'ASK_ABORTED')),
|
||||
})
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }])
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards the execution abort signal to the review question', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
const controller = new AbortController()
|
||||
@@ -987,7 +1014,7 @@ describe('HMR disposal', () => {
|
||||
expect(ctx.get('planMode')).toBeUndefined()
|
||||
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
await boundary(ctx, agent, 'step-start')
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ function runPlanCommand(session: Session, args: string, index: number): void {
|
||||
|
||||
/** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */
|
||||
function commitPlanMode(session: Session, active: boolean, turn: number): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('plan/mode', { active })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user