diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts index 0b0a74d36..a977e6471 100644 --- a/integration-tests/sdk-typescript/system-control.test.ts +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -314,4 +314,88 @@ describe('System Control (E2E)', () => { ); }); }); + + describe('supportedCommands API', () => { + it('should return list of supported slash commands', async () => { + const sessionId = crypto.randomUUID(); + const generator = (async function* () { + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Hello' }, + parent_tool_use_id: null, + } as SDKUserMessage; + })(); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + model: 'qwen3-max', + debug: false, + }, + }); + + try { + const result = await q.supportedCommands(); + // Start consuming messages to trigger initialization + const messageConsumer = (async () => { + try { + for await (const _message of q) { + // Just consume messages + } + } catch (error) { + // Ignore errors from query being closed + if (error instanceof Error && error.message !== 'Query is closed') { + throw error; + } + } + })(); + + // Verify result structure + expect(result).toBeDefined(); + expect(result).toHaveProperty('commands'); + expect(Array.isArray(result?.['commands'])).toBe(true); + + const commands = result?.['commands'] as string[]; + + // Verify default allowed built-in commands are present + expect(commands).toContain('init'); + expect(commands).toContain('summary'); + expect(commands).toContain('compress'); + + // Verify commands are sorted + const sortedCommands = [...commands].sort(); + expect(commands).toEqual(sortedCommands); + + // Verify all commands are strings + commands.forEach((cmd) => { + expect(typeof cmd).toBe('string'); + expect(cmd.length).toBeGreaterThan(0); + }); + + await q.close(); + await messageConsumer; + } catch (error) { + await q.close(); + throw error; + } + }); + + it('should throw error when supportedCommands is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + model: 'qwen3-max', + }, + }); + + await q.close(); + + await expect(q.supportedCommands()).rejects.toThrow('Query is closed'); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 46ffd6702..d83ba8a8d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -65,12 +65,6 @@ import { PlanEmitter } from './emitters/PlanEmitter.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; import { SubAgentTracker } from './SubAgentTracker.js'; -/** - * Built-in commands that are allowed in ACP integration mode. - * Only safe, read-only commands that don't require interactive UI. - */ -export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init', 'summary', 'compress']; - /** * Session represents an active conversation session with the AI model. * It uses modular components for consistent event emission: @@ -172,13 +166,12 @@ export class Session implements SessionContext { let parts: Part[] | null; if (isSlashCommand(inputText)) { - // Handle slash command - allow specific built-in commands for ACP integration + // Handle slash command - uses default allowed commands (init, summary, compress) const slashCommandResult = await handleSlashCommand( inputText, pendingSend, this.config, this.settings, - ALLOWED_BUILTIN_COMMANDS_FOR_ACP, ); parts = await this.#processSlashCommandResult( @@ -300,11 +293,10 @@ export class Session implements SessionContext { async sendAvailableCommandsUpdate(): Promise { const abortController = new AbortController(); try { + // Use default allowed commands from getAvailableCommands const slashCommands = await getAvailableCommands( this.config, - this.settings, abortController.signal, - ALLOWED_BUILTIN_COMMANDS_FOR_ACP, ); // Convert SlashCommand[] to AvailableCommand[] format for ACP protocol diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index e214a8810..824858aa3 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -20,8 +20,7 @@ import type { CLIControlSetModelRequest, CLIMcpServerConfig, } from '../../types.js'; -import { CommandService } from '../../../services/CommandService.js'; -import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js'; +import { getAvailableCommands } from '../../../nonInteractiveCliCommands.js'; import { MCPServerConfig, AuthProviderType, @@ -407,7 +406,7 @@ export class SystemController extends BaseController { } /** - * Load slash command names using CommandService + * Load slash command names using getAvailableCommands * * @param signal - AbortSignal to respect for cancellation * @returns Promise resolving to array of slash command names @@ -418,21 +417,14 @@ export class SystemController extends BaseController { } try { - const service = await CommandService.create( - [new BuiltinCommandLoader(this.context.config)], - signal, - ); + const commands = await getAvailableCommands(this.context.config, signal); if (signal.aborted) { return []; } - const names = new Set(); - const commands = service.getCommands(); - for (const command of commands) { - names.add(command.name); - } - return Array.from(names).sort(); + // Extract command names and sort + return commands.map((cmd) => cmd.name).sort(); } catch (error) { // Check if the error is due to abort if (signal.aborted) { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index ae8a39992..f2366c767 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -42,12 +42,6 @@ import { computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; -const ALLOWED_BUILTIN_COMMANDS_FOR_NON_INTERACTIVE = [ - 'init', - 'summary', - 'compress', -]; - /** * Emits a final message for slash command results. * Note: systemMessage should already be emitted before calling this function. @@ -192,7 +186,6 @@ export async function runNonInteractive( abortController, config, settings, - ALLOWED_BUILTIN_COMMANDS_FOR_NON_INTERACTIVE, ); switch (slashCommandResult.type) { case 'submit_prompt': diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 711307412..410abaa48 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -24,6 +24,21 @@ import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js' import type { LoadedSettings } from './config/settings.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js'; +/** + * Built-in commands that are allowed in non-interactive modes (CLI and ACP). + * Only safe, read-only commands that don't require interactive UI. + * + * These commands are: + * - init: Initialize project configuration + * - summary: Generate session summary + * - compress: Compress conversation history + */ +export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [ + 'init', + 'summary', + 'compress', +] as const; + /** * Result of handling a slash command in non-interactive mode. * @@ -201,7 +216,8 @@ function filterCommandsForNonInteractive( * @param config The configuration object * @param settings The loaded settings * @param allowedBuiltinCommandNames Optional array of built-in command names that are - * allowed. If not provided or empty, only file commands are available. + * allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress). + * Pass an empty array to only allow file commands. * @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing * the outcome of the command execution. */ @@ -210,7 +226,9 @@ export const handleSlashCommand = async ( abortController: AbortController, config: Config, settings: LoadedSettings, - allowedBuiltinCommandNames?: string[], + allowedBuiltinCommandNames: string[] = [ + ...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, + ], ): Promise => { const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/')) { @@ -307,17 +325,18 @@ export const handleSlashCommand = async ( * Retrieves all available slash commands for the current configuration. * * @param config The configuration object - * @param settings The loaded settings * @param abortSignal Signal to cancel the loading process * @param allowedBuiltinCommandNames Optional array of built-in command names that are - * allowed. If not provided or empty, only file commands are available. + * allowed. Defaults to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE (init, summary, compress). + * Pass an empty array to only include file commands. * @returns A Promise that resolves to an array of SlashCommand objects */ export const getAvailableCommands = async ( config: Config, - settings: LoadedSettings, abortSignal: AbortSignal, - allowedBuiltinCommandNames?: string[], + allowedBuiltinCommandNames: string[] = [ + ...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, + ], ): Promise => { try { const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index a6dac920d..1f4e4f618 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -35,22 +35,33 @@ import { } from './nonInteractiveHelpers.js'; // Mock dependencies -vi.mock('../services/CommandService.js', () => ({ - CommandService: { - create: vi.fn().mockResolvedValue({ - getCommands: vi - .fn() - .mockReturnValue([ - { name: 'help' }, - { name: 'commit' }, - { name: 'memory' }, - ]), - }), - }, -})); +vi.mock('../nonInteractiveCliCommands.js', () => ({ + getAvailableCommands: vi + .fn() + .mockImplementation( + async ( + _config: unknown, + _signal: AbortSignal, + allowedBuiltinCommandNames?: string[], + ) => { + const allowedSet = new Set(allowedBuiltinCommandNames ?? []); + const allCommands = [ + { name: 'help', kind: 'built-in' }, + { name: 'commit', kind: 'file' }, + { name: 'memory', kind: 'built-in' }, + { name: 'init', kind: 'built-in' }, + { name: 'summary', kind: 'built-in' }, + { name: 'compress', kind: 'built-in' }, + ]; -vi.mock('../services/BuiltinCommandLoader.js', () => ({ - BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})), + // Filter commands: always include file commands, only include allowed built-in commands + return allCommands.filter( + (cmd) => + cmd.kind === 'file' || + (cmd.kind === 'built-in' && allowedSet.has(cmd.name)), + ); + }, + ), })); vi.mock('../ui/utils/computeStats.js', () => ({ @@ -511,10 +522,12 @@ describe('buildSystemMessage', () => { }); it('should build system message with all fields', async () => { + const allowedBuiltinCommands = ['init', 'summary', 'compress']; const result = await buildSystemMessage( mockConfig, 'test-session-id', 'auto' as PermissionMode, + allowedBuiltinCommands, ); expect(result).toEqual({ @@ -530,7 +543,7 @@ describe('buildSystemMessage', () => { ], model: 'test-model', permission_mode: 'auto', - slash_commands: ['commit', 'help', 'memory'], + slash_commands: ['commit', 'compress', 'init', 'summary'], qwen_code_version: '1.0.0', agents: [], }); @@ -546,6 +559,7 @@ describe('buildSystemMessage', () => { config, 'test-session-id', 'auto' as PermissionMode, + ['init', 'summary'], ); expect(result.tools).toEqual([]); @@ -561,6 +575,7 @@ describe('buildSystemMessage', () => { config, 'test-session-id', 'auto' as PermissionMode, + ['init', 'summary'], ); expect(result.mcp_servers).toEqual([]); @@ -576,10 +591,37 @@ describe('buildSystemMessage', () => { config, 'test-session-id', 'auto' as PermissionMode, + ['init', 'summary'], ); expect(result.qwen_code_version).toBe('unknown'); }); + + it('should only include allowed built-in commands and all file commands', async () => { + const allowedBuiltinCommands = ['init', 'summary']; + const result = await buildSystemMessage( + mockConfig, + 'test-session-id', + 'auto' as PermissionMode, + allowedBuiltinCommands, + ); + + // Should include: 'commit' (FILE), 'init' (BUILT_IN, allowed), 'summary' (BUILT_IN, allowed) + // Should NOT include: 'help', 'memory', 'compress' (BUILT_IN but not in allowed set) + expect(result.slash_commands).toEqual(['commit', 'init', 'summary']); + }); + + it('should include only file commands when no built-in commands are allowed', async () => { + const result = await buildSystemMessage( + mockConfig, + 'test-session-id', + 'auto' as PermissionMode, + [], // Empty array - no built-in commands allowed + ); + + // Should only include 'commit' (FILE command) + expect(result.slash_commands).toEqual(['commit']); + }); }); describe('createTaskToolProgressHandler', () => { diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index 1fd7472b9..6bfb3ec1a 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -25,10 +25,9 @@ import type { PermissionMode, CLISystemMessage, } from '../nonInteractive/types.js'; -import { CommandService } from '../services/CommandService.js'; -import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js'; import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; import { computeSessionStats } from '../ui/utils/computeStats.js'; +import { getAvailableCommands } from '../nonInteractiveCliCommands.js'; /** * Normalizes various part list formats into a consistent Part[] array. @@ -187,24 +186,27 @@ export function computeUsageFromMetrics(metrics: SessionMetrics): Usage { } /** - * Load slash command names using CommandService + * Load slash command names using getAvailableCommands * * @param config - Config instance + * @param allowedBuiltinCommandNames - Optional array of allowed built-in command names. + * If not provided, uses the default from getAvailableCommands. * @returns Promise resolving to array of slash command names */ -async function loadSlashCommandNames(config: Config): Promise { +async function loadSlashCommandNames( + config: Config, + allowedBuiltinCommandNames?: string[], +): Promise { const controller = new AbortController(); try { - const service = await CommandService.create( - [new BuiltinCommandLoader(config)], + const commands = await getAvailableCommands( + config, controller.signal, + allowedBuiltinCommandNames, ); - const names = new Set(); - const commands = service.getCommands(); - for (const command of commands) { - names.add(command.name); - } - return Array.from(names).sort(); + + // Extract command names and sort + return commands.map((cmd) => cmd.name).sort(); } catch (error) { if (config.getDebugMode()) { console.error( @@ -233,12 +235,15 @@ async function loadSlashCommandNames(config: Config): Promise { * @param config - Config instance * @param sessionId - Session identifier * @param permissionMode - Current permission/approval mode + * @param allowedBuiltinCommandNames - Optional array of allowed built-in command names. + * If not provided, defaults to empty array (only file commands will be included). * @returns Promise resolving to CLISystemMessage */ export async function buildSystemMessage( config: Config, sessionId: string, permissionMode: PermissionMode, + allowedBuiltinCommandNames?: string[], ): Promise { const toolRegistry = config.getToolRegistry(); const tools = toolRegistry ? toolRegistry.getAllToolNames() : []; @@ -251,8 +256,11 @@ export async function buildSystemMessage( })) : []; - // Load slash commands - const slashCommands = await loadSlashCommandNames(config); + // Load slash commands with filtering based on allowed built-in commands + const slashCommands = await loadSlashCommandNames( + config, + allowedBuiltinCommandNames || [], + ); // Load subagent names from config let agentNames: string[] = []; diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 78bb10b95..c01229037 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -272,8 +272,6 @@ export class Query implements AsyncIterable { // Get only successfully connected SDK servers for CLI const sdkMcpServersForCli = this.getSdkMcpServersForCli(); const mcpServersForCli = this.getMcpServersForCli(); - logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli); - logger.debug('External MCP servers for CLI:', mcpServersForCli); await this.sendControlRequest(ControlRequestType.INITIALIZE, { hooks: null, @@ -629,6 +627,11 @@ export class Query implements AsyncIterable { return Promise.reject(new Error('Query is closed')); } + if (subtype !== ControlRequestType.INITIALIZE) { + // Ensure all other control requests get processed after initialization + await this.initialized; + } + const requestId = randomUUID(); const request: CLIControlRequest = {