Skip to content

Commit 6346ff1

Browse files
merge: resolve conflict in command-registry.ts
Keep both doctor imports and new buildSkillPrompt import from upstream. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
2 parents d84d52c + dcbfacd commit 6346ff1

34 files changed

Lines changed: 1862 additions & 97 deletions

bun.lock

Lines changed: 51 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/release/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebuff",
3-
"version": "1.0.685",
3+
"version": "1.0.686",
44
"description": "AI coding agent",
55
"license": "MIT",
66
"bin": {

cli/src/chat.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ export const Chat = ({
535535
logoutMutation,
536536
streamMessageIdRef,
537537
addToQueue,
538+
hasQueuedMessages: () => queuedCount > 0,
538539
clearMessages,
539540
saveToHistory,
540541
scrollToLatest,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
3+
import { useChatStore } from '../../state/chat-store'
4+
import {
5+
__resetSteeringForTests,
6+
activateSteering,
7+
drainSteeringMessages,
8+
} from '../../utils/steering-buffer'
9+
import { routeUserPrompt } from '../router'
10+
11+
import type { RouterParams } from '../command-registry'
12+
13+
const createMockParams = (overrides: Partial<RouterParams> = {}): RouterParams =>
14+
({
15+
agentMode: 'DEFAULT',
16+
inputRef: { current: null },
17+
inputValue: '',
18+
isChainInProgressRef: { current: false },
19+
isStreaming: false,
20+
logoutMutation: {} as RouterParams['logoutMutation'],
21+
streamMessageIdRef: { current: null },
22+
addToQueue: mock(() => {}),
23+
hasQueuedMessages: () => false,
24+
clearMessages: mock(() => {}),
25+
saveToHistory: mock(() => {}),
26+
scrollToLatest: mock(() => {}),
27+
sendMessage: mock(async () => {}),
28+
setCanProcessQueue: mock(() => {}),
29+
setInputFocused: mock(() => {}),
30+
setInputValue: mock(() => {}),
31+
setIsAuthenticated: mock(() => {}),
32+
setMessages: mock(() => {}),
33+
setUser: mock(() => {}),
34+
...overrides,
35+
}) as RouterParams
36+
37+
beforeEach(() => {
38+
useChatStore.getState().clearPendingBashMessages()
39+
})
40+
41+
afterEach(() => {
42+
__resetSteeringForTests()
43+
useChatStore.getState().clearPendingBashMessages()
44+
})
45+
46+
describe('mid-turn routing', () => {
47+
test('plain text steers the active run and echoes a bubble immediately', async () => {
48+
activateSteering('run-1')
49+
const params = createMockParams({
50+
inputValue: 'actually use zod for validation',
51+
isStreaming: true,
52+
})
53+
await routeUserPrompt(params)
54+
55+
expect(params.addToQueue).not.toHaveBeenCalled()
56+
expect(params.sendMessage).not.toHaveBeenCalled()
57+
// Bubble echoed at push time so the submit is visible right away.
58+
expect(params.setMessages).toHaveBeenCalledTimes(1)
59+
const drained = drainSteeringMessages('run-1')
60+
expect(drained.map((entry) => entry.text)).toEqual([
61+
'actually use zod for validation',
62+
])
63+
expect(drained[0]!.messageId).toStartWith('user-')
64+
})
65+
66+
test('falls back to the queue when no run is accepting steering', async () => {
67+
const params = createMockParams({
68+
inputValue: 'between chained runs',
69+
isStreaming: true,
70+
})
71+
await routeUserPrompt(params)
72+
73+
expect(params.addToQueue).toHaveBeenCalledTimes(1)
74+
const [queued] = (params.addToQueue as ReturnType<typeof mock>).mock
75+
.calls[0] as [string]
76+
expect(queued).toBe('between chained runs')
77+
})
78+
79+
test('queues instead of steering when earlier messages are already queued', async () => {
80+
activateSteering('run-1')
81+
const params = createMockParams({
82+
inputValue: 'this must not overtake the queue',
83+
isStreaming: true,
84+
hasQueuedMessages: () => true,
85+
})
86+
await routeUserPrompt(params)
87+
88+
expect(drainSteeringMessages('run-1')).toEqual([])
89+
expect(params.addToQueue).toHaveBeenCalledTimes(1)
90+
})
91+
92+
test('queues instead of steering while bash output is pending', async () => {
93+
activateSteering('run-1')
94+
useChatStore.getState().addPendingBashMessage({
95+
command: 'bun test',
96+
output: '3 fail',
97+
} as never)
98+
const params = createMockParams({
99+
inputValue: 'fix those failures',
100+
isStreaming: true,
101+
})
102+
await routeUserPrompt(params)
103+
104+
expect(drainSteeringMessages('run-1')).toEqual([])
105+
expect(params.addToQueue).toHaveBeenCalledTimes(1)
106+
})
107+
108+
test('slash commands never steer', async () => {
109+
activateSteering('run-1')
110+
const params = createMockParams({
111+
inputValue: '/definitely-not-a-command',
112+
isStreaming: true,
113+
})
114+
await routeUserPrompt(params)
115+
116+
expect(drainSteeringMessages('run-1')).toEqual([])
117+
expect(params.addToQueue).toHaveBeenCalledTimes(1)
118+
})
119+
120+
test('idle submits are unaffected and send normally', async () => {
121+
activateSteering('run-1')
122+
const params = createMockParams({ inputValue: 'a fresh task' })
123+
await routeUserPrompt(params)
124+
125+
expect(params.sendMessage).toHaveBeenCalledTimes(1)
126+
expect(drainSteeringMessages('run-1')).toEqual([])
127+
})
128+
})
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
3+
import { useChatStore } from '../../state/chat-store'
4+
import {
5+
__resetSkillRegistryForTests,
6+
__setSkillsForTests,
7+
} from '../../utils/skill-registry'
8+
import { findCommand } from '../command-registry'
9+
import { buildSkillPrompt } from '../prompt-builders'
10+
import { routeUserPrompt } from '../router'
11+
12+
import type { RouterParams } from '../command-registry'
13+
import type { SkillDefinition } from '@codebuff/common/types/skill'
14+
15+
const TEST_SKILL: SkillDefinition = {
16+
name: 'release-notes',
17+
description: 'Draft release notes from recent commits',
18+
content:
19+
'---\nname: release-notes\ndescription: Draft release notes\n---\n\nDo the thing.',
20+
filePath: '/tmp/skills/release-notes/SKILL.md',
21+
}
22+
23+
const createMockParams = (overrides: Partial<RouterParams> = {}): RouterParams =>
24+
({
25+
agentMode: 'DEFAULT',
26+
inputRef: { current: null },
27+
inputValue: '',
28+
isChainInProgressRef: { current: false },
29+
isStreaming: false,
30+
logoutMutation: {} as RouterParams['logoutMutation'],
31+
streamMessageIdRef: { current: null },
32+
addToQueue: mock(() => {}),
33+
clearMessages: mock(() => {}),
34+
saveToHistory: mock(() => {}),
35+
scrollToLatest: mock(() => {}),
36+
sendMessage: mock(async () => {}),
37+
setCanProcessQueue: mock(() => {}),
38+
setInputFocused: mock(() => {}),
39+
setInputValue: mock(() => {}),
40+
setIsAuthenticated: mock(() => {}),
41+
setMessages: mock(() => {}),
42+
setUser: mock(() => {}),
43+
...overrides,
44+
}) as RouterParams
45+
46+
const resetChatStore = () => {
47+
useChatStore.getState().setInputMode('default')
48+
useChatStore.getState().setPendingSkillName(null)
49+
}
50+
51+
beforeEach(() => {
52+
__setSkillsForTests({ [TEST_SKILL.name]: TEST_SKILL })
53+
resetChatStore()
54+
})
55+
56+
afterEach(() => {
57+
__resetSkillRegistryForTests()
58+
resetChatStore()
59+
})
60+
61+
describe('/skill:<name> command', () => {
62+
test('bare invocation enters skill input mode instead of sending', async () => {
63+
const command = findCommand('skill:release-notes')
64+
expect(command).toBeDefined()
65+
66+
const params = createMockParams({ inputValue: '/skill:release-notes' })
67+
await command!.handler(params, '')
68+
69+
expect(useChatStore.getState().inputMode).toBe('skill')
70+
expect(useChatStore.getState().pendingSkillName).toBe('release-notes')
71+
expect(params.sendMessage).not.toHaveBeenCalled()
72+
expect(params.addToQueue).not.toHaveBeenCalled()
73+
})
74+
75+
test('invocation with trailing text sends immediately', async () => {
76+
const command = findCommand('skill:release-notes')
77+
const params = createMockParams({
78+
inputValue: '/skill:release-notes for v2.1 only',
79+
})
80+
await command!.handler(params, 'for v2.1 only')
81+
82+
expect(useChatStore.getState().inputMode).toBe('default')
83+
expect(params.sendMessage).toHaveBeenCalledTimes(1)
84+
const [{ content }] = (params.sendMessage as ReturnType<typeof mock>).mock
85+
.calls[0] as [{ content: string }]
86+
expect(content).toBe(buildSkillPrompt(TEST_SKILL, 'for v2.1 only'))
87+
expect(content).toContain('<skill name="release-notes">')
88+
expect(content).toContain('User request: for v2.1 only')
89+
})
90+
})
91+
92+
describe('skill input mode submit', () => {
93+
const enterSkillMode = () => {
94+
useChatStore.getState().setInputMode('skill')
95+
useChatStore.getState().setPendingSkillName(TEST_SKILL.name)
96+
}
97+
98+
test('submit with text sends the skill plus the user request', async () => {
99+
enterSkillMode()
100+
const params = createMockParams({ inputValue: 'focus on the API changes' })
101+
await routeUserPrompt(params)
102+
103+
expect(useChatStore.getState().inputMode).toBe('default')
104+
expect(useChatStore.getState().pendingSkillName).toBeNull()
105+
expect(params.sendMessage).toHaveBeenCalledTimes(1)
106+
const [{ content }] = (params.sendMessage as ReturnType<typeof mock>).mock
107+
.calls[0] as [{ content: string }]
108+
expect(content).toBe(
109+
buildSkillPrompt(TEST_SKILL, 'focus on the API changes'),
110+
)
111+
})
112+
113+
test('empty submit runs the skill without a user request', async () => {
114+
enterSkillMode()
115+
const params = createMockParams({ inputValue: '' })
116+
await routeUserPrompt(params)
117+
118+
expect(params.sendMessage).toHaveBeenCalledTimes(1)
119+
const [{ content }] = (params.sendMessage as ReturnType<typeof mock>).mock
120+
.calls[0] as [{ content: string }]
121+
expect(content).toBe(buildSkillPrompt(TEST_SKILL, ''))
122+
expect(content).not.toContain('User request:')
123+
})
124+
125+
test('submit while a turn is running queues instead of sending', async () => {
126+
enterSkillMode()
127+
const params = createMockParams({
128+
inputValue: 'and be brief',
129+
isStreaming: true,
130+
})
131+
await routeUserPrompt(params)
132+
133+
expect(params.sendMessage).not.toHaveBeenCalled()
134+
expect(params.addToQueue).toHaveBeenCalledTimes(1)
135+
const [queued] = (params.addToQueue as ReturnType<typeof mock>).mock
136+
.calls[0] as [string]
137+
expect(queued).toBe(buildSkillPrompt(TEST_SKILL, 'and be brief'))
138+
})
139+
140+
test('a skill deleted mid-session reports instead of sending nothing', async () => {
141+
enterSkillMode()
142+
__resetSkillRegistryForTests()
143+
const params = createMockParams({ inputValue: 'anything' })
144+
await routeUserPrompt(params)
145+
146+
expect(params.sendMessage).not.toHaveBeenCalled()
147+
expect(params.setMessages).toHaveBeenCalled()
148+
expect(useChatStore.getState().inputMode).toBe('default')
149+
})
150+
151+
test('leaving skill mode clears the pending skill', () => {
152+
enterSkillMode()
153+
useChatStore.getState().setInputMode('default')
154+
expect(useChatStore.getState().pendingSkillName).toBeNull()
155+
})
156+
})

cli/src/commands/command-registry.ts

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
collectDoctorReport,
1414
formatDoctorReport,
1515
} from './doctor'
16-
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
16+
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs, buildSkillPrompt } from './prompt-builders'
1717
import { handleReasoningCommand } from './reasoning'
1818
import { runBashCommand } from './router'
1919
import { handleUsageCommand } from './usage'
@@ -48,6 +48,9 @@ export type RouterParams = {
4848
logoutMutation: UseMutationResult<boolean, Error, void, unknown>
4949
streamMessageIdRef: React.MutableRefObject<string | null>
5050
addToQueue: (message: string, attachments?: PendingAttachment[]) => void
51+
/** Whether the message queue currently holds anything. Steering checks it
52+
* so a mid-turn submit can't overtake earlier queued submissions. */
53+
hasQueuedMessages?: () => boolean
5154
clearMessages: () => void
5255
saveToHistory: (message: string) => void
5356
scrollToLatest: () => void
@@ -730,36 +733,50 @@ function createSkillCommand(skillName: string): CommandDefinition {
730733
params.saveToHistory(trimmed)
731734
params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
732735

733-
// Build the message content with skill context and optional user args
734-
const skillContext = `<skill name="${skill.name}">
735-
${skill.content}
736-
</skill>`
737-
738-
const userPrompt = `I invoke the following skill:\n\n${skillContext}\n\n`
739-
+ (args.trim()
740-
? `User request: ${args.trim()}`
741-
: '')
742-
743-
// Check streaming/queue state
744-
if (
745-
params.isStreaming ||
746-
params.streamMessageIdRef.current ||
747-
params.isChainInProgressRef.current
748-
) {
749-
const pendingAttachments = capturePendingAttachments()
750-
params.addToQueue(userPrompt, pendingAttachments)
736+
// Bare invocation: like /interview, drop into an input mode so the
737+
// user can add instructions before the skill is sent. Enter with an
738+
// empty composer still runs the skill as-is (the router's skill-mode
739+
// branch), so a no-args run costs one extra keystroke, not a feature.
740+
if (!args.trim()) {
741+
useChatStore.getState().enterSkillMode(skill.name)
751742
params.setInputFocused(true)
752743
params.inputRef.current?.focus()
753744
return
754745
}
755746

756-
params.sendMessage({
757-
content: userPrompt,
758-
agentMode: params.agentMode,
759-
})
760-
setTimeout(() => {
761-
params.scrollToLatest()
762-
}, 0)
747+
dispatchSkillPrompt(params, skill, args)
763748
},
764749
})
765750
}
751+
752+
/**
753+
* Send (or queue, mid-turn) a user-invoked skill prompt. Shared by the
754+
* /skill:<name> args form and the skill input mode's submit (router), so the
755+
* two entry paths for the same feature cannot drift.
756+
*/
757+
export function dispatchSkillPrompt(
758+
params: RouterParams,
759+
skill: { name: string; content: string },
760+
input: string,
761+
): void {
762+
const userPrompt = buildSkillPrompt(skill, input)
763+
764+
if (
765+
params.isStreaming ||
766+
params.streamMessageIdRef.current ||
767+
params.isChainInProgressRef.current
768+
) {
769+
params.addToQueue(userPrompt, capturePendingAttachments())
770+
params.setInputFocused(true)
771+
params.inputRef.current?.focus()
772+
return
773+
}
774+
775+
params.sendMessage({
776+
content: userPrompt,
777+
agentMode: params.agentMode,
778+
})
779+
setTimeout(() => {
780+
params.scrollToLatest()
781+
}, 0)
782+
}

0 commit comments

Comments
 (0)