chore: clean up launched memory features

This commit is contained in:
Sandy Tao
2026-05-12 13:06:07 -07:00
parent ebe15553a9
commit e431f6e479
82 changed files with 604 additions and 4166 deletions
@@ -17,9 +17,8 @@ describe('CommandHandler', () => {
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');
const memAdd = parse('/memory add hello world');
expect(memAdd.commandToExecute?.name).toBe('memory add');
expect(memAdd.args).toBe('hello world');
const memList = parse('/memory list');
expect(memList.commandToExecute?.name).toBe('memory list');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
-50
View File
@@ -5,7 +5,6 @@
*/
import {
addMemory,
listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
@@ -19,12 +18,6 @@ import type {
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
@@ -32,7 +25,6 @@ export class MemoryCommand implements Command {
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
new InboxMemoryCommand(),
];
readonly requiresWorkspace = true;
@@ -85,48 +77,6 @@ export class ListMemoryCommand implements Command {
}
}
export class AddMemoryCommand implements Command {
readonly name = 'memory add';
readonly description = 'Add content to the memory.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const textToAdd = args.join(' ').trim();
const result = addMemory(textToAdd);
if (result.type === 'message') {
return { name: this.name, data: result.content };
}
const toolRegistry = context.agentContext.toolRegistry;
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const signal = abortController.signal;
await context.sendMessage(`Saving memory via ${result.toolName}...`);
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
shellExecutionConfig: {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.agentContext.sandboxManager,
},
});
await refreshMemory(context.agentContext.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
-167
View File
@@ -15,7 +15,6 @@ import {
EDIT_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
type ExtensionLoader,
debugLogger,
ApprovalMode,
type MCPServerConfig,
@@ -112,27 +111,6 @@ vi.mock('@google/gemini-cli-core', async () => {
}),
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(
cwd,
dirs,
fileService,
extensionLoader: ExtensionLoader,
_folderTrust,
_importFormat,
_fileFilteringOptions,
_maxDirs,
) => {
const extensionPaths =
extensionLoader?.getExtensions?.()?.flatMap((e) => e.contextFiles) ||
[];
return Promise.resolve({
memoryContent: extensionPaths.join(',') || '',
fileCount: extensionPaths?.length || 0,
filePaths: extensionPaths,
});
},
),
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
respectGitIgnore: false,
respectGeminiIgnore: true,
@@ -1067,151 +1045,6 @@ describe('loadCliConfig', () => {
});
});
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
// Restore ExtensionManager mocks that were reset
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
ExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
// Other common mocks would be reset here.
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { jitContext: false },
});
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
contextFiles: ['/path/to/ext1/GEMINI.md'],
isActive: true,
},
{
path: '/path/to/ext2',
name: 'ext2',
id: 'ext2-id',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext3',
name: 'ext3',
id: 'ext3-id',
version: '1.0.0',
contextFiles: [
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
isActive: true,
},
]);
const argv = await parseArguments(createTestMergedSettings());
await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
expect.any(Object),
expect.any(ExtensionManager),
true,
'tree',
expect.objectContaining({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
200, // maxDirs
['.git'], // boundaryMarkers
);
});
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
process.argv = ['node', 'script.js'];
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
const settings = createTestMergedSettings({
experimental: { jitContext: false },
context: {
includeDirectories: [includeDir],
loadMemoryFromIncludeDirectories: true,
},
});
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[includeDir],
expect.any(Object),
expect.any(ExtensionManager),
true,
'tree',
expect.objectContaining({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { jitContext: false },
context: {
includeDirectories: ['/path/to/include'],
loadMemoryFromIncludeDirectories: false,
},
});
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
expect.any(Object),
expect.any(ExtensionManager),
true,
'tree',
expect.objectContaining({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
it('should NOT call loadServerHierarchicalMemory when skipMemoryLoad is true', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { jitContext: false },
});
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv, {
skipMemoryLoad: true,
});
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
});
});
describe('mergeMcpServers', () => {
it('should not modify the original settings object', async () => {
const settings = createTestMergedSettings({
+2 -48
View File
@@ -20,18 +20,15 @@ import {
ApprovalMode,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
resolveTelemetrySettings,
FatalConfigError,
getErrorMessage,
getPty,
debugLogger,
loadServerHierarchicalMemory,
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
@@ -572,7 +569,6 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
skipMemoryLoad?: boolean;
}
export async function loadCliConfig(
@@ -581,12 +577,7 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const {
cwd = process.cwd(),
projectHooks,
skipExtensions = false,
skipMemoryLoad = false,
} = options;
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -596,7 +587,6 @@ export async function loadCliConfig(
process.env['GEMINI_SANDBOX'] = 'true';
}
const memoryImportFormat = settings.context?.importFormat || 'tree';
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
const ideMode = settings.ide?.enabled ?? false;
@@ -612,7 +602,7 @@ export async function loadCliConfig(
query: argv.query,
})?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
// Set the context filename in the server's memory file helpers before loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
// However, loadHierarchicalGeminiMemory is called *before* createServerConfig.
@@ -625,11 +615,6 @@ export async function loadCliConfig(
const fileService = new FileDiscoveryService(cwd);
const memoryFileFiltering = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...settings.context?.fileFiltering,
};
const fileFiltering = {
...DEFAULT_FILE_FILTERING_OPTIONS,
...settings.context?.fileFiltering,
@@ -680,8 +665,6 @@ export async function loadCliConfig(
?.getExtensions()
?.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental.jitContext ?? true;
let extensionRegistryURI =
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
(trustedFolder ? settings.experimental?.extensionRegistryURI : undefined);
@@ -692,33 +675,9 @@ export async function loadCliConfig(
);
}
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
const finalExtensionLoader =
extensionManager ?? new SimpleExtensionLoader([]);
if (!experimentalJitContext && !skipMemoryLoad) {
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const result = await loadServerHierarchicalMemory(
cwd,
settings.context?.loadMemoryFromIncludeDirectories || false
? includeDirectories
: [],
fileService,
finalExtensionLoader,
trustedFolder,
memoryImportFormat,
memoryFileFiltering,
settings.context?.discoveryMaxDirs,
settings.context?.memoryBoundaryMarkers,
);
memoryContent = result.memoryContent;
fileCount = result.fileCount;
filePaths = result.filePaths;
}
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -1030,9 +989,6 @@ export async function loadCliConfig(
settings.security?.environmentVariableRedaction?.allowed,
enableEnvironmentVariableRedaction:
settings.security?.environmentVariableRedaction?.enabled,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
geminiMdFilePaths: filePaths,
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
@@ -1077,8 +1033,6 @@ export async function loadCliConfig(
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext,
experimentalMemoryV2: settings.experimental?.memoryV2,
experimentalAutoMemory: settings.experimental?.autoMemory,
experimentalGemma: settings.experimental?.gemma,
contextManagement,
@@ -109,6 +109,7 @@ describe('ExtensionManager theme loading', () => {
getFileExclusions: () => ({
isIgnored: () => false,
}),
getMemoryContextManager: () => undefined,
getGeminiMdFilePaths: () => [],
getMcpServers: () => ({}),
getAllowedMcpServers: () => [],
@@ -185,6 +186,7 @@ describe('ExtensionManager theme loading', () => {
getWorkspaceContext: () => ({
getDirectories: () => [],
}),
getMemoryContextManager: () => undefined,
getDebugMode: () => false,
getFileService: () => ({
findFiles: async () => [],
-20
View File
@@ -2252,16 +2252,6 @@ const SETTINGS_SCHEMA = {
'Enables extension loading/unloading within the CLI session.',
showInDialog: false,
},
jitContext: {
type: 'boolean',
label: 'JIT Context Loading',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Enable Just-In-Time (JIT) context loading. Defaults to true; set to false to opt out and load all GEMINI.md files into the system instruction up-front.',
showInDialog: false,
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
@@ -2392,16 +2382,6 @@ const SETTINGS_SCHEMA = {
},
},
},
memoryV2: {
type: 'boolean',
label: 'Memory v2',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
showInDialog: true,
},
stressTestProfile: {
type: 'boolean',
label:
@@ -26,11 +26,6 @@ vi.mock('@google/gemini-cli-core', async () => {
);
return {
...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
createPolicyEngineConfig: vi.fn().mockResolvedValue({
rules: [],
checkers: [],
-1
View File
@@ -499,7 +499,6 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
skipExtensions: true,
skipMemoryLoad: true,
});
adminControlsListner.setConfig(partialConfig);
@@ -38,7 +38,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
})),
isMemoryV2Enabled: vi.fn(() => false),
isAutoMemoryEnabled: vi.fn(() => false),
getListExtensions: vi.fn(() => false),
getExtensions: vi.fn(() => []),
@@ -166,7 +165,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getExperimentalGemma: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
+4 -14
View File
@@ -70,7 +70,6 @@ import {
debugLogger,
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
@@ -1065,19 +1064,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
Date.now(),
);
try {
let flattenedMemory: string;
let fileCount: number;
if (config.isJitContextEnabled()) {
await config.getMemoryContextManager()?.refresh();
config.updateSystemInstructionIfInitialized();
flattenedMemory = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
const result = await refreshServerHierarchicalMemory(config);
flattenedMemory = flattenMemory(result.memoryContent);
fileCount = result.fileCount;
}
await config.getMemoryContextManager()?.refresh();
config.updateSystemInstructionIfInitialized();
const flattenedMemory = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount();
historyManager.addItem(
{
@@ -80,6 +80,7 @@ describe('directoryCommand', () => {
}),
getWorkingDir: () => path.resolve('/test/dir'),
shouldLoadMemoryFromIncludeDirectories: () => false,
getMemoryContextManager: vi.fn(),
getDebugMode: () => false,
getFileService: () => ({}),
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
@@ -15,10 +15,7 @@ import {
type CommandContext,
} from './types.js';
import { MessageType, type HistoryItem } from '../types.js';
import {
refreshServerHierarchicalMemory,
type Config,
} from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import {
expandHomeDir,
getDirectorySuggestions,
@@ -47,7 +44,7 @@ async function finishAddingDirectories(
if (added.length > 0) {
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
await config.getMemoryContextManager()?.refresh();
}
addItem({
type: MessageType.INFO,
@@ -11,13 +11,10 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
type Config,
refreshMemory,
refreshServerHierarchicalMemory,
SimpleExtensionLoader,
type FileDiscoveryService,
showMemory,
addMemory,
listMemoryFiles,
flattenMemory,
} from '@google/gemini-cli-core';
@@ -32,46 +29,28 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return String(error);
}),
refreshMemory: vi.fn(async (config) => {
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
messageType: 'info',
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
};
}
await config.getMemoryContextManager()?.refresh();
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
messageType: 'info',
content: 'Memory reloaded successfully.',
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
};
}),
showMemory: vi.fn(),
addMemory: vi.fn(),
listMemoryFiles: vi.fn(),
refreshServerHierarchicalMemory: vi.fn(),
};
});
const mockRefreshMemory = refreshMemory as Mock;
const mockRefreshServerHierarchicalMemory =
refreshServerHierarchicalMemory as Mock;
describe('memoryCommand', () => {
let mockContext: CommandContext;
const buildMemoryCommand = (isMemoryV2 = false): SlashCommand => {
const config: Pick<Config, 'isMemoryV2Enabled'> = {
isMemoryV2Enabled: () => isMemoryV2,
};
return memoryCommand(config as Config);
};
const buildMemoryCommand = (): SlashCommand => memoryCommand(null);
const getSubCommand = (
name: 'show' | 'add' | 'reload' | 'list',
): SlashCommand => {
const getSubCommand = (name: 'show' | 'reload' | 'list'): SlashCommand => {
const subCommand = buildMemoryCommand().subCommands?.find(
(cmd) => cmd.name === name,
);
@@ -81,23 +60,11 @@ describe('memoryCommand', () => {
return subCommand;
};
describe('Memory v2', () => {
it('omits the /memory add subcommand when memoryV2 is enabled', () => {
const command = buildMemoryCommand(true);
describe('subcommands', () => {
it('does not include the legacy add subcommand', () => {
const command = buildMemoryCommand();
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).not.toContain('add');
});
it('includes the /memory add subcommand by default', () => {
const command = buildMemoryCommand(false);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).toContain('add');
});
it('includes the /memory add subcommand when no config is provided', () => {
const command = memoryCommand(null);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).toContain('add');
expect(names).toEqual(['show', 'reload', 'list', 'inbox']);
});
});
@@ -178,63 +145,6 @@ describe('memoryCommand', () => {
});
});
describe('/memory add', () => {
let addCommand: SlashCommand;
beforeEach(() => {
addCommand = getSubCommand('add');
vi.mocked(addMemory).mockImplementation((args) => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
};
}
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim() },
};
});
mockContext = createMockCommandContext();
});
it('should return an error message if no arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = addCommand.action(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
it('should return a tool action and add an info message when arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this';
const result = addCommand.action(mockContext, ` ${fact} `);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
});
});
describe('/memory reload', () => {
let reloadCommand: SlashCommand;
let mockSetUserMemory: Mock;
@@ -270,8 +180,7 @@ describe('memoryCommand', () => {
updateSystemInstructionIfInitialized: vi
.fn()
.mockResolvedValue(undefined),
isJitContextEnabled: vi.fn().mockReturnValue(false),
getContextManager: vi.fn().mockReturnValue({
getMemoryContextManager: vi.fn().mockReturnValue({
refresh: mockContextManagerRefresh,
}),
getUserMemory: vi.fn().mockReturnValue(''),
@@ -294,21 +203,18 @@ describe('memoryCommand', () => {
mockRefreshMemory.mockClear();
});
it('should use ContextManager.refresh when JIT is enabled', async () => {
it('should use MemoryContextManager.refresh', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
// Enable JIT in mock config
const config = mockContext.services.agentContext?.config;
if (!config) throw new Error('Config is undefined');
vi.mocked(config.isJitContextEnabled).mockReturnValue(true);
vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content');
vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3);
await reloadCommand.action(mockContext, '');
expect(mockContextManagerRefresh).toHaveBeenCalledOnce();
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
@@ -319,7 +225,7 @@ describe('memoryCommand', () => {
);
});
it('should display success message when memory is reloaded with content (Legacy)', async () => {
it('should display success message when memory is reloaded with content', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
const successMessage = {
+1 -31
View File
@@ -6,7 +6,6 @@
import React from 'react';
import {
addMemory,
type Config,
listMemoryFiles,
refreshMemory,
@@ -41,30 +40,6 @@ const showSubCommand: SlashCommand = {
},
};
const addSubCommand: SlashCommand = {
name: 'add',
description: 'Add content to the memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (context, args): SlashCommandActionReturn | void => {
const result = addMemory(args);
if (result.type === 'message') {
return result;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${args.trim()}"`,
},
Date.now(),
);
return result;
},
};
const reloadSubCommand: SlashCommand = {
name: 'reload',
altNames: ['refresh'],
@@ -170,14 +145,9 @@ const inboxSubCommand: SlashCommand = {
},
};
export const memoryCommand = (config: Config | null): SlashCommand => {
// The `add` subcommand depends on the `save_memory` tool, which is not
// registered when Memory v2 is enabled. Omit it in that case.
const isMemoryV2 = config?.isMemoryV2Enabled() ?? false;
export const memoryCommand = (_config: Config | null): SlashCommand => {
const subCommands: SlashCommand[] = [
showSubCommand,
...(isMemoryV2 ? [] : [addSubCommand]),
reloadSubCommand,
listSubCommand,
inboxSubCommand,
@@ -1962,8 +1962,8 @@ describe('InputPrompt', () => {
},
{
name: 'should NOT trigger completion when cursor is after space following /',
text: '/memory add',
cursor: [0, 11],
text: '/memory list',
cursor: [0, 12],
showSuggestions: false,
},
{
@@ -174,27 +174,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
-1
View File
@@ -144,7 +144,6 @@ export const INFORMATIVE_TIPS = [
'Authenticate with an OAuth-enabled MCP server with /mcp auth',
'Reload MCP servers with /mcp reload',
'See the current instructional context with /memory show',
'Add content to the instructional memory with /memory add',
'Reload instructional context from GEMINI.md files with /memory reload',
'List the paths of the GEMINI.md files in use with /memory list',
'Choose your Gemini model with /model',
@@ -94,6 +94,7 @@ describe('handleAtCommand', () => {
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
getDirectories: () => [testRootDir],
}),
getMemoryContextManager: () => undefined,
storage: {
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
},
@@ -351,7 +351,6 @@ describe('useGeminiStream', () => {
isInteractive: () => false,
getExperiments: () => {},
getMaxSessionTurns: vi.fn(() => 100),
isJitContextEnabled: vi.fn(() => false),
getGlobalMemory: vi.fn(() => ''),
getUserMemory: vi.fn(() => ''),
getMessageBus: vi.fn(() => mockMessageBus),
@@ -1950,23 +1949,23 @@ describe('useGeminiStream', () => {
it('should schedule a tool call when the command processor returns a schedule_tool action', async () => {
const clientToolRequest: SlashCommandProcessorResult = {
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
toolName: 'activate_skill',
toolArgs: { name: 'test-skill' },
};
mockHandleSlashCommand.mockResolvedValue(clientToolRequest);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
await result.current.submitQuery('/memory show');
});
await waitFor(() => {
expect(mockScheduleToolCalls).toHaveBeenCalledWith(
[
expect.objectContaining({
name: 'save_memory',
args: { fact: 'test fact' },
name: 'activate_skill',
args: { name: 'test-skill' },
isClientInitiated: true,
}),
],
@@ -2194,25 +2193,25 @@ describe('useGeminiStream', () => {
});
});
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
it('should NOT record other client-initiated tool calls in history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
toolName: 'write_todos',
toolArgs: { todos: [] },
});
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
await result.current.submitQuery('/todos');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'save_memory',
args: { fact: 'test fact' },
name: 'write_todos',
args: { todos: [] },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
@@ -2226,7 +2225,7 @@ describe('useGeminiStream', () => {
responseParts: [
{
functionResponse: {
name: 'save_memory',
name: 'write_todos',
response: { success: true },
},
},
@@ -2245,91 +2244,6 @@ describe('useGeminiStream', () => {
});
});
describe('Memory Refresh on save_memory', () => {
it('should call performMemoryRefresh when a save_memory tool call completes successfully', async () => {
const mockPerformMemoryRefresh = vi.fn();
const completedToolCall: TrackedCompletedToolCall = {
request: {
callId: 'save-mem-call-1',
name: 'save_memory',
args: { fact: 'test' },
isClientInitiated: true,
prompt_id: 'prompt-id-6',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'save-mem-call-1',
responseParts: [{ text: 'Memory saved' }],
resultDisplay: 'Success: Memory saved',
error: undefined,
errorType: undefined, // FIX: Added missing property
},
tool: {
name: 'save_memory',
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
};
// Capture the onComplete callback
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
mockPerformMemoryRefresh,
false,
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with the completed save_memory tool
await act(async () => {
if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete([completedToolCall]);
}
});
await waitFor(() => {
expect(mockPerformMemoryRefresh).toHaveBeenCalledTimes(1);
});
});
});
describe('Error Handling', () => {
it('should call parseAndFormatApiError with the correct authType on stream initialization failure', async () => {
// 1. Setup
+3 -22
View File
@@ -224,7 +224,7 @@ export const useGeminiStream = (
shellModeActive: boolean,
getPreferredEditor: () => EditorType | undefined,
onAuthError: (error: string) => void,
performMemoryRefresh: () => Promise<void>,
_performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onCancelSubmit: (
@@ -264,7 +264,6 @@ export const useGeminiStream = (
useStateAndRef<Set<string>>(new Set());
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const logger = useLogger(config);
const gitService = useMemo(() => {
@@ -1884,8 +1883,8 @@ export const useGeminiStream = (
if (geminiClient) {
for (const tool of clientTools) {
// Only manually record skill activations in the chat history.
// Other client-initiated tools (like save_memory) update the system
// prompt/context and don't strictly need to be in the history.
// Other client-initiated tools update context and don't strictly
// need to be in the history.
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
continue;
}
@@ -1912,14 +1911,6 @@ export const useGeminiStream = (
}
}
// Identify new, successful save_memory calls that we haven't processed yet.
const newSuccessfulMemorySaves = completedAndReadyToSubmitTools.filter(
(t) =>
t.request.name === 'save_memory' &&
t.status === 'success' &&
!processedMemoryToolsRef.current.has(t.request.callId),
);
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
@@ -1931,15 +1922,6 @@ export const useGeminiStream = (
}
}
if (newSuccessfulMemorySaves.length > 0) {
// Perform the refresh only if there are new ones.
void performMemoryRefresh();
// Mark them as processed so we don't do this again on the next render.
newSuccessfulMemorySaves.forEach((t) =>
processedMemoryToolsRef.current.add(t.request.callId),
);
}
const geminiTools = completedAndReadyToSubmitTools.filter(
(t) => !t.request.isClientInitiated,
);
@@ -2063,7 +2045,6 @@ export const useGeminiStream = (
submitQuery,
markToolsAsSubmitted,
geminiClient,
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundTask,
@@ -80,6 +80,8 @@ describe('useIncludeDirsTrust', () => {
clearPendingIncludeDirectories: vi.fn(),
getFolderTrust: vi.fn().mockReturnValue(true),
getWorkspaceContext: () => mockWorkspaceContext,
shouldLoadMemoryFromIncludeDirectories: vi.fn().mockReturnValue(false),
getMemoryContextManager: vi.fn(),
getGeminiClient: vi
.fn()
.mockReturnValue({ addDirectoryContext: vi.fn() }),
@@ -8,10 +8,7 @@ import { useEffect } from 'react';
import { type Config } from '@google/gemini-cli-core';
import { loadTrustedFolders } from '../../config/trustedFolders.js';
import { expandHomeDir, batchAddDirectories } from '../utils/directoryUtils.js';
import {
debugLogger,
refreshServerHierarchicalMemory,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type HistoryItem } from '../types.js';
@@ -35,7 +32,7 @@ async function finishAddingDirectories(
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
await config.getMemoryContextManager()?.refresh();
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -17,11 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
homedir: () => mockHomeDir,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: 'mock memory',
fileCount: 10,
filePaths: ['/a/b/c.md'],
}),
};
});
+14 -14
View File
@@ -28,8 +28,8 @@ const mockCommands: readonly SlashCommand[] = [
altNames: ['mem'],
subCommands: [
{
name: 'add',
description: 'Add to memory',
name: 'list',
description: 'List memory files',
action: async () => {},
kind: CommandKind.BUILT_IN,
},
@@ -64,27 +64,27 @@ describe('parseSlashCommand', () => {
});
it('should parse a subcommand', () => {
const result = parseSlashCommand('/memory add', mockCommands);
expect(result.commandToExecute?.name).toBe('add');
const result = parseSlashCommand('/memory list', mockCommands);
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should parse a subcommand with arguments', () => {
const result = parseSlashCommand(
'/memory add some important data',
'/memory list some important data',
mockCommands,
);
expect(result.commandToExecute?.name).toBe('add');
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('some important data');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should handle a command alias', () => {
const result = parseSlashCommand('/mem add some data', mockCommands);
expect(result.commandToExecute?.name).toBe('add');
const result = parseSlashCommand('/mem list some data', mockCommands);
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('some data');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should handle a subcommand alias', () => {
@@ -113,12 +113,12 @@ describe('parseSlashCommand', () => {
it('should handle extra whitespace', () => {
const result = parseSlashCommand(
' /memory add some data ',
' /memory list some data ',
mockCommands,
);
expect(result.commandToExecute?.name).toBe('add');
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('some data');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should return undefined if query does not start with a slash', () => {
+1 -1
View File
@@ -16,7 +16,7 @@ export type ParsedSlashCommand = {
* Parses a raw slash command string into its command, arguments, and canonical path.
* If no valid command is found, the `commandToExecute` property will be `undefined`.
*
* @param query The raw input string, e.g., "/memory add some data" or "/help".
* @param query The raw input string, e.g., "/memory show" or "/help".
* @param commands The list of available top-level slash commands.
* @returns An object containing the resolved command, its arguments, and its canonical path.
*/