From b29aeb1847acc10e80e959427e16cf2d36390537 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 10 Jul 2026 19:17:45 +0800 Subject: [PATCH] fix(stdio): /mode is reserved even while a question prompt is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../feature/2026-07-07-plan-mode.md | 2 +- packages/ui/stdio-agent/src/stdio-chat.ts | 36 +++++++++------ .../ui/stdio-agent/tests/stdio-chat.spec.ts | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-07-plan-mode.md b/docs/rfc/implemented/feature/2026-07-07-plan-mode.md index c906366969..316e41af40 100644 --- a/docs/rfc/implemented/feature/2026-07-07-plan-mode.md +++ b/docs/rfc/implemented/feature/2026-07-07-plan-mode.md @@ -50,7 +50,7 @@ Mode definitions are validated plugin Config — per repo convention, changeable ### In the terminal -The stdio app gains `/mode` (print the current and available modes) and `/mode ` (switch + banner — a command line, never sent to the model). The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the stdio provider's one-prompt-owns-stdin queue that `ask_user_question` already uses. +The stdio app gains `/mode` (print the current and available modes) and `/mode ` (switch + banner — a command line, never sent to the model, and reserved even while a question prompt is active: a command is never recorded as an answer). The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the stdio provider's one-prompt-owns-stdin queue that `ask_user_question` already uses. ### Over ACP diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 50f25ca289..f12d218dc0 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -349,21 +349,21 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt }) reader.on('line', (line) => { - if (activeQuestion !== undefined) { - answerQuestion(line) - return - } const text = line.trim() - if (!text) return - const agent = ctx.agents.get(agentId) - if (!agent) { - ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) - return - } if (text === '/mode' || text.startsWith('/mode ')) { - // A command line, never sent to the model: print or switch the session - // mode. The switch is a pending intent the mode service flushes at the - // next turn boundary (dsh-mode's turn-enclosure contract). + // A command line, never sent to the model — and reserved even while a + // question prompt is active: a command swallowed as a free-text answer + // would land in the tool result as model-visible feedback (the plan + // review is exactly such a prompt), so command handling runs before + // answer dispatch. The switch is a pending intent the mode service + // flushes at the next turn boundary (dsh-mode's turn-enclosure + // contract); an active question stays pending and still owns the + // next non-command line. + const agent = ctx.agents.get(agentId) + if (!agent) { + ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) + return + } const modes = ctx.get('modes') if (modes === undefined) { output.write('session modes are not composed in this deployment\n> ') @@ -385,6 +385,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } return } + if (activeQuestion !== undefined) { + answerQuestion(line) + return + } + if (!text) return + const agent = ctx.agents.get(agentId) + if (!agent) { + ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) + return + } submittedWork = true if (agent.status === 'running') { agent.steer([{ type: 'text', text }]) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 54d58db63b..8b04802e5c 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -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') + }) +})