/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SlashCommandConflictHandler } from './SlashCommandConflictHandler.js'; import { coreEvents, CoreEvent, type SlashCommandConflictsPayload, type SlashCommandConflict, } from '@google/gemini-cli-core'; import { CommandKind } from '../ui/commands/types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { on: vi.fn(), off: vi.fn(), emitFeedback: vi.fn(), }, }; }); describe('SlashCommandConflictHandler', () => { let handler: SlashCommandConflictHandler; /** * Helper to find and invoke the registered conflict event listener. */ const simulateEvent = (conflicts: SlashCommandConflict[]) => { const callback = vi .mocked(coreEvents.on) .mock.calls.find( (call) => call[0] === CoreEvent.SlashCommandConflicts, )![1] as (payload: SlashCommandConflictsPayload) => void; callback({ conflicts }); }; beforeEach(() => { vi.useFakeTimers(); handler = new SlashCommandConflictHandler(); handler.start(); }); afterEach(() => { handler.stop(); vi.clearAllMocks(); vi.useRealTimers(); }); it('should listen for conflict events on start', () => { expect(coreEvents.on).toHaveBeenCalledWith( CoreEvent.SlashCommandConflicts, expect.any(Function), ); }); it('should display a descriptive message for a single extension conflict', () => { simulateEvent([ { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "Extension 'firebase' command '/deploy' was renamed to '/firebase.deploy' because it conflicts with built-in command.", ); }); it('should display a descriptive message for a single MCP conflict', () => { simulateEvent([ { name: 'pickle', renamedTo: 'test-server.pickle', loserMcpServerName: 'test-server', loserKind: CommandKind.MCP_PROMPT, winnerExtensionName: 'pickle-rick', winnerKind: CommandKind.EXTENSION_FILE, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "MCP server 'test-server' command '/pickle' was renamed to '/test-server.pickle' because it conflicts with extension 'pickle-rick' command.", ); }); it('should group multiple conflicts for the same command name', () => { simulateEvent([ { name: 'launch', renamedTo: 'user.launch', loserKind: CommandKind.USER_FILE, winnerKind: CommandKind.WORKSPACE_FILE, }, { name: 'launch', renamedTo: 'workspace.launch', loserKind: CommandKind.WORKSPACE_FILE, winnerKind: CommandKind.USER_FILE, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', `Conflicts detected for command '/launch': - User command '/launch' was renamed to '/user.launch' - Workspace command '/launch' was renamed to '/workspace.launch'`, ); }); it('should debounce newly active conflicts within the flush window', () => { const conflictA = { name: 'a', renamedTo: 'user.a', loserKind: CommandKind.USER_FILE, winnerKind: CommandKind.BUILT_IN, }; const conflictB = { name: 'b', renamedTo: 'user.b', loserKind: CommandKind.USER_FILE, winnerKind: CommandKind.BUILT_IN, }; simulateEvent([conflictA]); vi.advanceTimersByTime(200); simulateEvent([conflictA, conflictB]); vi.advanceTimersByTime(600); // Should emit two feedbacks (one for each unique command name) expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(2); }); it('should deduplicate already notified conflicts', () => { const conflict = { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }; simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); vi.mocked(coreEvents.emitFeedback).mockClear(); simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); }); it('should re-notify when a previously-resolved conflict reappears', () => { const conflict = { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }; // Initial occurrence triggers a notification. simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); vi.mocked(coreEvents.emitFeedback).mockClear(); // Conflict resolved: the next emission is empty (no conflicts active). simulateEvent([]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); // Same conflict reintroduced later: a fresh notification must fire, // because the dedup state was cleared when the conflict went away. simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); }); it('should not duplicate pending notifications when a conflict resolves and reappears before flush', () => { const conflict = { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }; simulateEvent([conflict]); vi.advanceTimersByTime(200); simulateEvent([]); vi.advanceTimersByTime(200); simulateEvent([conflict]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "Extension 'firebase' command '/deploy' was renamed to '/firebase.deploy' because it conflicts with built-in command.", ); }); it('should re-notify only the conflicts that were resolved and reappeared', () => { const conflictA = { name: 'deploy', renamedTo: 'firebase.deploy', loserExtensionName: 'firebase', loserKind: CommandKind.EXTENSION_FILE, winnerKind: CommandKind.BUILT_IN, }; const conflictB = { name: 'launch', renamedTo: 'workspace.launch', loserKind: CommandKind.WORKSPACE_FILE, winnerKind: CommandKind.USER_FILE, }; // Both active: each gets a notification. simulateEvent([conflictA, conflictB]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(2); vi.mocked(coreEvents.emitFeedback).mockClear(); // Only A is still active. B should be dropped from dedup. simulateEvent([conflictA]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); // Both reported again. A is still deduped (continuously active); // B re-fires because it was resolved between events. simulateEvent([conflictA, conflictB]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "Workspace command '/launch' was renamed to '/workspace.launch' because it conflicts with user command.", ); }); it('should display a descriptive message for a skill conflict', () => { simulateEvent([ { name: 'chat', renamedTo: 'google-workspace.chat', loserExtensionName: 'google-workspace', loserKind: CommandKind.SKILL, winnerKind: CommandKind.BUILT_IN, }, ]); vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', "Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.", ); }); });