Merge custom slash command visibility fix

Bring the detached worktree fix into local main so desktop sessions keep legacy custom slash commands visible across live CLI updates.

Constraint: Local main contains an additional local commit, so this is a real merge rather than a fast-forward.
Confidence: high
Scope-risk: moderate
Directive: Preserve both skill and legacy command fallback behavior when changing slash command session state.
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: Full verify remains blocked by unrelated/flaky coverage lane failures observed before merge.
Related: 010a4bf9
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 14:38:56 +08:00
commit f5ce69ba71
6 changed files with 225 additions and 6 deletions

View File

@ -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 }) {

View File

@ -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({

View File

@ -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<unknown>): SlashCommandState[] {
return commands
.map(normalizeSlashCommand)
.filter((command): command is SlashCommandState => command !== null)
}
function mergeSlashCommandUpdates(
current: ReadonlyArray<SlashCommandState>,
incoming: ReadonlyArray<SlashCommandState>,
): SlashCommandState[] {
const merged = new Map<string, SlashCommandState>()
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<ChatStore>((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]

View File

@ -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<void> {
await fs.mkdir(commandsDir, { recursive: true })
await fs.writeFile(
path.join(commandsDir, `${commandName}.md`),
['---', `description: ${description}`, 'argument-hint: <topic>', '---', '', `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: '<topic>',
}),
)
expect(body.commands).toContainEqual(
expect.objectContaining({
name: 'project-probe',
description: 'Project custom slash command',
argumentHint: '<topic>',
}),
)
})
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: '<value>' }],
{ 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: '<value>',
})
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')

View File

@ -445,6 +445,7 @@ function mergeSessionSlashCommands(
merged.set(command.name, {
name: command.name,
description: command.description || '',
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
})
}

View File

@ -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<SkillSlashCommand[]> {
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<SkillMeta[]> {
}
export async function listSkillSlashCommands(cwd?: string): Promise<SkillSlashCommand[]> {
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<string, SkillSlashCommand>()
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 ──────────────────────────────────────────────────────────────────