Merge remote-tracking branch 'origin/master' into session-query-search
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user