diff --git a/src/server/routers/claude.ts b/src/server/routers/claude.ts index 74605d56..389947bf 100644 --- a/src/server/routers/claude.ts +++ b/src/server/routers/claude.ts @@ -10,6 +10,7 @@ import { answerUserInput, hasPendingInput, getPendingInput, + getSessionCommands, } from '../services/claude-runner'; import { loadMergedSessionSettings } from '../services/settings-merger'; import { getSessionWorkingDir } from '../services/worktree-manager'; @@ -257,9 +258,7 @@ export const claudeRouter = router({ getCommands: protectedProcedure .input(z.object({ sessionId: z.string().uuid() })) - .query(async () => { - // Commands are now emitted via SSE during query initialization. - // This endpoint returns empty for now - the frontend gets commands via SSE. - return { commands: [] as Array<{ name: string; description: string; argumentHint: string }> }; + .query(async ({ input }) => { + return { commands: getSessionCommands(input.sessionId) }; }), }); diff --git a/src/server/services/claude-runner.test.ts b/src/server/services/claude-runner.test.ts index db52b81f..af1b52da 100644 --- a/src/server/services/claude-runner.test.ts +++ b/src/server/services/claude-runner.test.ts @@ -43,6 +43,8 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ import { buildSystemPrompt, + mergeSlashCommands, + getSessionCommands, answerUserInput, hasPendingInput, isClaudeRunning, @@ -119,6 +121,74 @@ describe('claude-runner', () => { }); }); + describe('mergeSlashCommands', () => { + it('should return existing commands when no new names provided', () => { + const existing = [{ name: 'commit', description: 'Commit changes', argumentHint: '' }]; + const result = mergeSlashCommands(existing, []); + expect(result).toEqual(existing); + }); + + it('should add new commands not in existing list', () => { + const existing = [{ name: 'commit', description: 'Commit changes', argumentHint: '' }]; + const result = mergeSlashCommands(existing, ['compact', 'cost']); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + name: 'commit', + description: 'Commit changes', + argumentHint: '', + }); + expect(result[1]).toEqual({ name: 'compact', description: '', argumentHint: '' }); + expect(result[2]).toEqual({ name: 'cost', description: '', argumentHint: '' }); + }); + + it('should not duplicate commands already in existing list', () => { + const existing = [ + { name: 'commit', description: 'Commit changes', argumentHint: '' }, + { name: 'review', description: 'Review code', argumentHint: '' }, + ]; + const result = mergeSlashCommands(existing, ['commit', 'review', 'compact']); + expect(result).toHaveLength(3); + // Original rich metadata preserved + expect(result[0]).toEqual({ + name: 'commit', + description: 'Commit changes', + argumentHint: '', + }); + expect(result[1]).toEqual({ + name: 'review', + description: 'Review code', + argumentHint: '', + }); + // New command added with empty metadata + expect(result[2]).toEqual({ name: 'compact', description: '', argumentHint: '' }); + }); + + it('should handle empty existing commands', () => { + const result = mergeSlashCommands([], ['compact', 'cost']); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ name: 'compact', description: '', argumentHint: '' }); + expect(result[1]).toEqual({ name: 'cost', description: '', argumentHint: '' }); + }); + + it('should handle both empty', () => { + const result = mergeSlashCommands([], []); + expect(result).toEqual([]); + }); + + it('should deduplicate names within slashCommandNames', () => { + const result = mergeSlashCommands([], ['compact', 'compact', 'cost', 'cost']); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ name: 'compact', description: '', argumentHint: '' }); + expect(result[1]).toEqual({ name: 'cost', description: '', argumentHint: '' }); + }); + }); + + describe('getSessionCommands', () => { + it('should return empty array for nonexistent sessions', () => { + expect(getSessionCommands('nonexistent-session')).toEqual([]); + }); + }); + describe('answerUserInput', () => { it('should return false when no pending input exists', () => { const result = answerUserInput('nonexistent-session', { q: 'answer' }); diff --git a/src/server/services/claude-runner.ts b/src/server/services/claude-runner.ts index 0a4b4431..b76e8382 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -10,9 +10,9 @@ * parking a promise that resolves when the user answers via tRPC. */ -import { query, type McpServerConfig } from '@anthropic-ai/claude-agent-sdk'; +import { query, type McpServerConfig, type SlashCommand } from '@anthropic-ai/claude-agent-sdk'; import { prisma } from '@/lib/prisma'; -import { getMessageType } from '@/lib/claude-messages'; +import { getMessageType, SystemInitContentSchema } from '@/lib/claude-messages'; import { extractRepoFullName } from '@/lib/utils'; import { v4 as uuid, v5 as uuidv5 } from 'uuid'; import { sseEvents } from './events'; @@ -23,6 +23,35 @@ import { StreamAccumulator } from './stream-accumulator'; const log = createLogger('claude-runner'); +/** + * Merges slash command names from the system init message with rich SlashCommand + * objects from `supportedCommands()`. + * + * The SDK's `supportedCommands()` only returns "skills" — a subset with rich + * metadata (name, description, argumentHint). The system init message's + * `slash_commands` array contains ALL available commands as bare strings. + * + * This function merges both: keeping the rich metadata for known skills and + * synthesizing minimal SlashCommand objects for commands that only appear in + * the slash_commands list. + */ +export function mergeSlashCommands( + existingCommands: SlashCommand[], + slashCommandNames: string[] +): SlashCommand[] { + const existingNames = new Set(existingCommands.map((cmd) => cmd.name)); + const merged = [...existingCommands]; + + for (const name of slashCommandNames) { + if (!existingNames.has(name)) { + merged.push({ name, description: '', argumentHint: '' }); + existingNames.add(name); + } + } + + return merged; +} + // Namespace UUID for generating deterministic IDs from error content const ERROR_LINE_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; @@ -49,6 +78,8 @@ interface SessionState { pendingInput: PendingUserInput | null; /** Working directory for this session */ workingDir: string; + /** Discovered slash commands (cached for getCommands endpoint) */ + commands: SlashCommand[]; } /** Active sessions tracked in memory */ @@ -152,6 +183,7 @@ function getSessionState(sessionId: string, workingDir: string): SessionState { currentQuery: null, pendingInput: null, workingDir, + commands: [], }; sessions.set(sessionId, state); } @@ -351,7 +383,45 @@ export async function runClaudeCommand(options: RunClaudeCommandOptions): Promis const q = query({ prompt, options: sdkOptions }); state.currentQuery = q; + // Fetch rich command metadata from the SDK asynchronously. + // The init message may arrive before this resolves, so we merge + // with any names already discovered to avoid losing commands. + void q + .supportedCommands() + .then((commands) => { + const alreadyDiscovered = state.commands.map((c) => c.name); + state.commands = mergeSlashCommands(commands, alreadyDiscovered); + sseEvents.emitCommands(sessionId, state.commands); + log.info('Emitted supported commands from SDK', { + sessionId, + count: state.commands.length, + }); + }) + .catch((err) => { + log.debug('Failed to fetch supportedCommands', { sessionId, error: toError(err).message }); + }); + for await (const message of q) { + // Extract slash_commands from system init messages and merge with + // rich commands from supportedCommands(). The SDK's supportedCommands() + // only returns "skills", but the system init message contains all + // slash commands (e.g. /compact, /cost, /review, etc.) + const initParsed = SystemInitContentSchema.safeParse(message); + if (initParsed.success && initParsed.data.slash_commands) { + const merged = mergeSlashCommands(state.commands, initParsed.data.slash_commands); + const newNames = new Set(merged.map((c) => c.name)); + const oldNames = new Set(state.commands.map((c) => c.name)); + const hasNewCommands = [...newNames].some((n) => !oldNames.has(n)); + if (hasNewCommands) { + state.commands = merged; + sseEvents.emitCommands(sessionId, merged); + log.info('Merged slash_commands from system init', { + sessionId, + total: merged.length, + }); + } + } + // Handle stream_events for partial messages if (message.type === 'stream_event') { const partial = accumulator.accumulate( @@ -504,6 +574,14 @@ export function answerUserInput(sessionId: string, answers: Record