Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/server/routers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) };
}),
});
70 changes: 70 additions & 0 deletions src/server/services/claude-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({

import {
buildSystemPrompt,
mergeSlashCommands,
getSessionCommands,
answerUserInput,
hasPendingInput,
isClaudeRunning,
Expand Down Expand Up @@ -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: '<pr>' },
];
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: '<pr>',
});
// 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' });
Expand Down
82 changes: 80 additions & 2 deletions src/server/services/claude-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
}
}
Comment on lines +45 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of mergeSlashCommands does not handle potential duplicates in the slashCommandNames array. If a name appears multiple times in the input array and is not already present in existingCommands, it will be added multiple times to the result. Updating the existingNames set within the loop ensures that each new command is only added once.

Suggested change
for (const name of slashCommandNames) {
if (!existingNames.has(name)) {
merged.push({ name, description: '', argumentHint: '' });
}
}
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';

Expand All @@ -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 */
Expand Down Expand Up @@ -152,6 +183,7 @@ function getSessionState(sessionId: string, workingDir: string): SessionState {
currentQuery: null,
pendingInput: null,
workingDir,
commands: [],
};
sessions.set(sessionId, state);
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -504,6 +574,14 @@ export function answerUserInput(sessionId: string, answers: Record<string, strin
return true;
}

/**
* Get cached slash commands for a session.
* Returns commands discovered during the last query, or empty if none.
*/
export function getSessionCommands(sessionId: string): SlashCommand[] {
return sessions.get(sessionId)?.commands ?? [];
}

/**
* Check if a session has a pending user input request.
*/
Expand Down
Loading