mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
chore(cli/slashcommands): Add status enum to SlashCommandEvent telemetry (#6166)
This commit is contained in:
@@ -4,18 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({
|
||||
const { logSlashCommand } = vi.hoisted(() => ({
|
||||
logSlashCommand: vi.fn(),
|
||||
SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
logSlashCommand,
|
||||
SlashCommandEvent,
|
||||
getIdeInstaller: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
@@ -25,10 +24,10 @@ const { mockProcessExit } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('node:process', () => {
|
||||
const mockProcess = {
|
||||
const mockProcess: Partial<NodeJS.Process> = {
|
||||
exit: mockProcessExit,
|
||||
platform: 'test-platform',
|
||||
};
|
||||
platform: 'sunos',
|
||||
} as unknown as NodeJS.Process;
|
||||
return {
|
||||
...mockProcess,
|
||||
default: mockProcess,
|
||||
@@ -77,22 +76,28 @@ import {
|
||||
ConfirmShellCommandsActionReturn,
|
||||
SlashCommand,
|
||||
} from '../commands/types.js';
|
||||
import { Config, ToolConfirmationOutcome } from '@google/gemini-cli-core';
|
||||
import { ToolConfirmationOutcome } from '@google/gemini-cli-core';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import {
|
||||
SlashCommandStatus,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core/index.js';
|
||||
|
||||
const createTestCommand = (
|
||||
function createTestCommand(
|
||||
overrides: Partial<SlashCommand>,
|
||||
kind: CommandKind = CommandKind.BUILT_IN,
|
||||
): SlashCommand => ({
|
||||
name: 'test',
|
||||
description: 'a test command',
|
||||
kind,
|
||||
...overrides,
|
||||
});
|
||||
): SlashCommand {
|
||||
return {
|
||||
name: 'test',
|
||||
description: 'a test command',
|
||||
kind,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useSlashCommandProcessor', () => {
|
||||
const mockAddItem = vi.fn();
|
||||
@@ -102,15 +107,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
const mockOpenAuthDialog = vi.fn();
|
||||
const mockSetQuittingMessages = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => '/mock/cwd'),
|
||||
getSessionId: vi.fn(() => 'test-session'),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
setHistory: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const mockConfig = makeFakeConfig({});
|
||||
|
||||
const mockSettings = {} as LoadedSettings;
|
||||
|
||||
@@ -314,6 +311,39 @@ describe('useSlashCommandProcessor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sets isProcessing to false if the the input is not a command', async () => {
|
||||
const setMockIsProcessing = vi.fn();
|
||||
const result = setupProcessorHook([], [], [], setMockIsProcessing);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('imnotacommand');
|
||||
});
|
||||
|
||||
expect(setMockIsProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets isProcessing to false if the command has an error', async () => {
|
||||
const setMockIsProcessing = vi.fn();
|
||||
const failCommand = createTestCommand({
|
||||
name: 'fail',
|
||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||
});
|
||||
|
||||
const result = setupProcessorHook(
|
||||
[failCommand],
|
||||
[],
|
||||
[],
|
||||
setMockIsProcessing,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/fail');
|
||||
});
|
||||
|
||||
expect(setMockIsProcessing).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setMockIsProcessing).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it('should set isProcessing to true during execution and false afterwards', async () => {
|
||||
const mockSetIsProcessing = vi.fn();
|
||||
const command = createTestCommand({
|
||||
@@ -329,14 +359,14 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// It should be true immediately after starting
|
||||
expect(mockSetIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(1, true);
|
||||
// It should not have been called with false yet
|
||||
expect(mockSetIsProcessing).not.toHaveBeenCalledWith(false);
|
||||
|
||||
await executionPromise;
|
||||
|
||||
// After the promise resolves, it should be called with false
|
||||
expect(mockSetIsProcessing).toHaveBeenCalledWith(false);
|
||||
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(2, false);
|
||||
expect(mockSetIsProcessing).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -884,7 +914,9 @@ describe('useSlashCommandProcessor', () => {
|
||||
const loggingTestCommands: SlashCommand[] = [
|
||||
createTestCommand({
|
||||
name: 'logtest',
|
||||
action: mockCommandAction,
|
||||
action: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'message', content: 'hello world' }),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logwithsub',
|
||||
@@ -895,6 +927,10 @@ describe('useSlashCommandProcessor', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'fail',
|
||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logalias',
|
||||
altNames: ['la'],
|
||||
@@ -905,7 +941,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
beforeEach(() => {
|
||||
mockCommandAction.mockClear();
|
||||
vi.mocked(logSlashCommand).mockClear();
|
||||
vi.mocked(SlashCommandEvent).mockClear();
|
||||
});
|
||||
|
||||
it('should log a simple slash command', async () => {
|
||||
@@ -917,8 +952,45 @@ describe('useSlashCommandProcessor', () => {
|
||||
await result.current.handleSlashCommand('/logtest');
|
||||
});
|
||||
|
||||
expect(logSlashCommand).toHaveBeenCalledTimes(1);
|
||||
expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined);
|
||||
expect(logSlashCommand).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({
|
||||
command: 'logtest',
|
||||
subcommand: undefined,
|
||||
status: SlashCommandStatus.SUCCESS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs nothing for a bogus command', async () => {
|
||||
const result = setupProcessorHook(loggingTestCommands);
|
||||
await waitFor(() =>
|
||||
expect(result.current.slashCommands.length).toBeGreaterThan(0),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/bogusbogusbogus');
|
||||
});
|
||||
|
||||
expect(logSlashCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a failure event for a failed command', async () => {
|
||||
const result = setupProcessorHook(loggingTestCommands);
|
||||
await waitFor(() =>
|
||||
expect(result.current.slashCommands.length).toBeGreaterThan(0),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/fail');
|
||||
});
|
||||
|
||||
expect(logSlashCommand).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({
|
||||
command: 'fail',
|
||||
status: 'error',
|
||||
subcommand: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a slash command with a subcommand', async () => {
|
||||
@@ -930,8 +1002,13 @@ describe('useSlashCommandProcessor', () => {
|
||||
await result.current.handleSlashCommand('/logwithsub sub');
|
||||
});
|
||||
|
||||
expect(logSlashCommand).toHaveBeenCalledTimes(1);
|
||||
expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub');
|
||||
expect(logSlashCommand).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({
|
||||
command: 'logwithsub',
|
||||
subcommand: 'sub',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log the command path when an alias is used', async () => {
|
||||
@@ -942,8 +1019,12 @@ describe('useSlashCommandProcessor', () => {
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/la');
|
||||
});
|
||||
expect(logSlashCommand).toHaveBeenCalledTimes(1);
|
||||
expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined);
|
||||
expect(logSlashCommand).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({
|
||||
command: 'logalias',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not log for unknown commands', async () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
GitService,
|
||||
Logger,
|
||||
logSlashCommand,
|
||||
SlashCommandEvent,
|
||||
makeSlashCommandEvent,
|
||||
SlashCommandStatus,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
@@ -235,77 +236,71 @@ export const useSlashCommandProcessor = (
|
||||
oneTimeShellAllowlist?: Set<string>,
|
||||
overwriteConfirmed?: boolean,
|
||||
): Promise<SlashCommandProcessorResult | false> => {
|
||||
if (typeof rawQuery !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (typeof rawQuery !== 'string') {
|
||||
return false;
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
|
||||
|
||||
const parts = trimmed.substring(1).trim().split(/\s+/);
|
||||
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
|
||||
|
||||
let currentCommands = commands;
|
||||
let commandToExecute: SlashCommand | undefined;
|
||||
let pathIndex = 0;
|
||||
let hasError = false;
|
||||
const canonicalPath: string[] = [];
|
||||
|
||||
for (const part of commandPath) {
|
||||
// TODO: For better performance and architectural clarity, this two-pass
|
||||
// search could be replaced. A more optimal approach would be to
|
||||
// pre-compute a single lookup map in `CommandService.ts` that resolves
|
||||
// all name and alias conflicts during the initial loading phase. The
|
||||
// processor would then perform a single, fast lookup on that map.
|
||||
|
||||
// First pass: check for an exact match on the primary command name.
|
||||
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
|
||||
|
||||
// Second pass: if no primary name matches, check for an alias.
|
||||
if (!foundCommand) {
|
||||
foundCommand = currentCommands.find((cmd) =>
|
||||
cmd.altNames?.includes(part),
|
||||
);
|
||||
}
|
||||
|
||||
const trimmed = rawQuery.trim();
|
||||
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
addItem(
|
||||
{ type: MessageType.USER, text: trimmed },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
const parts = trimmed.substring(1).trim().split(/\s+/);
|
||||
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
|
||||
|
||||
let currentCommands = commands;
|
||||
let commandToExecute: SlashCommand | undefined;
|
||||
let pathIndex = 0;
|
||||
const canonicalPath: string[] = [];
|
||||
|
||||
for (const part of commandPath) {
|
||||
// TODO: For better performance and architectural clarity, this two-pass
|
||||
// search could be replaced. A more optimal approach would be to
|
||||
// pre-compute a single lookup map in `CommandService.ts` that resolves
|
||||
// all name and alias conflicts during the initial loading phase. The
|
||||
// processor would then perform a single, fast lookup on that map.
|
||||
|
||||
// First pass: check for an exact match on the primary command name.
|
||||
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
|
||||
|
||||
// Second pass: if no primary name matches, check for an alias.
|
||||
if (!foundCommand) {
|
||||
foundCommand = currentCommands.find((cmd) =>
|
||||
cmd.altNames?.includes(part),
|
||||
);
|
||||
}
|
||||
|
||||
if (foundCommand) {
|
||||
commandToExecute = foundCommand;
|
||||
canonicalPath.push(foundCommand.name);
|
||||
pathIndex++;
|
||||
if (foundCommand.subCommands) {
|
||||
currentCommands = foundCommand.subCommands;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (foundCommand) {
|
||||
commandToExecute = foundCommand;
|
||||
canonicalPath.push(foundCommand.name);
|
||||
pathIndex++;
|
||||
if (foundCommand.subCommands) {
|
||||
currentCommands = foundCommand.subCommands;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedCommandPath = canonicalPath;
|
||||
const subcommand =
|
||||
resolvedCommandPath.length > 1
|
||||
? resolvedCommandPath.slice(1).join(' ')
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (commandToExecute) {
|
||||
const args = parts.slice(pathIndex).join(' ');
|
||||
|
||||
if (commandToExecute.action) {
|
||||
if (config) {
|
||||
const resolvedCommandPath = canonicalPath;
|
||||
const event = new SlashCommandEvent(
|
||||
resolvedCommandPath[0],
|
||||
resolvedCommandPath.length > 1
|
||||
? resolvedCommandPath.slice(1).join(' ')
|
||||
: undefined,
|
||||
);
|
||||
logSlashCommand(config, event);
|
||||
}
|
||||
|
||||
const fullCommandContext: CommandContext = {
|
||||
...commandContext,
|
||||
invocation: {
|
||||
@@ -327,7 +322,6 @@ export const useSlashCommandProcessor = (
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await commandToExecute.action(
|
||||
fullCommandContext,
|
||||
args,
|
||||
@@ -500,8 +494,18 @@ export const useSlashCommandProcessor = (
|
||||
content: `Unknown command: ${trimmed}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return { type: 'handled' };
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
hasError = true;
|
||||
if (config) {
|
||||
const event = makeSlashCommandEvent({
|
||||
command: resolvedCommandPath[0],
|
||||
subcommand,
|
||||
status: SlashCommandStatus.ERROR,
|
||||
});
|
||||
logSlashCommand(config, event);
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
@@ -511,6 +515,14 @@ export const useSlashCommandProcessor = (
|
||||
);
|
||||
return { type: 'handled' };
|
||||
} finally {
|
||||
if (config && resolvedCommandPath[0] && !hasError) {
|
||||
const event = makeSlashCommandEvent({
|
||||
command: resolvedCommandPath[0],
|
||||
subcommand,
|
||||
status: SlashCommandStatus.SUCCESS,
|
||||
});
|
||||
logSlashCommand(config, event);
|
||||
}
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user