basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { PartListUnion } from '@google/genai';8import {9 parseSlashCommand,10 parseStackedSlashCommands,11} from './utils/commands.js';12import {13 Logger,14 uiTelemetryService,15 type Config,16 createDebugLogger,17 recordSkillInvocation,18} from '@qwen-code/qwen-code-core';19import { CommandService } from './services/CommandService.js';20import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';21import { BundledSkillLoader } from './services/BundledSkillLoader.js';22import { FileCommandLoader } from './services/FileCommandLoader.js';23import { SavedWorkflowLoader } from './services/saved-workflow-loader.js';24import { McpPromptLoader } from './services/McpPromptLoader.js';25import { SkillCommandLoader } from './services/SkillCommandLoader.js';26import {27 type CommandContext,28 CommandKind,29 type SlashCommand,30 type SlashCommandActionReturn,31 type ExecutionMode,32} from './ui/commands/types.js';33import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';34import type { HistoryItemWithoutId } from './ui/types.js';35import type { LoadedSettings } from './config/settings.js';36import type { SessionStatsState } from './ui/contexts/SessionContext.js';37import { t } from './i18n/index.js';38import {39 appendUserPromptExpansionAdditionalContext,40 formatUserPromptExpansionBlockedMessage,41 serializeUserPromptExpansionPrompt,42} from './utils/userPromptExpansionHook.js';43 44const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS');45 46type CommandServiceInstance = Awaited<ReturnType<typeof CommandService.create>>;47 48function getSkillCommandName(command: SlashCommand): string {49 return command.skillDetail?.name ?? command.name;50}51 52/**53 * Result of handling a slash command in non-interactive mode.54 *55 * Supported types:56 * - 'submit_prompt': Submits content to the model (supports all modes)57 * - 'message': Returns a single message (supports non-interactive JSON/text only)58 * - 'stream_messages': Streams multiple messages (supports ACP only)59 * - 'unsupported': Command cannot be executed in this mode60 * - 'no_command': No command was found or executed61 */62export type NonInteractiveSlashCommandResult =63 | {64 type: 'submit_prompt';65 content: PartListUnion;66 outputHistoryItems?: HistoryItemWithoutId[];67 /** Per-turn model id (e.g. inline `/model <id> <prompt>`); no session change. */68 modelOverride?: string;69 }70 | {71 type: 'message';72 messageType: 'info' | 'warning' | 'error';73 content: string;74 outputHistoryItems?: HistoryItemWithoutId[];75 }76 | {77 type: 'stream_messages';78 messages: AsyncGenerator<79 { messageType: 'info' | 'warning' | 'error'; content: string },80 void,81 unknown82 >;83 }84 | {85 type: 'unsupported';86 reason: string;87 originalType: string;88 }89 | {90 type: 'no_command';91 };92 93/**94 * Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.95 *96 * Only the following result types are supported in non-interactive mode:97 * - submit_prompt: Submits content to the model (all modes)98 * - message: Returns a single message (non-interactive JSON/text only)99 * - stream_messages: Streams multiple messages (ACP only)100 *101 * All other result types are converted to 'unsupported'.102 *103 * @param result The result from executing a slash command action104 * @returns A NonInteractiveSlashCommandResult describing the outcome105 */106function handleCommandResult(107 result: SlashCommandActionReturn,108 outputHistoryItems?: HistoryItemWithoutId[],109): NonInteractiveSlashCommandResult {110 switch (result.type) {111 case 'submit_prompt':112 return {113 type: 'submit_prompt',114 content: result.content,115 ...(result.modelOverride116 ? { modelOverride: result.modelOverride }117 : {}),118 ...(outputHistoryItems?.length ? { outputHistoryItems } : {}),119 };120 121 case 'message':122 return {123 type: 'message',124 messageType: result.messageType,125 content: result.content,126 ...(outputHistoryItems?.length ? { outputHistoryItems } : {}),127 };128 129 case 'stream_messages':130 return {131 type: 'stream_messages',132 messages: result.messages,133 };134 135 /**136 * Currently return types below are never generated due to the137 * whitelist of allowed slash commands in ACP and non-interactive mode.138 * We'll try to add more supported return types in the future.139 */140 case 'tool':141 return {142 type: 'unsupported',143 reason:144 'Tool execution from slash commands is not supported in non-interactive mode.',145 originalType: 'tool',146 };147 148 case 'quit':149 return {150 type: 'unsupported',151 reason:152 'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',153 originalType: 'quit',154 };155 156 case 'dialog':157 return {158 type: 'unsupported',159 reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,160 originalType: 'dialog',161 };162 163 case 'load_history':164 return {165 type: 'unsupported',166 reason:167 'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',168 originalType: 'load_history',169 };170 171 case 'confirm_shell_commands':172 return {173 type: 'unsupported',174 reason:175 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',176 originalType: 'confirm_shell_commands',177 };178 179 case 'confirm_action':180 return {181 type: 'unsupported',182 reason:183 'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',184 originalType: 'confirm_action',185 };186 187 default: {188 // Exhaustiveness check189 const _exhaustive: never = result;190 return {191 type: 'unsupported',192 reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,193 originalType: 'unknown',194 };195 }196 }197}198 199async function fireUserPromptExpansionHook(200 config: Config,201 commandName: string,202 commandArgs: string,203 content: PartListUnion,204 signal: AbortSignal,205): Promise<{206 blockedResult?: NonInteractiveSlashCommandResult;207 content: PartListUnion;208}> {209 if (210 config.getDisableAllHooks?.() ||211 !(config.hasHooksForEvent?.('UserPromptExpansion') ?? false)212 ) {213 return { content };214 }215 216 const hookSystem = config.getHookSystem();217 if (!hookSystem) {218 return { content };219 }220 221 const output = await hookSystem.fireUserPromptExpansionEvent(222 commandName,223 commandArgs,224 serializeUserPromptExpansionPrompt(content),225 signal,226 );227 if (!output) {228 return { content };229 }230 231 const blockingError = output.getBlockingError();232 if (blockingError.blocked || output.shouldStopExecution()) {233 return {234 blockedResult: {235 type: 'message',236 messageType: 'error',237 content: formatUserPromptExpansionBlockedMessage(238 blockingError.reason || output.getEffectiveReason(),239 ),240 },241 content,242 };243 }244 245 return {246 content: appendUserPromptExpansionAdditionalContext(247 content,248 output.getAdditionalContext(),249 ),250 };251}252 253async function registerModelInvocableCommands(254 commandService: CommandServiceInstance,255 config: Config,256 executionMode: ExecutionMode,257 settings?: LoadedSettings,258): Promise<void> {259 if (!settings) {260 return;261 }262 263 config.setModelInvocableCommandsProvider(() =>264 commandService.getModelInvocableCommands().map((cmd) => ({265 name: cmd.name,266 description: cmd.modelDescription ?? cmd.description,267 })),268 );269 270 config.setModelInvocableCommandsExecutor(271 async (name: string, args: string = '') => {272 const commands = commandService.getModelInvocableCommands();273 const cmd = commands.find((c) => c.name === name);274 if (!cmd?.action) return null;275 const minimalContext = {276 executionMode,277 invocation: {278 raw: args ? `/${name} ${args}` : `/${name}`,279 name,280 args,281 },282 services: { config, settings, logger: null },283 } as unknown as CommandContext;284 const result = await cmd.action(minimalContext, args);285 if (!result || result.type !== 'submit_prompt') return null;286 const hookSignal = new AbortController().signal;287 const hookResult = await fireUserPromptExpansionHook(288 config,289 name,290 args,291 result.content,292 hookSignal,293 );294 if (hookResult.blockedResult) {295 return hookResult.blockedResult.type === 'message'296 ? { error: hookResult.blockedResult.content }297 : null;298 }299 const content = hookResult.content;300 if (typeof content === 'string') return content;301 if (Array.isArray(content)) {302 return content303 .map((p) =>304 typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''),305 )306 .join('');307 }308 return null;309 },310 );311 312 const skillManager =313 typeof config.getSkillManager === 'function'314 ? config.getSkillManager()315 : null;316 await skillManager?.notifyConfigChanged();317}318 319/**320 * Processes a slash command in a non-interactive environment.321 *322 * @param rawQuery The raw query string (should start with '/')323 * @param abortController Controller to cancel the operation324 * @param config The configuration object325 * @param settings The loaded settings326 * @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing327 * the outcome of the command execution.328 */329export const handleSlashCommand = async (330 rawQuery: string,331 abortController: AbortController,332 config: Config,333 settings: LoadedSettings,334): Promise<NonInteractiveSlashCommandResult> => {335 const trimmed = rawQuery.trim();336 if (!trimmed.startsWith('/')) {337 return { type: 'no_command' };338 }339 340 const isAcpMode = config.getExperimentalZedIntegration();341 const isInteractive = config.isInteractive();342 343 const executionMode: ExecutionMode = isAcpMode344 ? 'acp'345 : isInteractive346 ? 'interactive'347 : 'non_interactive';348 349 // Load all commands to check if the command exists but is not allowed350 const allLoaders = [351 new McpPromptLoader(config),352 new BuiltinCommandLoader(config),353 new BundledSkillLoader(config),354 new SkillCommandLoader(config),355 new SavedWorkflowLoader(config),356 new FileCommandLoader(config),357 ];358 359 // Build the disabled-command set (case-insensitive).360 const disabledSlashCommandsRaw = config.getDisabledSlashCommands();361 const disabledNameSet = new Set<string>();362 for (const name of disabledSlashCommandsRaw) {363 const trimmed = name.trim();364 if (trimmed) disabledNameSet.add(trimmed.toLowerCase());365 }366 const isDisabled = (cmd: { name: string; altNames?: readonly string[] }) =>367 disabledNameSet.has(cmd.name.toLowerCase()) ||368 (cmd.altNames ?? []).some((a) => disabledNameSet.has(a.toLowerCase()));369 370 // Load the full command set (unfiltered by the denylist) so that the371 // fallback existence check below can distinguish a disabled command from a372 // truly unknown one. Without this, a disabled command would fall through to373 // `no_command` and be forwarded to the model as plain prompt text.374 const allCommandService = await CommandService.create(375 allLoaders,376 abortController.signal,377 );378 const commandService =379 disabledNameSet.size > 0380 ? await CommandService.create(381 allLoaders,382 abortController.signal,383 disabledNameSet,384 )385 : allCommandService;386 await registerModelInvocableCommands(387 commandService,388 config,389 executionMode,390 settings,391 );392 const allCommands = allCommandService.getCommands();393 const filteredCommands = commandService394 .getCommandsForMode(executionMode)395 .filter((cmd) => !isDisabled(cmd));396 397 // First, try to parse with filtered commands398 const { commandToExecute, args } = parseSlashCommand(399 rawQuery,400 filteredCommands,401 );402 403 // Handle stacked skill invocations (e.g. /feat-dev /e2e-testing implement X)404 const stackedResult = parseStackedSlashCommands(rawQuery, filteredCommands);405 if (stackedResult.skills.length >= 2) {406 const combinedContent: PartListUnion[] = [];407 let firstModelOverride: string | undefined;408 const onCompleteCallbacks: Array<() => Promise<void>> = [];409 410 for (const skill of stackedResult.skills) {411 if (!skill.action) continue;412 const skillContext = {413 executionMode,414 invocation: {415 raw: `/${skill.name}`,416 name: skill.name,417 args: '',418 },419 services: { config, settings, logger: null },420 } as unknown as CommandContext;421 422 const skillResult = await skill.action(skillContext, '');423 if (skillResult?.type === 'submit_prompt') {424 combinedContent.push(skillResult.content);425 firstModelOverride ??= skillResult.modelOverride;426 if (skillResult.onComplete) {427 onCompleteCallbacks.push(skillResult.onComplete);428 }429 }430 431 recordSkillInvocation(config, {432 skillName: getSkillCommandName(skill),433 success: skillResult?.type === 'submit_prompt',434 });435 }436 437 if (stackedResult.remainingText) {438 combinedContent.push([{ text: stackedResult.remainingText }]);439 }440 441 const mergedContent: PartListUnion = combinedContent.flat();442 443 const hookResult = await fireUserPromptExpansionHook(444 config,445 stackedResult.skills.map((s) => s.name).join(' '),446 stackedResult.remainingText,447 mergedContent,448 abortController.signal,449 );450 if (hookResult.blockedResult) {451 return hookResult.blockedResult;452 }453 454 return {455 type: 'submit_prompt',456 content: hookResult.content,457 ...(firstModelOverride ? { modelOverride: firstModelOverride } : {}),458 ...(onCompleteCallbacks.length459 ? {460 onComplete: async () => {461 for (const cb of onCompleteCallbacks) await cb();462 },463 }464 : {}),465 };466 }467 468 if (!commandToExecute) {469 // Check if this is a known command that's just not allowed470 const { commandToExecute: knownCommand } = parseSlashCommand(471 rawQuery,472 allCommands,473 );474 475 if (knownCommand) {476 // Derive the token the user actually typed (e.g. "about" when the477 // primary name is "status") to surface a helpful error message.478 const typedToken =479 rawQuery.trim().substring(1).trim().split(/\s+/)[0] ??480 knownCommand.name;481 if (isDisabled(knownCommand)) {482 return {483 type: 'unsupported',484 reason: t(485 'The command "/{{command}}" is disabled by the current configuration.',486 { command: typedToken },487 ),488 originalType: 'filtered_command',489 };490 }491 // Command exists but is not allowed in this mode492 return {493 type: 'unsupported',494 reason: t('The command "/{{command}}" is not supported in this mode.', {495 command: typedToken,496 }),497 originalType: 'filtered_command',498 };499 }500 501 return { type: 'no_command' };502 }503 504 if (!commandToExecute.action) {505 return { type: 'no_command' };506 }507 508 // Not used by custom commands but may be in the future.509 const sessionStats: SessionStatsState = {510 sessionId: config?.getSessionId(),511 sessionStartTime: new Date(),512 metrics: config513 ? uiTelemetryService.getMetricsForSession(config.getSessionId())514 : uiTelemetryService.getMetrics(),515 lastPromptTokenCount: 0,516 promptCount: 1,517 };518 519 const logger = new Logger(config?.getSessionId() || '', config?.storage);520 521 const outputHistoryItems: HistoryItemWithoutId[] = [];522 const ui = createNonInteractiveUI();523 ui.addItem = (item) => {524 outputHistoryItems.push(item);525 return 0;526 };527 528 const context: CommandContext = {529 executionMode,530 services: {531 config,532 settings,533 logger,534 },535 ui,536 session: {537 stats: sessionStats,538 sessionShellAllowlist: new Set(),539 },540 invocation: {541 raw: trimmed,542 name: commandToExecute.name,543 args,544 },545 };546 547 const isSkillCommand = commandToExecute.kind === CommandKind.SKILL;548 let skillInvocationRecorded = false;549 const recordSkillCommandInvocation = (success: boolean) => {550 if (!isSkillCommand || skillInvocationRecorded) {551 return;552 }553 recordSkillInvocation(config, {554 skillName: getSkillCommandName(commandToExecute),555 success,556 });557 skillInvocationRecorded = true;558 };559 560 let result: SlashCommandActionReturn | void;561 try {562 result = await commandToExecute.action(context, args);563 } catch (error) {564 recordSkillCommandInvocation(false);565 throw error;566 }567 568 if (!result) {569 // Command executed but returned no result (e.g., void return)570 return {571 type: 'message',572 messageType: 'info',573 content: 'Command executed successfully.',574 };575 }576 577 if (result.type === 'submit_prompt') {578 let hookResult: Awaited<ReturnType<typeof fireUserPromptExpansionHook>>;579 try {580 hookResult = await fireUserPromptExpansionHook(581 config,582 commandToExecute.name,583 args,584 result.content,585 abortController.signal,586 );587 } catch (error) {588 recordSkillCommandInvocation(false);589 throw error;590 }591 if (hookResult.blockedResult) {592 recordSkillCommandInvocation(false);593 return hookResult.blockedResult;594 }595 recordSkillCommandInvocation(true);596 return handleCommandResult(597 { ...result, content: hookResult.content },598 outputHistoryItems,599 );600 }601 602 // Handle different result types603 return handleCommandResult(result, outputHistoryItems);604};605 606/**607 * Retrieves all available slash commands for the given execution mode.608 *609 * @param config The configuration object610 * @param abortSignal Signal to cancel the loading process611 * @param mode The execution mode to filter commands for. Defaults to 'acp'.612 * @returns A Promise that resolves to an array of SlashCommand objects613 */614export const getAvailableCommands = async (615 config: Config,616 abortSignal: AbortSignal,617 mode: ExecutionMode = 'acp',618 settings?: LoadedSettings,619): Promise<SlashCommand[]> => {620 try {621 const loaders = [622 new McpPromptLoader(config),623 new BuiltinCommandLoader(config),624 new BundledSkillLoader(config),625 new SkillCommandLoader(config),626 new SavedWorkflowLoader(config),627 new FileCommandLoader(config),628 ];629 630 const disabledSlashCommands = config.getDisabledSlashCommands();631 const commandService = await CommandService.create(632 loaders,633 abortSignal,634 disabledSlashCommands.length > 0635 ? new Set(disabledSlashCommands)636 : undefined,637 );638 await registerModelInvocableCommands(639 commandService,640 config,641 mode,642 settings,643 );644 return commandService.getCommandsForMode(mode) as SlashCommand[];645 } catch (error) {646 // Handle errors gracefully - log and return empty array647 debugLogger.error('Error loading available commands:', error);648 return [];649 }650};651 