mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(ux): Surface internal errors via unified event system (#11803)
This commit is contained in:
@@ -64,9 +64,12 @@ import {
|
|||||||
loadEnvironment,
|
loadEnvironment,
|
||||||
migrateDeprecatedSettings,
|
migrateDeprecatedSettings,
|
||||||
SettingScope,
|
SettingScope,
|
||||||
|
saveSettings,
|
||||||
|
type SettingsFile,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
import { FatalConfigError, GEMINI_DIR, Storage } from '@google/gemini-cli-core';
|
import { FatalConfigError, GEMINI_DIR, Storage } from '@google/gemini-cli-core';
|
||||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||||
|
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||||
|
|
||||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||||
// Use the (mocked) GEMINI_DIR for consistency
|
// Use the (mocked) GEMINI_DIR for consistency
|
||||||
@@ -96,6 +99,23 @@ vi.mock('fs', async (importOriginal) => {
|
|||||||
|
|
||||||
vi.mock('./extension.js');
|
vi.mock('./extension.js');
|
||||||
|
|
||||||
|
const mockCoreEvents = vi.hoisted(() => ({
|
||||||
|
emitFeedback: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
coreEvents: mockCoreEvents,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../utils/commentJson.js', () => ({
|
||||||
|
updateSettingsFilePreservingFormat: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('strip-json-comments', () => ({
|
vi.mock('strip-json-comments', () => ({
|
||||||
default: vi.fn((content) => content),
|
default: vi.fn((content) => content),
|
||||||
}));
|
}));
|
||||||
@@ -2495,4 +2515,62 @@ describe('Settings Loading and Merging', () => {
|
|||||||
expect(setValueSpy).not.toHaveBeenCalled();
|
expect(setValueSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('saveSettings', () => {
|
||||||
|
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
||||||
|
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||||
|
const settingsFile = {
|
||||||
|
path: '/mock/settings.json',
|
||||||
|
settings: { ui: { theme: 'dark' } },
|
||||||
|
originalSettings: { ui: { theme: 'dark' } },
|
||||||
|
} as unknown as SettingsFile;
|
||||||
|
|
||||||
|
saveSettings(settingsFile);
|
||||||
|
|
||||||
|
expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', {
|
||||||
|
ui: { theme: 'dark' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create directory if it does not exist', () => {
|
||||||
|
const mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||||
|
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
|
||||||
|
mockFsExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
const settingsFile = {
|
||||||
|
path: '/mock/new/dir/settings.json',
|
||||||
|
settings: {},
|
||||||
|
originalSettings: {},
|
||||||
|
} as unknown as SettingsFile;
|
||||||
|
|
||||||
|
saveSettings(settingsFile);
|
||||||
|
|
||||||
|
expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir');
|
||||||
|
expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit error feedback if saving fails', () => {
|
||||||
|
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||||
|
const error = new Error('Write failed');
|
||||||
|
mockUpdateSettings.mockImplementation(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsFile = {
|
||||||
|
path: '/mock/settings.json',
|
||||||
|
settings: {},
|
||||||
|
originalSettings: {},
|
||||||
|
} as unknown as SettingsFile;
|
||||||
|
|
||||||
|
saveSettings(settingsFile);
|
||||||
|
|
||||||
|
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'There was an error saving your latest settings changes.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
Storage,
|
Storage,
|
||||||
|
coreEvents,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import stripJsonComments from 'strip-json-comments';
|
import stripJsonComments from 'strip-json-comments';
|
||||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||||
@@ -799,6 +800,10 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
|||||||
settingsToSave as Record<string, unknown>,
|
settingsToSave as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving user settings file:', error);
|
coreEvents.emitFeedback(
|
||||||
|
'error',
|
||||||
|
'There was an error saving your latest settings changes.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SessionMetrics,
|
SessionMetrics,
|
||||||
AnyDeclarativeTool,
|
AnyDeclarativeTool,
|
||||||
AnyToolInvocation,
|
AnyToolInvocation,
|
||||||
|
UserFeedbackPayload,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
executeToolCall,
|
executeToolCall,
|
||||||
@@ -20,14 +21,32 @@ import {
|
|||||||
OutputFormat,
|
OutputFormat,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
FatalInputError,
|
FatalInputError,
|
||||||
|
CoreEvent,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
vi,
|
||||||
|
type Mock,
|
||||||
|
type MockInstance,
|
||||||
|
} from 'vitest';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
|
|
||||||
// Mock core modules
|
// Mock core modules
|
||||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||||
|
|
||||||
|
const mockCoreEvents = vi.hoisted(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
drainFeedbackBacklog: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const original =
|
const original =
|
||||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
@@ -48,6 +67,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
uiTelemetryService: {
|
uiTelemetryService: {
|
||||||
getMetrics: vi.fn(),
|
getMetrics: vi.fn(),
|
||||||
},
|
},
|
||||||
|
coreEvents: mockCoreEvents,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +90,7 @@ describe('runNonInteractive', () => {
|
|||||||
let mockShutdownTelemetry: Mock;
|
let mockShutdownTelemetry: Mock;
|
||||||
let consoleErrorSpy: MockInstance;
|
let consoleErrorSpy: MockInstance;
|
||||||
let processStdoutSpy: MockInstance;
|
let processStdoutSpy: MockInstance;
|
||||||
|
let processStderrSpy: MockInstance;
|
||||||
let mockGeminiClient: {
|
let mockGeminiClient: {
|
||||||
sendMessageStream: Mock;
|
sendMessageStream: Mock;
|
||||||
getChatRecordingService: Mock;
|
getChatRecordingService: Mock;
|
||||||
@@ -87,6 +108,9 @@ describe('runNonInteractive', () => {
|
|||||||
processStdoutSpy = vi
|
processStdoutSpy = vi
|
||||||
.spyOn(process.stdout, 'write')
|
.spyOn(process.stdout, 'write')
|
||||||
.mockImplementation(() => true);
|
.mockImplementation(() => true);
|
||||||
|
processStderrSpy = vi
|
||||||
|
.spyOn(process.stderr, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit(${code}) called`);
|
throw new Error(`process.exit(${code}) called`);
|
||||||
});
|
});
|
||||||
@@ -1051,4 +1075,135 @@ describe('runNonInteractive', () => {
|
|||||||
);
|
);
|
||||||
expect(processStdoutSpy).toHaveBeenCalledWith('file.txt');
|
expect(processStdoutSpy).toHaveBeenCalledWith('file.txt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CoreEvents Integration', () => {
|
||||||
|
it('subscribes to UserFeedback and drains backlog on start', async () => {
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'test',
|
||||||
|
'prompt-id-events',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCoreEvents.on).toHaveBeenCalledWith(
|
||||||
|
CoreEvent.UserFeedback,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes from UserFeedback on finish', async () => {
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'test',
|
||||||
|
'prompt-id-events',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCoreEvents.off).toHaveBeenCalledWith(
|
||||||
|
CoreEvent.UserFeedback,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs to process.stderr when UserFeedback event is received', async () => {
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'test',
|
||||||
|
'prompt-id-events',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the registered handler
|
||||||
|
const handler = mockCoreEvents.on.mock.calls.find(
|
||||||
|
(call: unknown[]) => call[0] === CoreEvent.UserFeedback,
|
||||||
|
)?.[1];
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
// Simulate an event
|
||||||
|
const payload: UserFeedbackPayload = {
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Test error message',
|
||||||
|
};
|
||||||
|
handler(payload);
|
||||||
|
|
||||||
|
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||||
|
'[ERROR] Test error message\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs optional error object to process.stderr in debug mode', async () => {
|
||||||
|
vi.mocked(mockConfig.getDebugMode).mockReturnValue(true);
|
||||||
|
const events: ServerGeminiStreamEvent[] = [
|
||||||
|
{
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||||
|
createStreamFromEvents(events),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNonInteractive(
|
||||||
|
mockConfig,
|
||||||
|
mockSettings,
|
||||||
|
'test',
|
||||||
|
'prompt-id-events',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the registered handler
|
||||||
|
const handler = mockCoreEvents.on.mock.calls.find(
|
||||||
|
(call: unknown[]) => call[0] === CoreEvent.UserFeedback,
|
||||||
|
)?.[1];
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
// Simulate an event with error object
|
||||||
|
const errorObj = new Error('Original error');
|
||||||
|
// Mock stack for deterministic testing
|
||||||
|
errorObj.stack = 'Error: Original error\n at test';
|
||||||
|
const payload: UserFeedbackPayload = {
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Test warning message',
|
||||||
|
error: errorObj,
|
||||||
|
};
|
||||||
|
handler(payload);
|
||||||
|
|
||||||
|
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||||
|
'[WARNING] Test warning message\n',
|
||||||
|
);
|
||||||
|
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||||
|
'Error: Original error\n at test\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
Config,
|
Config,
|
||||||
ToolCallRequestInfo,
|
ToolCallRequestInfo,
|
||||||
CompletedToolCall,
|
CompletedToolCall,
|
||||||
|
UserFeedbackPayload,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
JsonStreamEventType,
|
JsonStreamEventType,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
|
coreEvents,
|
||||||
|
CoreEvent,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import type { Content, Part } from '@google/genai';
|
import type { Content, Part } from '@google/genai';
|
||||||
@@ -50,6 +53,18 @@ export async function runNonInteractive(
|
|||||||
debugMode: config.getDebugMode(),
|
debugMode: config.getDebugMode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleUserFeedback = (payload: UserFeedbackPayload) => {
|
||||||
|
const prefix = payload.severity.toUpperCase();
|
||||||
|
process.stderr.write(`[${prefix}] ${payload.message}\n`);
|
||||||
|
if (payload.error && config.getDebugMode()) {
|
||||||
|
const errorToLog =
|
||||||
|
payload.error instanceof Error
|
||||||
|
? payload.error.stack || payload.error.message
|
||||||
|
: String(payload.error);
|
||||||
|
process.stderr.write(`${errorToLog}\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const streamFormatter =
|
const streamFormatter =
|
||||||
config.getOutputFormat() === OutputFormat.STREAM_JSON
|
config.getOutputFormat() === OutputFormat.STREAM_JSON
|
||||||
@@ -58,6 +73,9 @@ export async function runNonInteractive(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
consolePatcher.patch();
|
consolePatcher.patch();
|
||||||
|
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
|
||||||
|
coreEvents.drainFeedbackBacklog();
|
||||||
|
|
||||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === 'EPIPE') {
|
if (err.code === 'EPIPE') {
|
||||||
@@ -287,6 +305,7 @@ export async function runNonInteractive(
|
|||||||
handleError(error, config);
|
handleError(error, config);
|
||||||
} finally {
|
} finally {
|
||||||
consolePatcher.cleanup();
|
consolePatcher.cleanup();
|
||||||
|
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||||
if (isTelemetrySdkInitialized()) {
|
if (isTelemetrySdkInitialized()) {
|
||||||
await shutdownTelemetry(config);
|
await shutdownTelemetry(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,29 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { render, cleanup } from 'ink-testing-library';
|
import { render, cleanup } from 'ink-testing-library';
|
||||||
import { AppContainer } from './AppContainer.js';
|
import { AppContainer } from './AppContainer.js';
|
||||||
import { type Config, makeFakeConfig } from '@google/gemini-cli-core';
|
import {
|
||||||
|
type Config,
|
||||||
|
makeFakeConfig,
|
||||||
|
CoreEvent,
|
||||||
|
type UserFeedbackPayload,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
// Mock coreEvents
|
||||||
|
const mockCoreEvents = vi.hoisted(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
drainFeedbackBacklog: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
coreEvents: mockCoreEvents,
|
||||||
|
};
|
||||||
|
});
|
||||||
import type { LoadedSettings } from '../config/settings.js';
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
import type { InitializationResult } from '../core/initializer.js';
|
import type { InitializationResult } from '../core/initializer.js';
|
||||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||||
@@ -1293,4 +1315,73 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(mockCloseModelDialog).toHaveBeenCalled();
|
expect(mockCloseModelDialog).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CoreEvents Integration', () => {
|
||||||
|
it('subscribes to UserFeedback and drains backlog on mount', () => {
|
||||||
|
render(
|
||||||
|
<AppContainer
|
||||||
|
config={mockConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version="1.0.0"
|
||||||
|
initializationResult={mockInitResult}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCoreEvents.on).toHaveBeenCalledWith(
|
||||||
|
CoreEvent.UserFeedback,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes from UserFeedback on unmount', () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<AppContainer
|
||||||
|
config={mockConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version="1.0.0"
|
||||||
|
initializationResult={mockInitResult}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockCoreEvents.off).toHaveBeenCalledWith(
|
||||||
|
CoreEvent.UserFeedback,
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds history item when UserFeedback event is received', () => {
|
||||||
|
render(
|
||||||
|
<AppContainer
|
||||||
|
config={mockConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version="1.0.0"
|
||||||
|
initializationResult={mockInitResult}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the registered handler
|
||||||
|
const handler = mockCoreEvents.on.mock.calls.find(
|
||||||
|
(call: unknown[]) => call[0] === CoreEvent.UserFeedback,
|
||||||
|
)?.[1];
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
// Simulate an event
|
||||||
|
const payload: UserFeedbackPayload = {
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Test error message',
|
||||||
|
};
|
||||||
|
handler(payload);
|
||||||
|
|
||||||
|
expect(mockedUseHistory().addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Test error message',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
type IdeInfo,
|
type IdeInfo,
|
||||||
type IdeContext,
|
type IdeContext,
|
||||||
type UserTierId,
|
type UserTierId,
|
||||||
|
type UserFeedbackPayload,
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
IdeClient,
|
IdeClient,
|
||||||
ideContextStore,
|
ideContextStore,
|
||||||
@@ -44,6 +45,8 @@ import {
|
|||||||
recordExitFail,
|
recordExitFail,
|
||||||
ShellExecutionService,
|
ShellExecutionService,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
|
coreEvents,
|
||||||
|
CoreEvent,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { validateAuthMethod } from '../config/auth.js';
|
import { validateAuthMethod } from '../config/auth.js';
|
||||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||||
@@ -1066,6 +1069,53 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
stdout,
|
stdout,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUserFeedback = (payload: UserFeedbackPayload) => {
|
||||||
|
let type: MessageType;
|
||||||
|
switch (payload.severity) {
|
||||||
|
case 'error':
|
||||||
|
type = MessageType.ERROR;
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
type = MessageType.WARNING;
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
type = MessageType.INFO;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected severity for user feedback: ${payload.severity}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
historyManager.addItem(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
text: payload.message,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there is an attached error object, log it to the debug drawer.
|
||||||
|
if (payload.error) {
|
||||||
|
debugLogger.warn(
|
||||||
|
`[Feedback Details for "${payload.message}"]`,
|
||||||
|
payload.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
|
||||||
|
|
||||||
|
// Flush any messages that happened during startup before this component
|
||||||
|
// mounted.
|
||||||
|
coreEvents.drainFeedbackBacklog();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||||
|
};
|
||||||
|
}, [historyManager]);
|
||||||
|
|
||||||
const filteredConsoleMessages = useMemo(() => {
|
const filteredConsoleMessages = useMemo(() => {
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
return consoleMessages;
|
return consoleMessages;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export * from './utils/partUtils.js';
|
|||||||
export * from './utils/promptIdContext.js';
|
export * from './utils/promptIdContext.js';
|
||||||
export * from './utils/thoughtUtils.js';
|
export * from './utils/thoughtUtils.js';
|
||||||
export * from './utils/debugLogger.js';
|
export * from './utils/debugLogger.js';
|
||||||
|
export * from './utils/events.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
CoreEventEmitter,
|
||||||
|
CoreEvent,
|
||||||
|
type UserFeedbackPayload,
|
||||||
|
} from './events.js';
|
||||||
|
|
||||||
|
describe('CoreEventEmitter', () => {
|
||||||
|
let events: CoreEventEmitter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
events = new CoreEventEmitter();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit feedback immediately when a listener is present', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
severity: 'info' as const,
|
||||||
|
message: 'Test message',
|
||||||
|
};
|
||||||
|
|
||||||
|
events.emitFeedback(payload.severity, payload.message);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should buffer feedback when no listener is present', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const payload = {
|
||||||
|
severity: 'warning' as const,
|
||||||
|
message: 'Buffered message',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit while no listeners attached
|
||||||
|
events.emitFeedback(payload.severity, payload.message);
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Attach listener and drain
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
events.drainFeedbackBacklog();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect the backlog size limit and maintain FIFO order', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const MAX_BACKLOG_SIZE = 10000;
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) {
|
||||||
|
events.emitFeedback('info', `Message ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
events.drainFeedbackBacklog();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE);
|
||||||
|
// Verify strictly that the FIRST call was Message 10 (0-9 dropped)
|
||||||
|
expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Message 10' });
|
||||||
|
// Verify strictly that the LAST call was Message 109
|
||||||
|
expect(listener.mock.lastCall?.[0]).toMatchObject({
|
||||||
|
message: `Message ${MAX_BACKLOG_SIZE + 9}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the backlog after draining', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.emitFeedback('error', 'Test error');
|
||||||
|
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
events.drainFeedbackBacklog();
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
listener.mockClear();
|
||||||
|
events.drainFeedbackBacklog();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include optional error object in payload', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
|
||||||
|
const error = new Error('Original error');
|
||||||
|
events.emitFeedback('error', 'Something went wrong', error);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Something went wrong',
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple listeners correctly', () => {
|
||||||
|
const listenerA = vi.fn();
|
||||||
|
const listenerB = vi.fn();
|
||||||
|
|
||||||
|
events.on(CoreEvent.UserFeedback, listenerA);
|
||||||
|
events.on(CoreEvent.UserFeedback, listenerB);
|
||||||
|
|
||||||
|
events.emitFeedback('info', 'Broadcast message');
|
||||||
|
|
||||||
|
expect(listenerA).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listenerB).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop receiving events after off() is called', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
|
||||||
|
events.emitFeedback('info', 'First message');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
events.off(CoreEvent.UserFeedback, listener);
|
||||||
|
events.emitFeedback('info', 'Second message');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1); // Still 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle re-entrant feedback emission during draining safely', () => {
|
||||||
|
events.emitFeedback('info', 'Buffered 1');
|
||||||
|
events.emitFeedback('info', 'Buffered 2');
|
||||||
|
|
||||||
|
const listener = vi.fn((payload: UserFeedbackPayload) => {
|
||||||
|
// When 'Buffered 1' is received, immediately emit another event.
|
||||||
|
if (payload.message === 'Buffered 1') {
|
||||||
|
events.emitFeedback('warning', 'Re-entrant message');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on(CoreEvent.UserFeedback, listener);
|
||||||
|
events.drainFeedbackBacklog();
|
||||||
|
|
||||||
|
// Expectation with atomic snapshot:
|
||||||
|
// 1. loop starts with ['Buffered 1', 'Buffered 2']
|
||||||
|
// 2. emits 'Buffered 1'
|
||||||
|
// 3. listener fires for 'Buffered 1', calls emitFeedback('Re-entrant')
|
||||||
|
// 4. emitFeedback sees listener attached, emits 'Re-entrant' synchronously
|
||||||
|
// 5. listener fires for 'Re-entrant'
|
||||||
|
// 6. loop continues, emits 'Buffered 2'
|
||||||
|
// 7. listener fires for 'Buffered 2'
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(3);
|
||||||
|
expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Buffered 1' });
|
||||||
|
expect(listener.mock.calls[1][0]).toMatchObject({
|
||||||
|
message: 'Re-entrant message',
|
||||||
|
});
|
||||||
|
expect(listener.mock.calls[2][0]).toMatchObject({ message: 'Buffered 2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the severity level for user-facing feedback.
|
||||||
|
* This maps loosely to UI `MessageType`
|
||||||
|
*/
|
||||||
|
export type FeedbackSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for the 'user-feedback' event.
|
||||||
|
*/
|
||||||
|
export interface UserFeedbackPayload {
|
||||||
|
/**
|
||||||
|
* The severity level determines how the message is rendered in the UI
|
||||||
|
* (e.g. colored text, specific icon).
|
||||||
|
*/
|
||||||
|
severity: FeedbackSeverity;
|
||||||
|
/**
|
||||||
|
* The main message to display to the user in the chat history or stdout.
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
/**
|
||||||
|
* The original error object, if applicable.
|
||||||
|
* Listeners can use this to extract stack traces for debug logging
|
||||||
|
* or verbose output, while keeping the 'message' field clean for end users.
|
||||||
|
*/
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CoreEvent {
|
||||||
|
UserFeedback = 'user-feedback',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoreEventEmitter extends EventEmitter {
|
||||||
|
private _feedbackBacklog: UserFeedbackPayload[] = [];
|
||||||
|
private static readonly MAX_BACKLOG_SIZE = 10000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends actionable feedback to the user.
|
||||||
|
* Buffers automatically if the UI hasn't subscribed yet.
|
||||||
|
*/
|
||||||
|
emitFeedback(
|
||||||
|
severity: FeedbackSeverity,
|
||||||
|
message: string,
|
||||||
|
error?: unknown,
|
||||||
|
): void {
|
||||||
|
const payload: UserFeedbackPayload = { severity, message, error };
|
||||||
|
|
||||||
|
if (this.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||||
|
if (this._feedbackBacklog.length >= CoreEventEmitter.MAX_BACKLOG_SIZE) {
|
||||||
|
this._feedbackBacklog.shift();
|
||||||
|
}
|
||||||
|
this._feedbackBacklog.push(payload);
|
||||||
|
} else {
|
||||||
|
this.emit(CoreEvent.UserFeedback, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||||
|
* subscribes.
|
||||||
|
*/
|
||||||
|
drainFeedbackBacklog(): void {
|
||||||
|
const backlog = [...this._feedbackBacklog];
|
||||||
|
this._feedbackBacklog.length = 0; // Clear in-place
|
||||||
|
for (const payload of backlog) {
|
||||||
|
this.emit(CoreEvent.UserFeedback, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override on(
|
||||||
|
event: CoreEvent.UserFeedback,
|
||||||
|
listener: (payload: UserFeedbackPayload) => void,
|
||||||
|
): this;
|
||||||
|
override on(
|
||||||
|
event: string | symbol,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
listener: (...args: any[]) => void,
|
||||||
|
): this {
|
||||||
|
return super.on(event, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
override off(
|
||||||
|
event: CoreEvent.UserFeedback,
|
||||||
|
listener: (payload: UserFeedbackPayload) => void,
|
||||||
|
): this;
|
||||||
|
override off(
|
||||||
|
event: string | symbol,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
listener: (...args: any[]) => void,
|
||||||
|
): this {
|
||||||
|
return super.off(event, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
override emit(
|
||||||
|
event: CoreEvent.UserFeedback,
|
||||||
|
payload: UserFeedbackPayload,
|
||||||
|
): boolean;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
override emit(event: string | symbol, ...args: any[]): boolean {
|
||||||
|
return super.emit(event, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coreEvents = new CoreEventEmitter();
|
||||||
Reference in New Issue
Block a user