feat(ux): Surface internal errors via unified event system (#11803)

This commit is contained in:
Abhi
2025-10-23 14:14:14 -04:00
committed by GitHub
parent 7787a31f81
commit 3a501196f0
9 changed files with 676 additions and 3 deletions
+78
View File
@@ -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,
);
});
});
}); });
+6 -1
View File
@@ -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,
);
} }
} }
+156 -1
View File
@@ -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',
);
});
});
}); });
+19
View File
@@ -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);
} }
+92 -1
View File
@@ -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),
);
});
});
}); });
+50
View File
@@ -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;
+1
View File
@@ -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';
+159
View File
@@ -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' });
});
});
+115
View File
@@ -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();