mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
commit
f5ce69ba71
@ -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 }) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -445,6 +445,7 @@ function mergeSessionSlashCommands(
|
||||
merged.set(command.name, {
|
||||
name: command.name,
|
||||
description: command.description || '',
|
||||
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user