fix(stdio): /mode is reserved even while a question prompt is active

Review finding at the seam of two surfaces this branch added to the
same stdin: with an ask_user_question (or plan-review) prompt active,
the line handler dispatched every line as the answer first, so
'/mode plan' typed mid-question was recorded as free-text feedback —
model-visible in the tool result — and the mode never changed. Command
handling now runs before answer dispatch: the command executes, the
question stays pending and still owns the next non-command line. A
literal '/mode…' free-text answer is the trade-off deliberately spent —
a swallowed command that becomes review feedback costs far more than
that contrived answer shape.
This commit is contained in:
kingwl
2026-07-10 19:17:45 +08:00
parent 3025fbaeb3
commit b29aeb1847
3 changed files with 70 additions and 14 deletions

View File

@@ -871,3 +871,49 @@ describe('createStdioChat /mode command', () => {
expect(ctx.modes.get(agent)).toEqual({ current: 'default' })
})
})
describe('createStdioChat /mode during an active question', () => {
it('reserves /mode while a question is pending — the command is never recorded as the answer', async () => {
const bundle = await setup()
await bundle.ctx.plugin(SystemPrompt)
await bundle.ctx.plugin(ToolRegistry)
await bundle.ctx.plugin(ModesService)
const agent = {
id: 'main' as Agent['id'],
status: 'idle',
options: {},
session: new RealSession(SessionId('main-session')),
send: () => {},
steer: () => {},
} as never as Agent
bundle.ctx.agents.register(agent)
const answer = bundle.ctx.userInteraction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
}],
})
await new Promise(r => setImmediate(r))
// The command runs as a command: the mode switches, the question stays
// pending (it still owns the next non-command line).
bundle.input.feed('/mode plan')
await new Promise(r => setImmediate(r))
expect(bundle.out.text()).toContain('mode → plan (applies from the next turn)')
expect(bundle.ctx.modes.get(agent)).toEqual({ current: 'default', pending: PLAN_MODE })
bundle.input.feed('1')
await expect(answer).resolves.toEqual({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
})
it('logs and drops /mode when the target agent is not running', async () => {
const { ctx, input } = await setup()
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('/mode plan')
await new Promise(r => setImmediate(r))
expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
})
})