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
137 changes: 136 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 { dispatchSkillPrompt, 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 All @@ -36,11 +39,13 @@ const createMockParams = (overrides: Partial<RouterParams> = {}): RouterParams =

beforeEach(() => {
useChatStore.getState().clearPendingBashMessages()
useChatStore.getState().clearPendingAttachments()
})

afterEach(() => {
__resetSteeringForTests()
useChatStore.getState().clearPendingBashMessages()
useChatStore.getState().clearPendingAttachments()
})

describe('mid-turn routing', () => {
Expand Down Expand Up @@ -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<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()
})
})

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<typeof mock>)
.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)
})
})
})
74 changes: 38 additions & 36 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.
*
* 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:<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))
}
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