mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23: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 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
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: {},
|
agentTaskNotifications: {},
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
},
|
},
|
||||||
@ -492,6 +498,7 @@ describe('Content-only pages render without errors', () => {
|
|||||||
expect(screen.getByText('Slash commands')).toBeInTheDocument()
|
expect(screen.getByText('Slash commands')).toBeInTheDocument()
|
||||||
expect(screen.getByText('/clear')).toBeInTheDocument()
|
expect(screen.getByText('/clear')).toBeInTheDocument()
|
||||||
expect(screen.getByText('/cost')).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 })
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||||
|
|||||||
@ -319,6 +319,10 @@ function HelpPanel({
|
|||||||
const otherCommands = (commands ?? [])
|
const otherCommands = (commands ?? [])
|
||||||
.filter((command) => !groupedNames.has(command.name))
|
.filter((command) => !groupedNames.has(command.name))
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
|
const hiddenOtherCommandCount = Math.max(
|
||||||
|
0,
|
||||||
|
(commands ?? []).filter((command) => !groupedNames.has(command.name)).length - otherCommands.length,
|
||||||
|
)
|
||||||
|
|
||||||
const renderCommand = (command: SlashCommandOption) => (
|
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">
|
<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)]">
|
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
{otherCommands.map(renderCommand)}
|
{otherCommands.map(renderCommand)}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -224,7 +224,7 @@ describe('chatStore history mapping', () => {
|
|||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
slashCommands: [],
|
slashCommands: [{ name: 'old-command', description: 'Old command' }],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
},
|
},
|
||||||
@ -519,6 +519,7 @@ describe('chatStore history mapping', () => {
|
|||||||
expect(session?.streamingText).toBe('')
|
expect(session?.streamingText).toBe('')
|
||||||
expect(session?.chatState).toBe('idle')
|
expect(session?.chatState).toBe('idle')
|
||||||
expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 })
|
expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 })
|
||||||
|
expect(session?.slashCommands).toEqual([])
|
||||||
expect(clearTasksMock).toHaveBeenCalled()
|
expect(clearTasksMock).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -785,6 +785,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
slashCommands: [],
|
||||||
}))
|
}))
|
||||||
useCLITaskStore.getState().clearTasks()
|
useCLITaskStore.getState().clearTasks()
|
||||||
useSessionStore.getState().updateSessionTitle(sessionId, 'New Session')
|
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 messages: any[] = []
|
||||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||||
|
|
||||||
@ -254,7 +254,11 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
if (msg.type === 'error') {
|
if (msg.type === 'error') {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
ws.close()
|
ws.close()
|
||||||
reject(new Error(msg.message))
|
if (allowError) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
reject(new Error(msg.message))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (msg.type === 'message_complete') {
|
if (msg.type === 'message_complete') {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@ -544,6 +548,33 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
expect(body.messages).toEqual([])
|
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 () => {
|
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
|
||||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -219,7 +219,18 @@ async function handleUserMessage(
|
|||||||
sessionStopRequested.delete(sessionId)
|
sessionStopRequested.delete(sessionId)
|
||||||
clearPrewarmState(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)
|
await handleDesktopClearCommand(ws)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1080,7 +1091,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
|||||||
return [{
|
return [{
|
||||||
type: 'system_notification',
|
type: 'system_notification',
|
||||||
subtype: 'compact_boundary',
|
subtype: 'compact_boundary',
|
||||||
message: 'Context compacted',
|
message: getCompactBoundaryMessage(cliMsg),
|
||||||
data: cliMsg.compact_metadata ?? 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 })
|
sendMessage(ws, { type: 'error', message, code })
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDesktopSlashCommandName(content: string): string | null {
|
function getDesktopSlashCommand(content: string): ReturnType<typeof parseSlashCommand> {
|
||||||
const parsed = parseSlashCommand(content.trim())
|
const parsed = parseSlashCommand(content.trim())
|
||||||
return parsed?.commandName ?? null
|
if (!parsed || parsed.isMcp) return null
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLocalCommandOutput(content: unknown): string | null {
|
function extractLocalCommandOutput(content: unknown): string | null {
|
||||||
@ -1139,6 +1151,16 @@ function extractTaggedContent(raw: string, tag: string): string | null {
|
|||||||
return match?.[1]?.trim() ?? 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(
|
function rebindSessionOutput(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
ws: ServerWebSocket<WebSocketData>,
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user