fix(mode): address PR #239 review — boundary flush ordering, disposal fence, config validation, TUI plan config

Four ds-review-bot findings:
- examples/tui-agent composed dsh-mode without the now-required
  modes.plan.section, so the TUI leaf failed at Loader startup (the keyless
  smoke only asserts the banner and missed it); graft the same deployment
  plan instructions the ACP leaf carries.
- The prompt-submit and turn-continuation flushes ran before next(), so a
  session/set_mode arriving while a downstream async listener (the shipped
  hooks listeners' shape) awaited applied one request late; both listeners
  now prepend and flush after next(), matching the request-error wrapper,
  with a regression test pinning the ordering.
- An HMR unload during the exit_plan_mode review let a later approval write
  into the disposed service and claim an exit whose flush could never land;
  the execute path now checks the fiber lifetime after the await and fails
  the call (the mode stays plan; the model re-presents).
- resolveConfig accepted empty/untrimmed mode names that list()/ACP then
  advertised while the package invariant rejected their selection,
  desynchronizing the picker; names are validated non-empty and trimmed at
  load, the same shape the invariant enforces.
This commit is contained in:
kingwl
2026-07-22 09:58:57 +08:00
parent 5d04213445
commit 7a2dff8b11
4 files changed
+108 -15

No files matched your search

+1 -1
View File
@@ -726,7 +726,7 @@ set(agent: Agent, mode: string): void
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/mode/mode/src/index.ts:204`](../../packages/mode/mode/src/index.ts)
Source: [`packages/mode/mode/src/index.ts:210`](../../packages/mode/mode/src/index.ts)
## `ctx.permission` — `PermissionService`
+16
View File
@@ -93,6 +93,22 @@
# TUI's user-interaction provider.
- id: mode
name: '@deepseek-ai/dsh-mode'
config:
modes:
plan:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
+38 -14
View File
@@ -142,6 +142,12 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
if (name === DEFAULT_MODE) {
throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
}
// The same shape the package invariant enforces on `mode/set`: accepting
// an empty or untrimmed KEY here would advertise a name whose selection
// the invariant then rejects, desynchronizing the picker forever.
if (name.trim() === '' || name.trim() !== name) {
throw new Error(`ModeConfig: mode name ${JSON.stringify(name)} must be non-empty and trimmed`)
}
if (typeof definition.section !== 'string') {
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
}
@@ -231,22 +237,32 @@ export class ModesService extends Service {
// flushed mode therefore lands before the prompt that should reflect it.
// Contained: policy must never block a prompt or turn; onBoundary can throw
// only when session.append rejects during teardown.
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
// Flush AFTER next() on every seam (the request-error wrapper below does
// the same): downstream listeners may await, and a `session/set_mode`
// arriving during that window must still shape the request this boundary
// precedes — a pre-next() flush would apply it one request late.
ctx.on('agent/prompt-submit', async (agent, _content, _source, _signal, next) => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
}
return next()
})
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) => {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
return decision
}, { prepend: true })
ctx.on('agent/turn-continuation', async (agent, _turn, _decision, _signal, next) => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-mode: boundary flush failed: %o', error)
}
}
return next()
})
return decision
}, { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
@@ -337,6 +353,14 @@ export class ModesService extends Service {
agent,
signal: exec.signal,
})
// The review may outlive this plugin fiber (HMR unload while the user
// decides): the boundary listeners that would flush the switch are
// already gone, so a success result here would claim an exit that can
// never land. Fail the call instead; a remounted service still holds
// plan mode and the model re-presents.
if (disposed) {
throw new Error('the 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 item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
+53
View File
@@ -143,6 +143,13 @@ describe('resolveConfig', () => {
.toThrow('"default" is reserved')
})
it('rejects an empty or untrimmed mode name loudly (the invariant would reject its selection)', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, '': { section: 'x' } } }))
.toThrow('mode name "" must be non-empty and trimmed')
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, ' review ': { section: 'x' } } }))
.toThrow('mode name " review " must be non-empty and trimmed')
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { ...PLAN_CONFIG.modes, bad: { section: 5 } as unknown as { section: string } } }))
.toThrow('needs a string `section`')
@@ -229,6 +236,28 @@ describe('the boundary flush', () => {
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
})
it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
// A downstream async listener (the shipped hooks listeners' shape): the
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the mode/set still
// precedes the request this boundary gates.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.modes.set(agent, PLAN_MODE)
await next()
return decision
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE })
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
@@ -813,6 +842,30 @@ describe('exit_plan_mode', () => {
expect(asked[0]?.signal).toBe(controller.signal)
})
it('fails the call when the plugin is disposed while the review awaits (no phantom exit)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(ModesService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
ctx.userInteraction.registerProvider({
ask: () => new Promise((resolve) => { answer = resolve }),
})
const agent = await agentWithSession(ctx, 'agent-1', { mode: PLAN_MODE })
const pending = callExit(ctx, agent)
// Let execute reach the review await, then unload the plugin (HMR) and
// only afterwards approve. The boundary listeners are gone, so a success
// would claim an exit that can never flush — the call must fail instead.
await new Promise(resolve => setImmediate(resolve))
await fiber.dispose()
answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
const result = await pending
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: the mode service was reloaded while the plan was under review; present the plan again' }])
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
})
it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })