fix: tighten desktop slash command edge cases

Desktop slash command handling needed a few follow-up guards after adding
CLI parity. Bare /clear remains a local desktop reset, but /clear with
arguments is rejected instead of silently discarding user text. Clear also
resets cached slash commands, compact boundaries preserve CLI-provided text,
and the help panel now discloses when the More section is truncated.

Constraint: Preserve the desktop /clear fast path without accepting accidental arguments
Rejected: Let /clear arguments fall through to the CLI | the CLI command still clears context and would keep the footgun
Confidence: high
Scope-risk: narrow
Directive: Keep desktop-local slash command guards in sync with CLI command argument semantics
Tested: cd desktop && bun run test src/stores/chatStore.test.ts src/__tests__/pages.test.tsx --run -t "clears local desktop chat state|ActiveSession routes /help"
Tested: bun test src/server/__tests__/conversations.test.ts
Tested: cd desktop && bun run lint
Tested: git diff --check
Not-tested: Full pages.test.tsx because unrelated locale-sensitive assertions are already present in that file
This commit is contained in:
程序员阿江(Relakkes) 2026-04-27 18:49:01 +08:00
parent caa164b62c
commit 1631c449f6
6 changed files with 79 additions and 8 deletions

View File

@ -474,7 +474,13 @@ describe('Content-only pages render without errors', () => {
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [{ name: 'cost', description: 'Show token usage and costs' }],
slashCommands: [
{ name: 'cost', description: 'Show token usage and costs' },
...Array.from({ length: 14 }, (_, index) => ({
name: `extra-${index + 1}`,
description: `Extra command ${index + 1}`,
})),
],
agentTaskNotifications: {},
elapsedTimer: null,
},
@ -492,6 +498,7 @@ describe('Content-only pages render without errors', () => {
expect(screen.getByText('Slash commands')).toBeInTheDocument()
expect(screen.getByText('/clear')).toBeInTheDocument()
expect(screen.getByText('/cost')).toBeInTheDocument()
expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument()
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })

View File

@ -319,6 +319,10 @@ function HelpPanel({
const otherCommands = (commands ?? [])
.filter((command) => !groupedNames.has(command.name))
.slice(0, 12)
const hiddenOtherCommandCount = Math.max(
0,
(commands ?? []).filter((command) => !groupedNames.has(command.name)).length - otherCommands.length,
)
const renderCommand = (command: SlashCommandOption) => (
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-4 py-3 first:border-t-0">
@ -355,6 +359,11 @@ function HelpPanel({
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{otherCommands.map(renderCommand)}
</div>
{hiddenOtherCommandCount > 0 && (
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{hiddenOtherCommandCount} more commands available. Type / to search the full command list.
</p>
)}
</section>
)}
</div>

View File

@ -224,7 +224,7 @@ describe('chatStore history mapping', () => {
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
slashCommands: [{ name: 'old-command', description: 'Old command' }],
agentTaskNotifications: {},
elapsedTimer: null,
},
@ -519,6 +519,7 @@ describe('chatStore history mapping', () => {
expect(session?.streamingText).toBe('')
expect(session?.chatState).toBe('idle')
expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 })
expect(session?.slashCommands).toEqual([])
expect(clearTasksMock).toHaveBeenCalled()
})

View File

@ -785,6 +785,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
elapsedSeconds: 0,
statusVerb: '',
tokenUsage: { input_tokens: 0, output_tokens: 0 },
slashCommands: [],
}))
useCLITaskStore.getState().clearTasks()
useSessionStore.getState().updateSessionTitle(sessionId, 'New Session')

View File

@ -235,7 +235,7 @@ describe('WebSocket Chat Integration', () => {
}
}
async function runTurn(sessionId: string, content: string): Promise<any[]> {
async function runTurn(sessionId: string, content: string, allowError = false): Promise<any[]> {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
@ -254,7 +254,11 @@ describe('WebSocket Chat Integration', () => {
if (msg.type === 'error') {
clearTimeout(timeout)
ws.close()
reject(new Error(msg.message))
if (allowError) {
resolve()
} else {
reject(new Error(msg.message))
}
}
if (msg.type === 'message_complete') {
clearTimeout(timeout)
@ -544,6 +548,33 @@ describe('WebSocket Chat Integration', () => {
expect(body.messages).toEqual([])
})
it('should reject /clear arguments without clearing the desktop session', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd() }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
await runTurn(sessionId, 'message before invalid clear')
const clearTurn = await runTurn(sessionId, '/clear please keep this', true)
expect(
clearTurn.some(
(m) => m.type === 'error' && m.code === 'INVALID_SLASH_COMMAND_ARGS',
),
).toBe(true)
expect(
clearTurn.some(
(m) => m.type === 'system_notification' && m.subtype === 'session_cleared',
),
).toBe(false)
const nextTurn = await runTurn(sessionId, 'message after invalid clear')
expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true)
})
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',

View File

@ -219,7 +219,18 @@ async function handleUserMessage(
sessionStopRequested.delete(sessionId)
clearPrewarmState(sessionId)
if (getDesktopSlashCommandName(message.content) === 'clear') {
const desktopSlashCommand = getDesktopSlashCommand(message.content)
if (desktopSlashCommand?.commandName === 'clear' && desktopSlashCommand.args.trim()) {
sendMessage(ws, {
type: 'error',
message: 'The /clear command does not accept arguments.',
code: 'INVALID_SLASH_COMMAND_ARGS',
})
sendMessage(ws, { type: 'status', state: 'idle' })
return
}
if (desktopSlashCommand?.commandName === 'clear') {
await handleDesktopClearCommand(ws)
return
}
@ -1080,7 +1091,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
return [{
type: 'system_notification',
subtype: 'compact_boundary',
message: 'Context compacted',
message: getCompactBoundaryMessage(cliMsg),
data: cliMsg.compact_metadata ?? cliMsg,
}]
}
@ -1107,9 +1118,10 @@ function sendError(ws: ServerWebSocket<WebSocketData>, message: string, code: st
sendMessage(ws, { type: 'error', message, code })
}
function getDesktopSlashCommandName(content: string): string | null {
function getDesktopSlashCommand(content: string): ReturnType<typeof parseSlashCommand> {
const parsed = parseSlashCommand(content.trim())
return parsed?.commandName ?? null
if (!parsed || parsed.isMcp) return null
return parsed
}
function extractLocalCommandOutput(content: unknown): string | null {
@ -1139,6 +1151,16 @@ function extractTaggedContent(raw: string, tag: string): string | null {
return match?.[1]?.trim() ?? null
}
function getCompactBoundaryMessage(cliMsg: any): string {
const message = typeof cliMsg?.message === 'string' ? cliMsg.message.trim() : ''
if (message) return message
const content = typeof cliMsg?.content === 'string' ? cliMsg.content.trim() : ''
if (content) return content
return 'Context compacted'
}
function rebindSessionOutput(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,