feat(sessions): record interactive-only errors and warnings to chat recording JSON files (#13300)

This commit is contained in:
bl-ue
2025-11-19 09:32:13 -07:00
committed by GitHub
parent 282654e7b8
commit e1c711f5ba
14 changed files with 167 additions and 26 deletions
+12 -2
View File
@@ -1392,7 +1392,12 @@ describe('AppContainer State Management', () => {
pressKey({ name: 'c', ctrl: true }, 2); pressKey({ name: 'c', ctrl: true }, 2);
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
unmount(); unmount();
}); });
@@ -1432,7 +1437,12 @@ describe('AppContainer State Management', () => {
pressKey({ name: 'd', ctrl: true }, 2); pressKey({ name: 'd', ctrl: true }, 2);
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
unmount(); unmount();
}); });
+5 -3
View File
@@ -147,7 +147,9 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => { export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props; const { config, initializationResult, resumedSessionData } = props;
const historyManager = useHistory(); const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
useMemoryMonitor(historyManager); useMemoryMonitor(historyManager);
const settings = useSettings(); const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer(); const isAlternateBuffer = useAlternateBuffer();
@@ -1026,7 +1028,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
recordExitFail(config); recordExitFail(config);
} }
if (ctrlCPressCount > 1) { if (ctrlCPressCount > 1) {
handleSlashCommand('/quit'); handleSlashCommand('/quit', undefined, undefined, false);
} else { } else {
ctrlCTimerRef.current = setTimeout(() => { ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0); setCtrlCPressCount(0);
@@ -1044,7 +1046,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
recordExitFail(config); recordExitFail(config);
} }
if (ctrlDPressCount > 1) { if (ctrlDPressCount > 1) {
handleSlashCommand('/quit'); handleSlashCommand('/quit', undefined, undefined, false);
} else { } else {
ctrlDTimerRef.current = setTimeout(() => { ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0); setCtrlDPressCount(0);
@@ -30,6 +30,7 @@ describe('clearCommand', () => {
beforeEach(() => { beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined); mockResetChat = vi.fn().mockResolvedValue(undefined);
const mockGetChatRecordingService = vi.fn();
vi.clearAllMocks(); vi.clearAllMocks();
mockContext = createMockCommandContext({ mockContext = createMockCommandContext({
@@ -38,7 +39,11 @@ describe('clearCommand', () => {
getGeminiClient: () => getGeminiClient: () =>
({ ({
resetChat: mockResetChat, resetChat: mockResetChat,
getChat: () => ({
getChatRecordingService: mockGetChatRecordingService,
}),
}) as unknown as GeminiClient, }) as unknown as GeminiClient,
setSessionId: vi.fn(),
}, },
}, },
}); });
@@ -7,6 +7,7 @@
import { uiTelemetryService } from '@google/gemini-cli-core'; import { uiTelemetryService } from '@google/gemini-cli-core';
import type { SlashCommand } from './types.js'; import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js'; import { CommandKind } from './types.js';
import { randomUUID } from 'node:crypto';
export const clearCommand: SlashCommand = { export const clearCommand: SlashCommand = {
name: 'clear', name: 'clear',
@@ -14,6 +15,11 @@ export const clearCommand: SlashCommand = {
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
action: async (context, _args) => { action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient(); const geminiClient = context.services.config?.getGeminiClient();
const config = context.services.config;
const chatRecordingService = context.services.config
?.getGeminiClient()
?.getChat()
.getChatRecordingService();
if (geminiClient) { if (geminiClient) {
context.ui.setDebugMessage('Clearing terminal and resetting chat.'); context.ui.setDebugMessage('Clearing terminal and resetting chat.');
@@ -24,6 +30,13 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.'); context.ui.setDebugMessage('Clearing terminal.');
} }
// Start a new conversation recording with a new session ID
if (config && chatRecordingService) {
const newSessionId = randomUUID();
config.setSessionId(newSessionId);
chatRecordingService.initialize();
}
uiTelemetryService.setLastPromptTokenCount(0); uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear(); context.ui.clear();
}, },
@@ -311,6 +311,7 @@ export const useSlashCommandProcessor = (
rawQuery: PartListUnion, rawQuery: PartListUnion,
oneTimeShellAllowlist?: Set<string>, oneTimeShellAllowlist?: Set<string>,
overwriteConfirmed?: boolean, overwriteConfirmed?: boolean,
addToHistory: boolean = true,
): Promise<SlashCommandProcessorResult | false> => { ): Promise<SlashCommandProcessorResult | false> => {
if (!commands) { if (!commands) {
return false; return false;
@@ -326,8 +327,13 @@ export const useSlashCommandProcessor = (
setIsProcessing(true); setIsProcessing(true);
const userMessageTimestamp = Date.now(); if (addToHistory) {
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); const userMessageTimestamp = Date.now();
addItem(
{ type: MessageType.USER, text: trimmed },
userMessageTimestamp,
);
}
let hasError = false; let hasError = false;
const { const {
@@ -21,12 +21,13 @@ import type {
LoadedSettings, LoadedSettings,
} from '../../config/settings.js'; } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js';
import { MessageType, type HistoryItem } from '../types.js'; import { MessageType } from '../types.js';
import { import {
type EditorType, type EditorType,
checkHasEditorType, checkHasEditorType,
allowEditorTypeInSandbox, allowEditorTypeInSandbox,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { SettingPaths } from '../../config/settingPaths.js'; import { SettingPaths } from '../../config/settingPaths.js';
@@ -45,9 +46,7 @@ const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox);
describe('useEditorSettings', () => { describe('useEditorSettings', () => {
let mockLoadedSettings: LoadedSettings; let mockLoadedSettings: LoadedSettings;
let mockSetEditorError: MockedFunction<(error: string | null) => void>; let mockSetEditorError: MockedFunction<(error: string | null) => void>;
let mockAddItem: MockedFunction< let mockAddItem: MockedFunction<UseHistoryManagerReturn['addItem']>;
(item: Omit<HistoryItem, 'id'>, timestamp: number) => void
>;
let result: ReturnType<typeof useEditorSettings>; let result: ReturnType<typeof useEditorSettings>;
function TestComponent() { function TestComponent() {
@@ -9,13 +9,14 @@ import type {
LoadableSettingScope, LoadableSettingScope,
LoadedSettings, LoadedSettings,
} from '../../config/settings.js'; } from '../../config/settings.js';
import { type HistoryItem, MessageType } from '../types.js'; import { MessageType } from '../types.js';
import type { EditorType } from '@google/gemini-cli-core'; import type { EditorType } from '@google/gemini-cli-core';
import { import {
allowEditorTypeInSandbox, allowEditorTypeInSandbox,
checkHasEditorType, checkHasEditorType,
getEditorDisplayName, getEditorDisplayName,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { SettingPaths } from '../../config/settingPaths.js'; import { SettingPaths } from '../../config/settingPaths.js';
@@ -32,7 +33,7 @@ interface UseEditorSettingsReturn {
export const useEditorSettings = ( export const useEditorSettings = (
loadedSettings: LoadedSettings, loadedSettings: LoadedSettings,
setEditorError: (error: string | null) => void, setEditorError: (error: string | null) => void,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void, addItem: UseHistoryManagerReturn['addItem'],
): UseEditorSettingsReturn => { ): UseEditorSettingsReturn => {
const [isEditorDialogOpen, setIsEditorDialogOpen] = useState(false); const [isEditorDialogOpen, setIsEditorDialogOpen] = useState(false);
+55 -4
View File
@@ -6,6 +6,7 @@
import { useState, useRef, useCallback, useMemo } from 'react'; import { useState, useRef, useCallback, useMemo } from 'react';
import type { HistoryItem } from '../types.js'; import type { HistoryItem } from '../types.js';
import type { ChatRecordingService } from '@google/gemini-cli-core/src/services/chatRecordingService.js';
// Type for the updater function passed to updateHistoryItem // Type for the updater function passed to updateHistoryItem
type HistoryItemUpdater = ( type HistoryItemUpdater = (
@@ -14,7 +15,11 @@ type HistoryItemUpdater = (
export interface UseHistoryManagerReturn { export interface UseHistoryManagerReturn {
history: HistoryItem[]; history: HistoryItem[];
addItem: (itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number) => number; // Returns the generated ID addItem: (
itemData: Omit<HistoryItem, 'id'>,
baseTimestamp: number,
isResuming?: boolean,
) => number; // Returns the generated ID
updateItem: ( updateItem: (
id: number, id: number,
updates: Partial<Omit<HistoryItem, 'id'>> | HistoryItemUpdater, updates: Partial<Omit<HistoryItem, 'id'>> | HistoryItemUpdater,
@@ -29,7 +34,11 @@ export interface UseHistoryManagerReturn {
* Encapsulates the history array, message ID generation, adding items, * Encapsulates the history array, message ID generation, adding items,
* updating items, and clearing the history. * updating items, and clearing the history.
*/ */
export function useHistory(): UseHistoryManagerReturn { export function useHistory({
chatRecordingService,
}: {
chatRecordingService?: ChatRecordingService | null;
} = {}): UseHistoryManagerReturn {
const [history, setHistory] = useState<HistoryItem[]>([]); const [history, setHistory] = useState<HistoryItem[]>([]);
const messageIdCounterRef = useRef(0); const messageIdCounterRef = useRef(0);
@@ -45,7 +54,11 @@ export function useHistory(): UseHistoryManagerReturn {
// Adds a new item to the history state with a unique ID. // Adds a new item to the history state with a unique ID.
const addItem = useCallback( const addItem = useCallback(
(itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number): number => { (
itemData: Omit<HistoryItem, 'id'>,
baseTimestamp: number,
isResuming: boolean = false,
): number => {
const id = getNextMessageId(baseTimestamp); const id = getNextMessageId(baseTimestamp);
const newItem: HistoryItem = { ...itemData, id } as HistoryItem; const newItem: HistoryItem = { ...itemData, id } as HistoryItem;
@@ -63,9 +76,47 @@ export function useHistory(): UseHistoryManagerReturn {
} }
return [...prevHistory, newItem]; return [...prevHistory, newItem];
}); });
// Record UI-specific messages, but don't do it if we're actually loading
// an existing session.
if (!isResuming && chatRecordingService) {
switch (itemData.type) {
case 'compression':
case 'info':
chatRecordingService?.recordMessage({
model: undefined,
type: 'info',
content: itemData.text ?? '',
});
break;
case 'warning':
chatRecordingService?.recordMessage({
model: undefined,
type: 'warning',
content: itemData.text ?? '',
});
break;
case 'error':
chatRecordingService?.recordMessage({
model: undefined,
type: 'error',
content: itemData.text ?? '',
});
break;
case 'user':
case 'gemini':
case 'gemini_content':
// Core conversation recording handled by GeminiChat.
break;
default:
// Ignore the rest.
break;
}
}
return id; // Return the generated ID (even if not added, to keep signature) return id; // Return the generated ID (even if not added, to keep signature)
}, },
[getNextMessageId], [getNextMessageId, chatRecordingService],
); );
/** /**
@@ -55,6 +55,47 @@ describe('convertSessionToHistoryFormats', () => {
}); });
}); });
it('should convert system, warning, and error messages to appropriate types', () => {
const messages: MessageRecord[] = [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'System message',
type: 'info',
},
{
id: 'msg-2',
timestamp: '2025-01-01T00:02:00Z',
content: 'Warning message',
type: 'warning',
},
{
id: 'msg-3',
timestamp: '2025-01-01T00:03:00Z',
content: 'Error occurred',
type: 'error',
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory[0]).toEqual({
type: MessageType.INFO,
text: 'System message',
});
expect(result.uiHistory[1]).toEqual({
type: MessageType.WARNING,
text: 'Warning message',
});
expect(result.uiHistory[2]).toEqual({
type: MessageType.ERROR,
text: 'Error occurred',
});
// System, warning, and error messages should not be included in client history
expect(result.clientHistory).toEqual([]);
});
it('should filter out slash commands from client history', () => { it('should filter out slash commands from client history', () => {
const messages: MessageRecord[] = [ const messages: MessageRecord[] = [
{ {
+13 -5
View File
@@ -29,6 +29,15 @@ export function convertSessionToHistoryFormats(
case 'user': case 'user':
messageType = MessageType.USER; messageType = MessageType.USER;
break; break;
case 'info':
messageType = MessageType.INFO;
break;
case 'error':
messageType = MessageType.ERROR;
break;
case 'warning':
messageType = MessageType.WARNING;
break;
default: default:
messageType = MessageType.GEMINI; messageType = MessageType.GEMINI;
break; break;
@@ -70,9 +79,9 @@ export function convertSessionToHistoryFormats(
for (const msg of messages) { for (const msg of messages) {
// Skip system/error messages and user slash commands // Skip system/error messages and user slash commands
// if (msg.type === 'system' || msg.type === 'error') { if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
// continue; continue;
// } }
if (msg.type === 'user') { if (msg.type === 'user') {
// Skip user slash commands // Skip user slash commands
@@ -91,8 +100,7 @@ export function convertSessionToHistoryFormats(
}); });
} else if (msg.type === 'gemini') { } else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls // Handle Gemini messages with potential tool calls
const hasToolCalls = const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
'toolCalls' in msg && msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) { if (hasToolCalls) {
// Create model message with function calls // Create model message with function calls
@@ -101,11 +101,13 @@ describe('useSessionResume', () => {
1, 1,
{ type: 'user', text: 'Hello' }, { type: 'user', text: 'Hello' },
0, 0,
true,
); );
expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith(
2, 2,
{ type: 'gemini', text: 'Hi there!' }, { type: 'gemini', text: 'Hi there!' },
1, 1,
true,
); );
expect(mockRefreshStatic).toHaveBeenCalled(); expect(mockRefreshStatic).toHaveBeenCalled();
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
@@ -328,11 +330,13 @@ describe('useSessionResume', () => {
1, 1,
{ type: 'user', text: 'Hello from resumed session' }, { type: 'user', text: 'Hello from resumed session' },
0, 0,
true,
); );
expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith(
2, 2,
{ type: 'gemini', text: 'Welcome back!' }, { type: 'gemini', text: 'Welcome back!' },
1, 1,
true,
); );
expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
}); });
@@ -59,7 +59,7 @@ export function useSessionResume({
setQuittingMessages(null); setQuittingMessages(null);
historyManagerRef.current.clearItems(); historyManagerRef.current.clearItems();
uiHistory.forEach((item, index) => { uiHistory.forEach((item, index) => {
historyManagerRef.current.addItem(item, index); historyManagerRef.current.addItem(item, index, true);
}); });
refreshStaticRef.current(); // Force Static component to re-render with the updated history. refreshStaticRef.current(); // Force Static component to re-render with the updated history.
+3 -2
View File
@@ -10,8 +10,9 @@ import type {
LoadableSettingScope, LoadableSettingScope,
LoadedSettings, LoadedSettings,
} from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting
import { type HistoryItem, MessageType } from '../types.js'; import { MessageType } from '../types.js';
import process from 'node:process'; import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
interface UseThemeCommandReturn { interface UseThemeCommandReturn {
isThemeDialogOpen: boolean; isThemeDialogOpen: boolean;
@@ -24,7 +25,7 @@ interface UseThemeCommandReturn {
export const useThemeCommand = ( export const useThemeCommand = (
loadedSettings: LoadedSettings, loadedSettings: LoadedSettings,
setThemeError: (error: string | null) => void, setThemeError: (error: string | null) => void,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void, addItem: UseHistoryManagerReturn['addItem'],
initialThemeError: string | null, initialThemeError: string | null,
): UseThemeCommandReturn => { ): UseThemeCommandReturn => {
const [isThemeDialogOpen, setIsThemeDialogOpen] = const [isThemeDialogOpen, setIsThemeDialogOpen] =
@@ -62,7 +62,7 @@ export interface ToolCallRecord {
*/ */
export type ConversationRecordExtra = export type ConversationRecordExtra =
| { | {
type: 'user'; type: 'user' | 'info' | 'error' | 'warning';
} }
| { | {
type: 'gemini'; type: 'gemini';