Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin
2025-12-03 18:04:48 +08:00
committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
114 changed files with 9257 additions and 4039 deletions

View File

@@ -110,6 +110,9 @@ describe('useSlashCommandProcessor', () => {
const mockSetQuittingMessages = vi.fn();
const mockConfig = makeFakeConfig({});
mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
recordSlashCommand: vi.fn(),
});
const mockSettings = {} as LoadedSettings;
beforeEach(() => {
@@ -305,11 +308,15 @@ describe('useSlashCommandProcessor', () => {
expect(childAction).toHaveBeenCalledWith(
expect.objectContaining({
invocation: expect.objectContaining({
name: 'child',
args: 'with args',
}),
services: expect.objectContaining({
config: mockConfig,
}),
ui: expect.objectContaining({
addItem: mockAddItem,
addItem: expect.any(Function),
}),
}),
'with args',

View File

@@ -6,17 +6,15 @@
import { useCallback, useMemo, useEffect, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
type Logger,
type Config,
GitService,
Logger,
logSlashCommand,
makeSlashCommandEvent,
SlashCommandStatus,
ToolConfirmationOutcome,
Storage,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -41,6 +39,27 @@ import {
type ExtensionUpdateStatus,
} from '../state/extensions.js';
type SerializableHistoryItem = Record<string, unknown>;
function serializeHistoryItemForRecording(
item: Omit<HistoryItem, 'id'>,
): SerializableHistoryItem {
const clone: SerializableHistoryItem = { ...item };
if ('timestamp' in clone && clone['timestamp'] instanceof Date) {
clone['timestamp'] = clone['timestamp'].toISOString();
}
return clone;
}
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'quit',
'quit-confirm',
'exit',
'clear',
'reset',
'new',
]);
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openThemeDialog: () => void;
@@ -75,8 +94,9 @@ export const useSlashCommandProcessor = (
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
logger: Logger | null,
) => {
const session = useSessionStats();
const { stats: sessionStats, startNewSession } = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const [reloadTrigger, setReloadTrigger] = useState(0);
@@ -110,16 +130,6 @@ export const useSlashCommandProcessor = (
return new GitService(config.getProjectRoot(), config.storage);
}, [config]);
const logger = useMemo(() => {
const l = new Logger(
config?.getSessionId() || '',
config?.storage ?? new Storage(process.cwd()),
);
// The logger's initialize is async, but we can create the instance
// synchronously. Commands that use it will await its initialization.
return l;
}, [config]);
const [pendingItem, setPendingItem] = useState<HistoryItemWithoutId | null>(
null,
);
@@ -218,8 +228,9 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
},
session: {
stats: session.stats,
stats: sessionStats,
sessionShellAllowlist,
startNewSession,
},
}),
[
@@ -231,7 +242,8 @@ export const useSlashCommandProcessor = (
addItem,
clearItems,
refreshStatic,
session.stats,
sessionStats,
startNewSession,
actions,
pendingItem,
setPendingItem,
@@ -302,10 +314,25 @@ export const useSlashCommandProcessor = (
return false;
}
const recordedItems: Array<Omit<HistoryItem, 'id'>> = [];
const recordItem = (item: Omit<HistoryItem, 'id'>) => {
recordedItems.push(item);
};
const addItemWithRecording: UseHistoryManagerReturn['addItem'] = (
item,
timestamp,
) => {
recordItem(item);
return addItem(item, timestamp);
};
setIsProcessing(true);
const userMessageTimestamp = Date.now();
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
addItemWithRecording(
{ type: MessageType.USER, text: trimmed },
userMessageTimestamp,
);
let hasError = false;
const {
@@ -324,6 +351,10 @@ export const useSlashCommandProcessor = (
if (commandToExecute.action) {
const fullCommandContext: CommandContext = {
...commandContext,
ui: {
...commandContext.ui,
addItem: addItemWithRecording,
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
@@ -428,15 +459,7 @@ export const useSlashCommandProcessor = (
return;
}
if (shouldQuit) {
if (action === 'save_and_quit') {
// First save conversation with auto-generated tag, then quit
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-');
const autoSaveTag = `auto-save chat ${timestamp}`;
handleSlashCommand(`/chat save "${autoSaveTag}"`);
setTimeout(() => handleSlashCommand('/quit'), 100);
} else if (action === 'summary_and_quit') {
if (action === 'summary_and_quit') {
// Generate summary and then quit
handleSlashCommand('/summary')
.then(() => {
@@ -447,7 +470,7 @@ export const useSlashCommandProcessor = (
})
.catch((error) => {
// If summary fails, still quit but show error
addItem(
addItemWithRecording(
{
type: 'error',
text: `Failed to generate summary before quit: ${
@@ -466,7 +489,7 @@ export const useSlashCommandProcessor = (
} else {
// Just quit immediately - trigger the actual quit action
const now = Date.now();
const { sessionStartTime } = session.stats;
const { sessionStartTime } = sessionStats;
const wallDuration = now - sessionStartTime.getTime();
actions.quit([
@@ -550,7 +573,7 @@ export const useSlashCommandProcessor = (
});
if (!confirmed) {
addItem(
addItemWithRecording(
{
type: MessageType.INFO,
text: 'Operation cancelled.',
@@ -606,7 +629,7 @@ export const useSlashCommandProcessor = (
});
logSlashCommand(config, event);
}
addItem(
addItemWithRecording(
{
type: MessageType.ERROR,
text: e instanceof Error ? e.message : String(e),
@@ -615,6 +638,38 @@ export const useSlashCommandProcessor = (
);
return { type: 'handled' };
} finally {
if (config?.getChatRecordingService) {
const chatRecorder = config.getChatRecordingService();
const primaryCommand =
resolvedCommandPath[0] ||
trimmed.replace(/^[/?]/, '').split(/\s+/)[0] ||
trimmed;
const shouldRecord =
!SLASH_COMMANDS_SKIP_RECORDING.has(primaryCommand);
try {
if (shouldRecord) {
chatRecorder?.recordSlashCommand({
phase: 'invocation',
rawCommand: trimmed,
});
const outputItems = recordedItems
.filter((item) => item.type !== 'user')
.map(serializeHistoryItemForRecording);
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: trimmed,
outputHistoryItems: outputItems,
});
}
} catch (recordError) {
if (config.getDebugMode()) {
console.error(
'[slashCommand] Failed to record slash command:',
recordError,
);
}
}
}
if (config && resolvedCommandPath[0] && !hasError) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
@@ -637,7 +692,7 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
session.stats,
sessionStats,
],
);

View File

@@ -152,6 +152,9 @@ vi.mock('../contexts/SessionContext.js', () => ({
startNewPrompt: mockStartNewPrompt,
addUsage: mockAddUsage,
getPromptCount: vi.fn(() => 5),
stats: {
sessionId: 'test-session-id',
},
})),
}));
@@ -514,6 +517,7 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
);
});
@@ -840,6 +844,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
{ isContinuation: true },
);
});
@@ -1165,6 +1170,7 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@@ -1191,6 +1197,7 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@@ -1209,6 +1216,7 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@@ -1227,6 +1235,7 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@@ -2151,6 +2160,7 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined, // Argument 4: Options (undefined for normal prompts)
);
});
@@ -2509,6 +2519,7 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
// Verify only the first query was added to history
@@ -2560,12 +2571,14 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
@@ -2588,6 +2601,7 @@ describe('useGeminiStream', () => {
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});

View File

@@ -124,9 +124,13 @@ export const useGeminiStream = (
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const {
startNewPrompt,
getPromptCount,
stats: sessionStates,
} = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
const logger = useLogger(storage, sessionStates.sessionId);
const gitService = useMemo(() => {
if (!config.getProjectRoot()) {
return;
@@ -849,21 +853,24 @@ export const useGeminiStream = (
const finalQueryToSend = queryToSend;
if (!options?.isContinuation) {
// trigger new prompt event for session stats in CLI
startNewPrompt();
// log user prompt event for telemetry, only text prompts for now
if (typeof queryToSend === 'string') {
// logging the text prompts only for now
const promptText = queryToSend;
logUserPrompt(
config,
new UserPromptEvent(
promptText.length,
queryToSend.length,
prompt_id,
config.getContentGeneratorConfig()?.authType,
promptText,
queryToSend,
),
);
}
startNewPrompt();
setThought(null); // Reset thought when starting a new prompt
// Reset thought when starting a new prompt
setThought(null);
}
setIsResponding(true);
@@ -874,6 +881,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
options,
);
const processingStatus = await processGeminiStreamEvents(
stream,

View File

@@ -6,15 +6,19 @@
import { useState, useEffect } from 'react';
import type { Storage } from '@qwen-code/qwen-code-core';
import { sessionId, Logger } from '@qwen-code/qwen-code-core';
import { Logger } from '@qwen-code/qwen-code-core';
/**
* Hook to manage the logger instance.
*/
export const useLogger = (storage: Storage) => {
export const useLogger = (storage: Storage, sessionId: string) => {
const [logger, setLogger] = useState<Logger | null>(null);
useEffect(() => {
if (!sessionId) {
return;
}
const newLogger = new Logger(sessionId, storage);
/**
* Start async initialization, no need to await. Using await slows down the
@@ -27,7 +31,7 @@ export const useLogger = (storage: Storage) => {
setLogger(newLogger);
})
.catch(() => {});
}, [storage]);
}, [storage, sessionId]);
return logger;
};

View File

@@ -21,8 +21,6 @@ export const useQuitConfirmation = () => {
return { shouldQuit: false, action: 'cancel' };
} else if (choice === QuitChoice.QUIT) {
return { shouldQuit: true, action: 'quit' };
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
return { shouldQuit: true, action: 'save_and_quit' };
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
return { shouldQuit: true, action: 'summary_and_quit' };
}

View File

@@ -133,14 +133,14 @@ export function useReactToolScheduler(
const scheduler = useMemo(
() =>
new CoreToolScheduler({
config,
chatRecordingService: config.getChatRecordingService(),
outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor,
config,
onEditorClose,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
}),
[
config,
outputUpdateHandler,

View File

@@ -186,7 +186,11 @@ describe('useSlashCompletion', () => {
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
}),
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
createTestCommand({
name: 'clear',
altNames: ['reset', 'new'],
description: 'Clear the screen',
}),
createTestCommand({
name: 'memory',
description: 'Manage memory',
@@ -207,7 +211,13 @@ describe('useSlashCompletion', () => {
expect(result.current.suggestions.length).toBe(slashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
expect.arrayContaining([
'help (?)',
'clear (reset, new)',
'memory',
'chat',
'stats (usage)',
]),
);
});
@@ -256,7 +266,7 @@ describe('useSlashCompletion', () => {
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'stats',
label: 'stats (usage)',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
commandKind: CommandKind.BUILT_IN,
@@ -512,11 +522,7 @@ describe('useSlashCompletion', () => {
describe('Argument Completion', () => {
it('should call the command.completion function for argument suggestions', async () => {
const availableTags = [
'my-chat-tag-1',
'my-chat-tag-2',
'another-channel',
];
const availableTags = ['--project', '--global'];
const mockCompletionFn = vi
.fn()
.mockImplementation(
@@ -526,12 +532,12 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'memory',
description: 'Manage memory',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'show',
description: 'Show memory',
completion: mockCompletionFn,
}),
],
@@ -541,7 +547,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume my-ch',
'/memory show --project',
slashCommands,
mockCommandContext,
),
@@ -551,19 +557,18 @@ describe('useSlashCompletion', () => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume my-ch',
name: 'resume',
args: 'my-ch',
raw: '/memory show --project',
name: 'show',
args: '--project',
},
}),
'my-ch',
'--project',
);
});
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
{ label: '--project', value: '--project' },
]);
});
});
@@ -575,12 +580,12 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'workspace',
description: 'Manage workspaces',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'switch',
description: 'Switch workspace',
completion: mockCompletionFn,
}),
],
@@ -590,7 +595,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
'/workspace switch ',
slashCommands,
mockCommandContext,
),
@@ -600,8 +605,8 @@ describe('useSlashCompletion', () => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume',
name: 'resume',
raw: '/workspace switch',
name: 'switch',
args: '',
},
}),
@@ -618,12 +623,12 @@ describe('useSlashCompletion', () => {
const completionFn = vi.fn().mockResolvedValue(null);
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'workspace',
description: 'Manage workspaces',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'switch',
description: 'Switch workspace',
completion: completionFn,
}),
],
@@ -633,7 +638,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
'/workspace switch ',
slashCommands,
mockCommandContext,
),

View File

@@ -282,7 +282,7 @@ function useCommandSuggestions(
if (!signal.aborted) {
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
label: formatSlashCommandLabel(cmd),
value: cmd.name,
description: cmd.description,
commandKind: cmd.kind,
@@ -525,3 +525,14 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
completionEnd,
};
}
function formatSlashCommandLabel(command: SlashCommand): string {
const baseLabel = command.name;
const altNames = command.altNames?.filter(Boolean);
if (!altNames || altNames.length === 0) {
return baseLabel;
}
return `${baseLabel} (${altNames.join(', ')})`;
}

View File

@@ -68,6 +68,7 @@ const mockConfig = {
getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockTool = new MockTool({