Skip to content
Open
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
160 changes: 160 additions & 0 deletions packages/cli/src/nonInteractiveCliCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { vi, describe, it, expect, beforeEach } from 'vitest';
import { FatalInputError } from '@google/gemini-cli-core';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { CommandService } from './services/CommandService.js';
import { CommandKind, type SlashCommand } from './ui/commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import type { LoadedSettings } from './config/settings.js';

vi.mock('./services/CommandService.js', () => ({
CommandService: {
create: vi.fn(),
},
}));
vi.mock('./ui/noninteractive/nonInteractiveUi.js', () => ({
createNonInteractiveUI: vi.fn().mockReturnValue({}),
}));

function mockCommands(commands: SlashCommand[]) {
vi.mocked(CommandService.create).mockResolvedValue({
getCommands: () => commands,
getConflicts: () => [],
} as unknown as CommandService);
}

describe('handleSlashCommand (non-interactive)', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let abortController: AbortController;

beforeEach(() => {
vi.clearAllMocks();
abortController = new AbortController();
// The loaders passed to CommandService.create are still constructed even
// though create() is mocked, so the config must expose the methods each
// loader touches in its constructor.
mockConfig = {
getSessionId: vi.fn().mockReturnValue('session-id'),
storage: {},
getFolderTrust: vi.fn().mockReturnValue(false),
isTrustedFolder: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/project'),
getExtensions: vi.fn().mockReturnValue([]),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
getSkillManager: vi.fn().mockReturnValue(null),
getPromptRegistry: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
mockSettings = {} as unknown as LoadedSettings;
});

it('returns undefined for input that is not a slash command', async () => {
mockCommands([]);
const result = await handleSlashCommand(
'just a prompt',
abortController,
mockConfig,
mockSettings,
);
expect(result).toBeUndefined();
});

it('returns the content for a submit_prompt action', async () => {
mockCommands([
{
name: 'foo',
description: 'foo',
kind: CommandKind.BUILT_IN,
action: async () => ({
type: 'submit_prompt',
content: 'hello model',
}),
},
]);

const result = await handleSlashCommand(
'/foo',
abortController,
mockConfig,
mockSettings,
);
expect(result).toBe('hello model');
});

it('forwards postSubmitPrompt for a tool action (skill activation)', async () => {
mockCommands([
{
name: 'my-skill',
description: 'Activate the my-skill skill',
kind: CommandKind.SKILL,
autoExecute: true,
action: async () => ({
type: 'tool',
toolName: 'activate_skill',
toolArgs: { name: 'my-skill' },
postSubmitPrompt: 'Use the skill my-skill',
}),
},
]);

const result = await handleSlashCommand(
'/my-skill',
abortController,
mockConfig,
mockSettings,
);
expect(result).toBe('Use the skill my-skill');
});

it('throws for a tool action without a postSubmitPrompt', async () => {
mockCommands([
{
name: 'bare-tool',
description: 'bare',
kind: CommandKind.SKILL,
action: async () => ({
type: 'tool',
toolName: 'activate_skill',
toolArgs: { name: 'bare' },
}),
},
]);

await expect(
handleSlashCommand(
'/bare-tool',
abortController,
mockConfig,
mockSettings,
),
).rejects.toThrow(FatalInputError);
});

it('throws for a confirm_shell_commands action', async () => {
mockCommands([
{
name: 'needs-confirm',
description: 'confirm',
kind: CommandKind.BUILT_IN,
action: async () => ({
type: 'confirm_shell_commands',
commandsToConfirm: ['rm -rf /'],
}),
},
] as unknown as SlashCommand[]);

await expect(
handleSlashCommand(
'/needs-confirm',
abortController,
mockConfig,
mockSettings,
),
).rejects.toThrow(FatalInputError);
});
});
15 changes: 15 additions & 0 deletions packages/cli/src/nonInteractiveCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import { McpPromptLoader } from './services/McpPromptLoader.js';
import { SkillCommandLoader } from './services/SkillCommandLoader.js';
import type { CommandContext } from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js';
Expand Down Expand Up @@ -43,6 +44,7 @@ export const handleSlashCommand = async (
const commandService = await CommandService.create(
[
new BuiltinCommandLoader(config),
new SkillCommandLoader(config),
new McpPromptLoader(config),
new FileCommandLoader(config),
],
Expand Down Expand Up @@ -90,6 +92,19 @@ export const handleSlashCommand = async (
switch (result.type) {
case 'submit_prompt':
return result.content;
case 'tool':
// Commands such as skill activation (`/skill-name`) resolve to a
// tool call. Non-interactive mode cannot schedule a UI tool call,
// so we forward the command's follow-up prompt to the model, which
// then invokes the tool (e.g. `activate_skill`) itself. If no
// follow-up prompt is provided there is nothing we can forward, so
// we fail loudly rather than silently doing nothing.
if (!result.postSubmitPrompt) {
throw new FatalInputError(
'Exiting due to a tool action without a follow-up prompt, which is not supported in non-interactive mode.',
);
}
return result.postSubmitPrompt;
Comment on lines +95 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

When a command resolves to a tool action in non-interactive mode, it cannot schedule a UI tool call. Therefore, it must rely on a postSubmitPrompt to forward the request to the model. If postSubmitPrompt is missing or undefined, the command cannot be executed and will silently fail (returning undefined).

Following the general rule to handle undefined cases for optional properties and avoid relying on implementation details of specific loaders, we should explicitly throw a FatalInputError if postSubmitPrompt is not provided, consistent with how other unsupported actions are handled in non-interactive mode.

Suggested change
case 'tool':
// Commands such as skill activation (`/skill-name`) resolve to a
// tool call. Non-interactive mode cannot schedule a UI tool call,
// so we forward the command's follow-up prompt to the model, which
// then invokes the tool (e.g. `activate_skill`) itself. If no
// follow-up prompt is provided there is nothing to send.
return result.postSubmitPrompt;
case 'tool':
// Commands such as skill activation (`/skill-name`) resolve to a
// tool call. Non-interactive mode cannot schedule a UI tool call,
// so we forward the command's follow-up prompt to the model, which
// then invokes the tool (e.g. `activate_skill`) itself.
if (!result.postSubmitPrompt) {
throw new FatalInputError(
'Exiting due to a tool action without a follow-up prompt, which is not supported in non-interactive mode.',
);
}
return result.postSubmitPrompt;
References
  1. When consuming an object, if a property is optional in its type definition (interface), callers must handle the undefined case (e.g., by providing a default with ?? or throwing an error). Do not rely on the implementation details of the function that creates the object to always provide a value.

case 'confirm_shell_commands':
// This result indicates a command attempted to confirm shell commands.
// However note that currently, ShellTool is excluded in non-interactive
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,32 @@ describe('SuggestionsDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});

it('renders Skill tag for skill commands', async () => {
const skillSuggestions = [
{
label: 'my-skill',
value: 'my-skill',
description: 'Activate my skill',
commandKind: CommandKind.SKILL,
},
];

const { lastFrame } = await render(
<SuggestionsDisplay
suggestions={skillSuggestions}
activeIndex={0}
isLoading={false}
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
/>,
);
const frame = lastFrame();
expect(frame).toContain('[Skill]');
expect(frame).toMatchSnapshot();
});

it('renders command section separators for slash mode', async () => {
const groupedSuggestions = [
{
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/SuggestionsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function SuggestionsDisplay({
const COMMAND_KIND_SUFFIX: Partial<Record<CommandKind, string>> = {
[CommandKind.MCP_PROMPT]: ' [MCP]',
[CommandKind.AGENT]: ' [Agent]',
[CommandKind.SKILL]: ' [Skill]',
};

const getFullLabel = (s: Suggestion) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `
"
`;

exports[`SuggestionsDisplay > renders Skill tag for skill commands 1`] = `
" my-skill [Skill] Activate my skill
"
`;

exports[`SuggestionsDisplay > renders loading state 1`] = `
" Loading suggestions...
"
Expand Down
Loading