From 40fd853c6138f852c03d7a23cf1abae6136ac1b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 13:29:51 -0700 Subject: [PATCH 1/3] Fix slash command autofill broken after containerless rewrite When claude-runner.ts was rewritten to use the Agent SDK directly in-process (removing the old agent-service container architecture), the slash command extraction and SSE emission logic was not migrated. The UI infrastructure (PromptInput autocomplete, SSE subscriptions) was intact but the server never populated commands. Fix by: 1. Calling q.supportedCommands() after starting a query to get rich command metadata (name, description, argumentHint) 2. Extracting slash_commands from the system init message and merging with the rich commands (init message has ALL commands, but as bare strings without descriptions) 3. Emitting merged commands via sseEvents.emitCommands() Also adds mergeSlashCommands() helper with tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/services/claude-runner.test.ts | 56 +++++++++++++++++++ src/server/services/claude-runner.ts | 65 ++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/server/services/claude-runner.test.ts b/src/server/services/claude-runner.test.ts index db52b81f..16c06d66 100644 --- a/src/server/services/claude-runner.test.ts +++ b/src/server/services/claude-runner.test.ts @@ -43,6 +43,7 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ import { buildSystemPrompt, + mergeSlashCommands, answerUserInput, hasPendingInput, isClaudeRunning, @@ -119,6 +120,61 @@ 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([]); + }); + }); + 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..fbcc4993 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -10,7 +10,7 @@ * 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 { extractRepoFullName } from '@/lib/utils'; @@ -23,6 +23,34 @@ 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: '' }); + } + } + + return merged; +} + // Namespace UUID for generating deterministic IDs from error content const ERROR_LINE_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; @@ -351,7 +379,42 @@ export async function runClaudeCommand(options: RunClaudeCommandOptions): Promis const q = query({ prompt, options: sdkOptions }); state.currentQuery = q; + // Fetch rich command metadata from the SDK (fire-and-forget) + let supportedCommands: SlashCommand[] = []; + void q + .supportedCommands() + .then((commands) => { + supportedCommands = commands; + sseEvents.emitCommands(sessionId, commands); + log.info('Emitted supported commands from SDK', { sessionId, count: 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.) + if ( + message.type === 'system' && + 'subtype' in message && + message.subtype === 'init' && + 'slash_commands' in message && + Array.isArray(message.slash_commands) + ) { + const merged = mergeSlashCommands(supportedCommands, message.slash_commands as string[]); + if (merged.length > supportedCommands.length) { + supportedCommands = 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( From 5ec6246d3b78c35f92477eeaaa8beadf4830944e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 13:39:10 -0700 Subject: [PATCH 2/3] Fix race condition and duplicate handling in slash command merge Address PR review feedback: - Fix race condition: when supportedCommands() resolves after the system init message, merge with already-discovered names instead of overwriting them - Fix duplicate handling: add names to the existingNames set during iteration to prevent duplicates from slashCommandNames array Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/services/claude-runner.test.ts | 7 +++++++ src/server/services/claude-runner.ts | 15 +++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/server/services/claude-runner.test.ts b/src/server/services/claude-runner.test.ts index 16c06d66..43091b1e 100644 --- a/src/server/services/claude-runner.test.ts +++ b/src/server/services/claude-runner.test.ts @@ -173,6 +173,13 @@ describe('claude-runner', () => { 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('answerUserInput', () => { diff --git a/src/server/services/claude-runner.ts b/src/server/services/claude-runner.ts index fbcc4993..d2a59373 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -45,6 +45,7 @@ export function mergeSlashCommands( for (const name of slashCommandNames) { if (!existingNames.has(name)) { merged.push({ name, description: '', argumentHint: '' }); + existingNames.add(name); } } @@ -379,14 +380,20 @@ export async function runClaudeCommand(options: RunClaudeCommandOptions): Promis const q = query({ prompt, options: sdkOptions }); state.currentQuery = q; - // Fetch rich command metadata from the SDK (fire-and-forget) + // Fetch rich command metadata from the SDK (fire-and-forget). + // The init message may arrive before this resolves, so we merge + // with any names already discovered to avoid losing commands. let supportedCommands: SlashCommand[] = []; void q .supportedCommands() .then((commands) => { - supportedCommands = commands; - sseEvents.emitCommands(sessionId, commands); - log.info('Emitted supported commands from SDK', { sessionId, count: commands.length }); + const alreadyDiscovered = supportedCommands.map((c) => c.name); + supportedCommands = mergeSlashCommands(commands, alreadyDiscovered); + sseEvents.emitCommands(sessionId, supportedCommands); + log.info('Emitted supported commands from SDK', { + sessionId, + count: supportedCommands.length, + }); }) .catch((err) => { log.debug('Failed to fetch supportedCommands', { sessionId, error: toError(err).message }); From c9906619d27d7880ad723fcda80f654a9b4e569a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 13:43:50 -0700 Subject: [PATCH 3/3] Address code review: cache commands, use Zod, fix heuristic - Cache discovered commands on SessionState so the getCommands endpoint returns them on page reload (not just via SSE) - Use SystemInitContentSchema.safeParse() instead of manual property checks for type-safe init message parsing - Replace length-based heuristic with actual set comparison to detect new commands accurately Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/routers/claude.ts | 7 ++-- src/server/services/claude-runner.test.ts | 7 ++++ src/server/services/claude-runner.ts | 42 ++++++++++++++--------- 3 files changed, 35 insertions(+), 21 deletions(-) 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 43091b1e..af1b52da 100644 --- a/src/server/services/claude-runner.test.ts +++ b/src/server/services/claude-runner.test.ts @@ -44,6 +44,7 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ import { buildSystemPrompt, mergeSlashCommands, + getSessionCommands, answerUserInput, hasPendingInput, isClaudeRunning, @@ -182,6 +183,12 @@ describe('claude-runner', () => { }); }); + 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 d2a59373..b76e8382 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -12,7 +12,7 @@ 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'; @@ -78,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 */ @@ -181,6 +183,7 @@ function getSessionState(sessionId: string, workingDir: string): SessionState { currentQuery: null, pendingInput: null, workingDir, + commands: [], }; sessions.set(sessionId, state); } @@ -380,19 +383,18 @@ export async function runClaudeCommand(options: RunClaudeCommandOptions): Promis const q = query({ prompt, options: sdkOptions }); state.currentQuery = q; - // Fetch rich command metadata from the SDK (fire-and-forget). + // 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. - let supportedCommands: SlashCommand[] = []; void q .supportedCommands() .then((commands) => { - const alreadyDiscovered = supportedCommands.map((c) => c.name); - supportedCommands = mergeSlashCommands(commands, alreadyDiscovered); - sseEvents.emitCommands(sessionId, supportedCommands); + 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: supportedCommands.length, + count: state.commands.length, }); }) .catch((err) => { @@ -404,16 +406,14 @@ export async function runClaudeCommand(options: RunClaudeCommandOptions): Promis // 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.) - if ( - message.type === 'system' && - 'subtype' in message && - message.subtype === 'init' && - 'slash_commands' in message && - Array.isArray(message.slash_commands) - ) { - const merged = mergeSlashCommands(supportedCommands, message.slash_commands as string[]); - if (merged.length > supportedCommands.length) { - supportedCommands = merged; + 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, @@ -574,6 +574,14 @@ export function answerUserInput(sessionId: string, answers: Record