mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
caa164b62c
commit
1631c449f6
@ -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 })
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user