Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

This commit is contained in:
Tianyi Cui
2026-07-23 22:00:59 +08:00
83 changed files with 231 additions and 122 deletions

View File

@@ -4,6 +4,6 @@ Plan mode is one logged, per-agent collaboration state. It is a single **product
| Package | Role | ctx key |
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]`, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. ACP maps this capability onto its generic `default` / `plan` picker; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-plan-mode
Logged, per-agent plan collaboration state with deployment-owned guidance, a direct `/plan [message]` entry command, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
## Durable state
@@ -12,7 +12,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, a dir
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`.
When `ctx.commands` is composed, the package registers `/plan [message]`. The command selects plan mode first. A non-empty argument is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance; bare `/plan` only changes state.
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.
ACP is an adapter, not the owner of this vocabulary: it advertises the fixed wire ids `default` and `plan`, maps `session/set_mode` to the boolean service, and translates committed `plan/mode` events back to `current_mode_update`.
@@ -53,19 +53,19 @@ Inactive mode adds no tokens; active mode adds the configured section to every r
The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward.
### Optional command message
### Human command
#### What the model sees
`/plan` and its terminal result stay outside model history; a non-empty suffix becomes one trimmed user text block through `agent.steer()` after plan mode is selected.
`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one trimmed user text block through `agent.steer()` after plan mode is selected. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it.
#### Token effect
The suffix costs the same history tokens as submitting that text separately; a bare command adds none.
The optional message costs the same history tokens as submitting that text separately; bare `/plan` and `/plan off` add none. A narrated active exit adds the small retained switch notice.
#### KV Cache effect
The user block is append-only conversation growth, while entering plan mode also changes the earlier policy section.
The user block is append-only conversation growth. Entering or leaving plan mode changes the earlier policy section; a narrated exit notice is appended after the reusable request prefix.
### Exit tool schema and review exchange

View File

@@ -1,9 +1,10 @@
/**
* Plan mode is logged per-agent collaboration state: while active, a
* deployment-owned guidance section shapes each model request, and
* `exit_plan_mode` presents the completed plan for user review. It is
* independent of sandbox mode and approval policy; those enforcement axes do
* not read or write plan state.
* `exit_plan_mode` presents the completed plan for user review, while the
* `/plan off` command lets a user leave directly. Plan mode is independent of
* sandbox mode and approval policy; those enforcement axes do not read or
* write plan state.
*
* 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
@@ -210,13 +211,27 @@ export class PlanModeService extends Service {
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'plan',
description: 'Enter plan mode',
input: { hint: '[message]' },
description: 'Enter or leave plan mode',
input: { hint: '[off|message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
if (message === 'off') {
const state = this.get(agent)
this.set(agent, false)
if (state.active) {
return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
}
if (state.pending === true) {
return { kind: 'success', text: 'Plan mode entry cancelled.' }
}
return { kind: 'success', text: 'Plan mode is already inactive.' }
}
this.set(agent, true)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: 'Entering plan mode (applies from the next step).' }
return {
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
}
},
})
})

View File

@@ -546,14 +546,17 @@ describe('/plan', () => {
const plainSteer = vi.fn()
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
expect(ctx.commands.list(plainAgent)).toEqual([
{ name: 'plan', description: 'Enter plan mode', input: { hint: '[message]' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
])
const signal = new AbortController().signal
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
expect(plain).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(plain).toEqual({
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
})
expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
expect(plainSteer).not.toHaveBeenCalled()
@@ -561,11 +564,50 @@ describe('/plan', () => {
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
expect(plan).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(plan).toEqual({
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
})
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
})
it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
const ctx = await setup()
await ctx.plugin(CommandService)
await new Promise(resolve => setImmediate(resolve))
const signal = new AbortController().signal
const inactive = await agentWithSession(ctx, 'inactive-plan-command')
expect(await ctx.commands.execute(inactive, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
expect(ctx.planMode.get(inactive)).toEqual({ active: false })
const entering = await agentWithSession(ctx, 'entering-plan-command')
const enteringSteer = vi.fn()
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
await ctx.commands.execute(entering, '/plan', signal)
expect(await ctx.commands.execute(entering, '/plan off', signal))
.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, 'turn/start')
expect(ctx.planMode.get(entering)).toEqual({ active: false })
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
const activeSteer = vi.fn()
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
expect(await ctx.commands.execute(active, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
expect(await ctx.commands.execute(active, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(activeSteer).not.toHaveBeenCalled()
await boundary(ctx, active, 'turn/start')
expect(ctx.planMode.get(active)).toEqual({ active: false })
})
it('removes the contributed command when the plan-mode plugin is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -2384,6 +2384,7 @@ export function createTuiChat(
...ctx.commands.list(agent).map(command => ({
name: command.name,
description: command.description,
...(command.input === undefined ? {} : { argumentHint: command.input.hint }),
})),
...skillCommands,
],

View File

@@ -1706,6 +1706,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
})
result.terminal.send('/plugin-ch')
await tick()
expect(result.terminal.output).toContain('<value> — Run a plugin command')
result.terminal.send('\x03')
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
await tick()