Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion cli/src/commands/__tests__/router-steering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
activateSteering,
drainSteeringMessages,
} from '../../utils/steering-buffer'
import { findCommand } from '../command-registry'
import { routeUserPrompt } from '../router'

import type { RouterParams } from '../command-registry'

const createMockParams = (overrides: Partial<RouterParams> = {}): RouterParams =>
const createMockParams = (
overrides: Partial<RouterParams> = {},
): RouterParams =>
({
agentMode: 'DEFAULT',
inputRef: { current: null },
Expand Down Expand Up @@ -125,4 +128,94 @@ 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<typeof mock>).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 <text> 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 <text> 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 <text> 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()
})
})
})
70 changes: 36 additions & 34 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -808,33 +793,50 @@ function createSkillCommand(skillName: string): CommandDefinition {
}

/**
* Send (or queue, mid-turn) a user-invoked skill prompt. Shared by the
* /skill:<name> 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.
*/
export function dispatchSkillPrompt(
export function sendOrQueuePrompt(
params: RouterParams,
skill: { name: string; content: string },
input: string,
content: string,
attachments: PendingAttachment[] = [],
): void {
const userPrompt = buildSkillPrompt(skill, input)

if (
params.isStreaming ||
params.streamMessageIdRef.current ||
params.isChainInProgressRef.current
) {
params.addToQueue(userPrompt, capturePendingAttachments())
params.addToQueue(content, attachments)
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:<name> 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),
capturePendingAttachments(),
)
}
16 changes: 4 additions & 12 deletions cli/src/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { runTerminalCommand } from '@codebuff/sdk'
import {
dispatchSkillPrompt,
findCommand,
sendOrQueuePrompt,
type RouterParams,
type CommandResult,
} from './command-registry'
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
Loading