From 010a4bf9d80ce7b7a25502e49b98744941d769d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 14:38:40 +0800 Subject: [PATCH] fix: preserve custom slash commands after live updates The desktop command list could be replaced by a partial live CLI update after a turn, and the server fallback only knew about skills. Keep the client list stable while refreshing from the authoritative session endpoint, and include legacy .claude/commands entries in that endpoint. Constraint: Claude Code custom slash commands still use .claude/commands/*.md alongside newer skill commands. Rejected: Only union client-side updates | would still miss custom commands before CLI init and lose argument hints from the authoritative API. Confidence: high Scope-risk: moderate Directive: Keep session slash command fallback aware of both skills and legacy command directories. Tested: bun test src/server/__tests__/sessions.test.ts -t "slash-commands" Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts Tested: bun run check:desktop Tested: bun run check:server Tested: bun run check:native Not-tested: bun run verify remains red due unrelated/flaky coverage lane failures outside this change. Related: https://github.com/NanmiCoder/cc-haha/issues/495 --- desktop/src/api/sessions.ts | 2 +- desktop/src/stores/chatStore.test.ts | 33 +++++++++ desktop/src/stores/chatStore.ts | 52 ++++++++++++- src/server/__tests__/sessions.test.ts | 101 ++++++++++++++++++++++++++ src/server/api/sessions.ts | 1 + src/server/api/skills.ts | 42 ++++++++++- 6 files changed, 225 insertions(+), 6 deletions(-) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 9c76649b..6687b149 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -341,7 +341,7 @@ export const sessionsApi = { }, getSlashCommands(sessionId: string) { - return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`) + return api.get<{ commands: Array<{ name: string; description: string; argumentHint?: string }> }>(`/api/sessions/${sessionId}/slash-commands`) }, getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number; contextOnly?: boolean }) { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 1880b2a1..cbddbe71 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -976,6 +976,39 @@ describe('chatStore history mapping', () => { ]) }) + it('refreshes merged slash commands when a live CLI update omits project commands', async () => { + const cliCommand = { name: 'builtin-help', description: 'Built-in command' } + const projectCommand = { name: 'project-probe', description: 'Project custom command' } + + vi.mocked(sessionsApi.getSlashCommands).mockClear() + vi.mocked(sessionsApi.getSlashCommands).mockResolvedValueOnce({ + commands: [cliCommand, projectCommand], + }) + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + slashCommands: [projectCommand], + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'slash_commands', + data: [cliCommand], + }) + + await Promise.resolve() + + expect(sessionsApi.getSlashCommands).toHaveBeenCalledTimes(1) + expect(sessionsApi.getSlashCommands).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.slashCommands).toEqual([ + cliCommand, + projectCommand, + ]) + }) + it('syncs live TodoWrite tool input into the task store for that session', () => { const todos = [{ content: 'Live todo', status: 'in_progress' }] useChatStore.setState({ diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 443e4f98..51f87c90 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -331,6 +331,41 @@ function updateSessionIn( return { ...sessions, [sessionId]: { ...session, ...updater(session) } } } +type SlashCommandState = PerSessionState['slashCommands'][number] + +function normalizeSlashCommand(command: unknown): SlashCommandState | null { + if (!command || typeof command !== 'object') return null + const candidate = command as { name?: unknown; description?: unknown; argumentHint?: unknown } + if (typeof candidate.name !== 'string' || !candidate.name) return null + return { + name: candidate.name, + description: typeof candidate.description === 'string' ? candidate.description : '', + ...(typeof candidate.argumentHint === 'string' && candidate.argumentHint + ? { argumentHint: candidate.argumentHint } + : {}), + } +} + +function normalizeSlashCommandList(commands: ReadonlyArray): SlashCommandState[] { + return commands + .map(normalizeSlashCommand) + .filter((command): command is SlashCommandState => command !== null) +} + +function mergeSlashCommandUpdates( + current: ReadonlyArray, + incoming: ReadonlyArray, +): SlashCommandState[] { + const merged = new Map() + for (const command of current) { + if (command.name) merged.set(command.name, command) + } + for (const command of incoming) { + if (command.name) merged.set(command.name, command) + } + return [...merged.values()] +} + async function fetchAndMapSessionHistory(sessionId: string) { const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) @@ -992,7 +1027,22 @@ export const useChatStore = create((set, get) => ({ break case 'system_notification': if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { - update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string; argumentHint?: string }> })) + const incomingCommands = normalizeSlashCommandList(msg.data) + update((session) => ({ + slashCommands: mergeSlashCommandUpdates(session.slashCommands, incomingCommands), + })) + void sessionsApi.getSlashCommands(sessionId) + .then(({ commands }) => { + if (!get().sessions[sessionId]) return + set((s) => ({ + sessions: updateSessionIn(s.sessions, sessionId, () => ({ + slashCommands: normalizeSlashCommandList(commands), + })), + })) + }) + .catch(() => { + // Keep the last known local + CLI union when the authoritative refresh is unavailable. + }) } if (msg.subtype === 'session_cleared') { const session = get().sessions[sessionId] diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index ffd0eee9..c269aef3 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -18,6 +18,7 @@ import { sanitizePath } from '../../utils/sessionStoragePortable.js' import { clearInstalledPluginsCache } from '../../utils/plugins/installedPluginsManager.js' import { clearPluginCache } from '../../utils/plugins/pluginLoader.js' import { resetSettingsCache } from '../../utils/settings/settingsCache.js' +import { updateSessionSlashCommands } from '../ws/handler.js' // ============================================================================ // Test helpers @@ -114,6 +115,19 @@ async function writeSkill( ) } +async function writeLegacySlashCommand( + commandsDir: string, + commandName: string, + description: string, +): Promise { + await fs.mkdir(commandsDir, { recursive: true }) + await fs.writeFile( + path.join(commandsDir, `${commandName}.md`), + ['---', `description: ${description}`, 'argument-hint: ', '---', '', `Run ${commandName}.`].join('\n'), + 'utf-8', + ) +} + function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { cwd, @@ -2111,6 +2125,93 @@ describe('Sessions API', () => { ) }) + it('GET /api/sessions/:id/slash-commands should include legacy custom commands before CLI init', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeef' + const workDir = path.join(tmpDir, 'workspace', 'app') + + await writeLegacySlashCommand( + path.join(tmpDir, 'commands'), + 'user-probe', + 'User custom slash command', + ) + await writeLegacySlashCommand( + path.join(workDir, '.claude', 'commands'), + 'project-probe', + 'Project custom slash command', + ) + + await writeSessionFile('-tmp-api-test', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry(workDir), + ]) + + clearCommandsCache() + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + commands: Array<{ name: string; description: string; argumentHint?: string }> + } + + expect(body.commands).toContainEqual( + expect.objectContaining({ + name: 'user-probe', + description: 'User custom slash command', + argumentHint: '', + }), + ) + expect(body.commands).toContainEqual( + expect.objectContaining({ + name: 'project-probe', + description: 'Project custom slash command', + argumentHint: '', + }), + ) + }) + + it('GET /api/sessions/:id/slash-commands should preserve cached command argument hints when merging custom commands', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeef001' + const workDir = path.join(tmpDir, 'workspace', 'app') + + await writeLegacySlashCommand( + path.join(workDir, '.claude', 'commands'), + 'project-probe', + 'Project custom slash command', + ) + + await writeSessionFile('-tmp-api-test', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry(workDir), + ]) + + updateSessionSlashCommands( + sessionId, + [{ name: 'builtin-probe', description: 'Cached CLI command', argumentHint: '' }], + { notifyClient: false }, + ) + clearCommandsCache() + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + commands: Array<{ name: string; description: string; argumentHint?: string }> + } + + expect(body.commands).toContainEqual({ + name: 'builtin-probe', + description: 'Cached CLI command', + argumentHint: '', + }) + expect(body.commands).toContainEqual( + expect.objectContaining({ + name: 'project-probe', + description: 'Project custom slash command', + }), + ) + }) + it('GET /api/sessions/:id/slash-commands should include enabled plugin skills before CLI init', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff' const workDir = path.join(tmpDir, 'workspace', 'app') diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 4fe19747..4472f55b 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -445,6 +445,7 @@ function mergeSessionSlashCommands( merged.set(command.name, { name: command.name, description: command.description || '', + ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), }) } diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 1d268588..5de04159 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -13,6 +13,7 @@ import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js' import { getCwd } from '../../utils/cwd.js' import { loadAllPlugins, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' +import { getSkillDirCommands } from '../../skills/loadSkillsDir.js' import type { LoadedPlugin } from '../../types/plugin.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' @@ -278,6 +279,22 @@ type PluginSkillLocation = { export type SkillSlashCommand = { name: string description: string + argumentHint?: string +} + +async function collectLegacySlashCommands(cwd: string): Promise { + const commands = await getSkillDirCommands(cwd) + return commands + .filter((command) => + command.type === 'prompt' && + command.loadedFrom === 'commands_DEPRECATED' && + command.userInvocable !== false && + !command.isHidden) + .map((command) => ({ + name: command.name, + description: command.description || '', + ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), + })) } function buildPluginSkillName(pluginName: string, skillDir: string): string { @@ -381,12 +398,29 @@ async function collectAllSkills(cwd?: string): Promise { } export async function listSkillSlashCommands(cwd?: string): Promise { - return (await collectAllSkills(cwd)) - .filter((skill) => skill.userInvocable) - .map((skill) => ({ + const requestedCwd = cwd || getCwd() + const [skills, legacyCommands] = await Promise.all([ + collectAllSkills(requestedCwd), + collectLegacySlashCommands(requestedCwd), + ]) + + const byName = new Map() + + for (const skill of skills) { + if (!skill.userInvocable) continue + byName.set(skill.name, { name: skill.name, description: skill.description || '', - })) + }) + } + + for (const command of legacyCommands) { + if (!byName.has(command.name)) { + byName.set(command.name, command) + } + } + + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)) } // ─── Router ──────────────────────────────────────────────────────────────────