mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-05-24 09:38:34 +00:00
Closes #24333. SlashCommandConflictHandler now treats each conflict event as the current active-conflict snapshot instead of keeping a process-lifetime history. Conflicts that remain active stay deduped, conflicts absent from the latest snapshot are dropped from dedupe state, and reintroduced conflicts notify again. Pending notifications are also filtered against each new snapshot so a conflict that resolves and reappears inside the debounce window is emitted once instead of producing stale or duplicate feedback. CommandService emits the conflict snapshot even when it is empty, which lets the handler observe that all conflicts have resolved. Tests cover continuous dedupe, re-notification after resolution, partial reintroduction, the debounce snapshot path, and the pre-flush resolve/reappear edge case.
282 lines
8.3 KiB
TypeScript
282 lines
8.3 KiB
TypeScript
/**
|
|
* @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<typeof import('@google/gemini-cli-core')>();
|
|
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.",
|
|
);
|
|
});
|
|
});
|