Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining

# Conflicts:
#	apps/web/tests/snapshots/queue-actions/layout.expected.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/goal.i18n.yaml
#	docs/core-data-structures/goal.md
#	docs/core-data-structures/goal.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/context/time-context/src/index.ts
#	packages/context/tmux-context/src/index.ts
#	packages/core/agent-loop/src/agent.ts
#	packages/core/system-prompt/src/index.ts
#	packages/goal/goal/src/domain.ts
#	packages/goal/goal/src/index.ts
#	packages/goal/goal/src/render.ts
#	packages/plan/plan-mode/src/index.ts
This commit is contained in:
creatixchu
2026-08-06 11:49:03 +08:00
1357 changed files with 27674 additions and 18803 deletions

View File

@@ -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: 6f0a9ac477b49b96ddfc2ce667e3556dec727569
README.zh.md: 9d3d98ef387fa2f82bb87bf3e35b2a220bd643ec
README.md: 7273fa1a9be063e208596788eb4ca4f2bd3409a4
README.zh.md: 0878319545593948fcf04fbc3de518641e1ecfbe

View File

@@ -8,7 +8,7 @@ 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
@@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr
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
@@ -94,5 +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 has one assembled-application snapshot, the Web `plan-review` e2e lane (submit → decision card → approved flip). The rejected-feedback and dismissed branches are covered by package tests only, and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.
- Only the Web UI renders the `plan-review` intent; the TUI presents the review through its generic question flow, which is answerable but does not read as a plan gate.
- Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow.

View File

@@ -8,7 +8,7 @@
`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` 通知两条提交路径皆然)
## 模型与人类交互
@@ -18,7 +18,7 @@
组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode任何其他非空参数都会先启用 plan mode再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。
TUI 使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
## 会话投影
@@ -94,5 +94,4 @@ mode 转换不改变工具目录plan 参数与评审结果按常规方式扩
- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。
- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。
- Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。
- `exit_plan_mode` 评审弧有一个组装应用快照,即 Web `plan-review` e2e 通道(提交 → 决定卡片 → 已批准切换。已拒绝反馈与放弃审阅两个分支仅由包测试覆盖TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。
- 只有 Web UI 渲染 `plan-review` 意图TUI 通过其通用问题流程呈现该评审,可以回答,但读起来不像一个计划关口。
- 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。

View File

@@ -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,9 +25,9 @@
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 { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
@@ -196,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
@@ -424,13 +435,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
@@ -442,21 +453,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 }],
// The narration is already one sentence, so it is its own summary.
source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text },
}), { surfaceOp: 'append' })
})
}
}

View File

@@ -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([

View File

@@ -19,7 +19,7 @@ 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 },
})
}
@@ -31,8 +31,7 @@ describe('plan-mode stream invariants', () => {
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' } },
})
})
@@ -56,7 +55,7 @@ describe('plan-mode stream invariants', () => {
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 })

View File

@@ -3,7 +3,7 @@ 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, {
@@ -21,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 = Session.create(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
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'],
@@ -56,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). */
@@ -209,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)
})
@@ -235,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)
@@ -259,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)
})
@@ -292,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)
})
@@ -303,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([])
})
@@ -312,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([])
})
@@ -321,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.'])
})
@@ -333,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.'])
})
@@ -344,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([])
})
@@ -364,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
@@ -388,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 })
})
@@ -612,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)
@@ -626,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 })
})
@@ -751,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)
@@ -808,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)
@@ -830,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([])
})
@@ -1023,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)
})
})

View File

@@ -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' } })
}