diff --git a/cli/src/commands/__tests__/router-steering.test.ts b/cli/src/commands/__tests__/router-steering.test.ts index cb15dcf92b..8ab90d84cc 100644 --- a/cli/src/commands/__tests__/router-steering.test.ts +++ b/cli/src/commands/__tests__/router-steering.test.ts @@ -6,11 +6,14 @@ import { activateSteering, drainSteeringMessages, } from '../../utils/steering-buffer' +import { dispatchSkillPrompt, findCommand } from '../command-registry' import { routeUserPrompt } from '../router' import type { RouterParams } from '../command-registry' -const createMockParams = (overrides: Partial = {}): RouterParams => +const createMockParams = ( + overrides: Partial = {}, +): RouterParams => ({ agentMode: 'DEFAULT', inputRef: { current: null }, @@ -36,11 +39,13 @@ const createMockParams = (overrides: Partial = {}): RouterParams = beforeEach(() => { useChatStore.getState().clearPendingBashMessages() + useChatStore.getState().clearPendingAttachments() }) afterEach(() => { __resetSteeringForTests() useChatStore.getState().clearPendingBashMessages() + useChatStore.getState().clearPendingAttachments() }) describe('mid-turn routing', () => { @@ -125,4 +130,134 @@ describe('mid-turn routing', () => { expect(params.sendMessage).toHaveBeenCalledTimes(1) expect(drainSteeringMessages('run-1')).toEqual([]) }) + + describe('plan/interview/review input modes queue instead of interrupting', () => { + afterEach(() => { + useChatStore.getState().setInputMode('default') + }) + + test('plan mode queues a mid-turn submit instead of sending it', async () => { + useChatStore.getState().setInputMode('plan') + const params = createMockParams({ + inputValue: 'add dark mode', + isStreaming: true, + }) + await routeUserPrompt(params) + + // Must never fire a second run against the busy owner: that would + // register a new active-run owner and interrupt the in-flight one. + expect(params.sendMessage).not.toHaveBeenCalled() + expect(params.addToQueue).toHaveBeenCalledTimes(1) + const [queued] = (params.addToQueue as ReturnType).mock + .calls[0] as [string] + expect(queued).toContain('add dark mode') + }) + + test('interview mode queues a mid-turn submit instead of sending it', async () => { + useChatStore.getState().setInputMode('interview') + const params = createMockParams({ + inputValue: 'what should the API look like', + isStreaming: true, + }) + await routeUserPrompt(params) + + expect(params.sendMessage).not.toHaveBeenCalled() + expect(params.addToQueue).toHaveBeenCalledTimes(1) + }) + + test('review mode queues a mid-turn submit instead of sending it', async () => { + useChatStore.getState().setInputMode('review') + const params = createMockParams({ + inputValue: 'check for null handling', + isStreaming: true, + }) + await routeUserPrompt(params) + + expect(params.sendMessage).not.toHaveBeenCalled() + expect(params.addToQueue).toHaveBeenCalledTimes(1) + }) + + test('plan mode still sends immediately when idle', async () => { + useChatStore.getState().setInputMode('plan') + const params = createMockParams({ inputValue: 'add dark mode' }) + await routeUserPrompt(params) + + expect(params.sendMessage).toHaveBeenCalledTimes(1) + expect(params.addToQueue).not.toHaveBeenCalled() + }) + }) + + describe('/interview and /review with inline args queue instead of interrupting', () => { + test('/interview queues mid-turn instead of sending', () => { + const params = createMockParams({ + inputValue: '/interview what should the API look like', + isStreaming: true, + }) + findCommand('interview')!.handler(params, 'what should the API look like') + + expect(params.sendMessage).not.toHaveBeenCalled() + expect(params.addToQueue).toHaveBeenCalledTimes(1) + }) + + test('/review queues mid-turn instead of sending', () => { + const params = createMockParams({ + inputValue: '/review check for null handling', + isStreaming: true, + }) + findCommand('review')!.handler(params, 'check for null handling') + + expect(params.sendMessage).not.toHaveBeenCalled() + expect(params.addToQueue).toHaveBeenCalledTimes(1) + }) + + test('/interview still sends immediately when idle', () => { + const params = createMockParams({ + inputValue: '/interview what should the API look like', + }) + findCommand('interview')!.handler(params, 'what should the API look like') + + expect(params.sendMessage).toHaveBeenCalledTimes(1) + expect(params.addToQueue).not.toHaveBeenCalled() + }) + }) + + describe('staged attachments follow the prompt they were staged for', () => { + const stageAttachment = () => + useChatStore.getState().addPendingAttachment({ + kind: 'text', + id: 'pasted-1', + content: 'a long pasted block', + preview: 'a long pasted block', + charCount: 19, + }) + + test('a queued /interview carries the staged attachments with it', () => { + stageAttachment() + const params = createMockParams({ + inputValue: '/interview what should the API look like', + isStreaming: true, + }) + findCommand('interview')!.handler(params, 'what should the API look like') + + const [, attachments] = (params.addToQueue as ReturnType) + .mock.calls[0] as [string, unknown[]] + // Queued sends pass their attachments explicitly, which suppresses the + // pendingAttachments fallback in prepareUserMessage. Anything left in + // the store here would land on some later, unrelated message. + expect(attachments).toHaveLength(1) + expect(useChatStore.getState().pendingAttachments).toHaveLength(0) + }) + + test('an idle skill dispatch leaves the staged attachments for the send path', () => { + stageAttachment() + const params = createMockParams({ inputValue: '/skill:tidy' }) + dispatchSkillPrompt(params, { name: 'tidy', content: 'Tidy up.' }, '') + + expect(params.sendMessage).toHaveBeenCalledTimes(1) + // sendMessage is called without an attachments key on purpose: it falls + // back to the store. Capturing here would clear them into a value the + // idle branch never passes on. + expect(useChatStore.getState().pendingAttachments).toHaveLength(1) + }) + }) }) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 9c381796fc..5eb3b157b5 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -608,15 +608,10 @@ const ALL_COMMANDS: CommandDefinition[] = [ params.saveToHistory(params.inputValue.trim()) clearInput(params) - // If user provided text directly, send it immediately + // If user provided text directly, send it now (or queue it if a run + // is already in progress) if (trimmedArgs) { - params.sendMessage({ - content: buildInterviewPrompt(trimmedArgs), - agentMode: params.agentMode, - }) - setTimeout(() => { - params.scrollToLatest() - }, 0) + sendOrQueuePrompt(params, buildInterviewPrompt(trimmedArgs)) return } @@ -633,15 +628,10 @@ const ALL_COMMANDS: CommandDefinition[] = [ params.saveToHistory(params.inputValue.trim()) clearInput(params) - // If user provided plan text directly, send it immediately + // If user provided plan text directly, send it now (or queue it if a + // run is already in progress) if (trimmedArgs) { - params.sendMessage({ - content: buildPlanPrompt(trimmedArgs), - agentMode: params.agentMode, - }) - setTimeout(() => { - params.scrollToLatest() - }, 0) + sendOrQueuePrompt(params, buildPlanPrompt(trimmedArgs)) return } @@ -658,15 +648,10 @@ const ALL_COMMANDS: CommandDefinition[] = [ params.saveToHistory(params.inputValue.trim()) clearInput(params) - // If user provided review text directly, send it immediately without showing the screen + // If user provided review text directly, send it now without showing + // the screen (or queue it if a run is already in progress) if (trimmedArgs) { - params.sendMessage({ - content: buildReviewPromptFromArgs(trimmedArgs), - agentMode: params.agentMode, - }) - setTimeout(() => { - params.scrollToLatest() - }, 0) + sendOrQueuePrompt(params, buildReviewPromptFromArgs(trimmedArgs)) return } @@ -808,33 +793,50 @@ function createSkillCommand(skillName: string): CommandDefinition { } /** - * Send (or queue, mid-turn) a user-invoked skill prompt. Shared by the - * /skill: args form and the skill input mode's submit (router), so the - * two entry paths for the same feature cannot drift. + * Send a prompt immediately, or queue it behind an in-progress run so it + * isn't dropped. Shared by every command whose handler can also fire + * mid-turn (skill args, /plan, /interview, /review, and their input-mode + * counterparts in the router) so those entry paths can't drift out of sync + * with the busy check. + * + * Attachments are handled on exactly one side of the branch, and must stay + * that way. A queued send passes its attachments explicitly, which suppresses + * the pendingAttachments fallback in prepareUserMessage, so the queue entry + * has to carry them or they sit in the store and surface on some later, + * unrelated message. An immediate send passes no attachments key at all and + * relies on that same fallback, so capturing here would clear them into a + * value this branch never forwards. */ -export function dispatchSkillPrompt( - params: RouterParams, - skill: { name: string; content: string }, - input: string, -): void { - const userPrompt = buildSkillPrompt(skill, input) - +export function sendOrQueuePrompt(params: RouterParams, content: string): void { if ( params.isStreaming || params.streamMessageIdRef.current || params.isChainInProgressRef.current ) { - params.addToQueue(userPrompt, capturePendingAttachments()) + params.addToQueue(content, capturePendingAttachments()) params.setInputFocused(true) params.inputRef.current?.focus() return } params.sendMessage({ - content: userPrompt, + content, agentMode: params.agentMode, }) setTimeout(() => { params.scrollToLatest() }, 0) } + +/** + * Send (or queue, mid-turn) a user-invoked skill prompt. Shared by the + * /skill: args form and the skill input mode's submit (router), so the + * two entry paths for the same feature cannot drift. + */ +export function dispatchSkillPrompt( + params: RouterParams, + skill: { name: string; content: string }, + input: string, +): void { + sendOrQueuePrompt(params, buildSkillPrompt(skill, input)) +} diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index 7453034ebe..a588cf56c7 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -5,6 +5,7 @@ import { runTerminalCommand } from '@codebuff/sdk' import { dispatchSkillPrompt, findCommand, + sendOrQueuePrompt, type RouterParams, type CommandResult, } from './command-registry' @@ -325,10 +326,7 @@ export async function routeUserPrompt( setInputFocused(true) inputRef.current?.focus() - sendMessage({ content: buildPlanPrompt(trimmed), agentMode }) - setTimeout(() => { - scrollToLatest() - }, 0) + sendOrQueuePrompt(params, buildPlanPrompt(trimmed)) return } @@ -341,10 +339,7 @@ export async function routeUserPrompt( setInputFocused(true) inputRef.current?.focus() - sendMessage({ content: buildInterviewPrompt(trimmed), agentMode }) - setTimeout(() => { - scrollToLatest() - }, 0) + sendOrQueuePrompt(params, buildInterviewPrompt(trimmed)) return } @@ -389,10 +384,7 @@ export async function routeUserPrompt( setInputFocused(true) inputRef.current?.focus() - sendMessage({ content: buildReviewPrompt('custom', trimmed), agentMode }) - setTimeout(() => { - scrollToLatest() - }, 0) + sendOrQueuePrompt(params, buildReviewPrompt('custom', trimmed)) return }