mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(a2a): Introduce /memory command for a2a server (#14456)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MemoryCommand } from './memory.js';
|
||||||
import { debugLogger } from '@google/gemini-cli-core';
|
import { debugLogger } from '@google/gemini-cli-core';
|
||||||
import { ExtensionsCommand } from './extensions.js';
|
import { ExtensionsCommand } from './extensions.js';
|
||||||
import { InitCommand } from './init.js';
|
import { InitCommand } from './init.js';
|
||||||
@@ -22,6 +23,7 @@ export class CommandRegistry {
|
|||||||
this.register(new ExtensionsCommand());
|
this.register(new ExtensionsCommand());
|
||||||
this.register(new RestoreCommand());
|
this.register(new RestoreCommand());
|
||||||
this.register(new InitCommand());
|
this.register(new InitCommand());
|
||||||
|
this.register(new MemoryCommand());
|
||||||
}
|
}
|
||||||
|
|
||||||
register(command: Command) {
|
register(command: Command) {
|
||||||
|
|||||||
208
packages/a2a-server/src/commands/memory.test.ts
Normal file
208
packages/a2a-server/src/commands/memory.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
addMemory,
|
||||||
|
listMemoryFiles,
|
||||||
|
refreshMemory,
|
||||||
|
showMemory,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
AddMemoryCommand,
|
||||||
|
ListMemoryCommand,
|
||||||
|
MemoryCommand,
|
||||||
|
RefreshMemoryCommand,
|
||||||
|
ShowMemoryCommand,
|
||||||
|
} from './memory.js';
|
||||||
|
import type { CommandContext } from './types.js';
|
||||||
|
import type {
|
||||||
|
AnyDeclarativeTool,
|
||||||
|
Config,
|
||||||
|
ToolRegistry,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
// Mock the core functions
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
showMemory: vi.fn(),
|
||||||
|
refreshMemory: vi.fn(),
|
||||||
|
listMemoryFiles: vi.fn(),
|
||||||
|
addMemory: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockShowMemory = vi.mocked(showMemory);
|
||||||
|
const mockRefreshMemory = vi.mocked(refreshMemory);
|
||||||
|
const mockListMemoryFiles = vi.mocked(listMemoryFiles);
|
||||||
|
const mockAddMemory = vi.mocked(addMemory);
|
||||||
|
|
||||||
|
describe('a2a-server memory commands', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
let mockConfig: Config;
|
||||||
|
let mockToolRegistry: ToolRegistry;
|
||||||
|
let mockSaveMemoryTool: AnyDeclarativeTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSaveMemoryTool = {
|
||||||
|
name: 'save_memory',
|
||||||
|
description: 'Saves memory',
|
||||||
|
buildAndExecute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as AnyDeclarativeTool;
|
||||||
|
|
||||||
|
mockToolRegistry = {
|
||||||
|
getTool: vi.fn(),
|
||||||
|
} as unknown as ToolRegistry;
|
||||||
|
|
||||||
|
mockConfig = {
|
||||||
|
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
mockContext = {
|
||||||
|
config: mockConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MemoryCommand', () => {
|
||||||
|
it('delegates to ShowMemoryCommand', async () => {
|
||||||
|
const command = new MemoryCommand();
|
||||||
|
mockShowMemory.mockReturnValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'showing memory',
|
||||||
|
});
|
||||||
|
const response = await command.execute(mockContext, []);
|
||||||
|
expect(response.data).toBe('showing memory');
|
||||||
|
expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ShowMemoryCommand', () => {
|
||||||
|
it('executes showMemory and returns the content', async () => {
|
||||||
|
const command = new ShowMemoryCommand();
|
||||||
|
mockShowMemory.mockReturnValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'test memory content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await command.execute(mockContext, []);
|
||||||
|
|
||||||
|
expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
|
||||||
|
expect(response.name).toBe('memory show');
|
||||||
|
expect(response.data).toBe('test memory content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RefreshMemoryCommand', () => {
|
||||||
|
it('executes refreshMemory and returns the content', async () => {
|
||||||
|
const command = new RefreshMemoryCommand();
|
||||||
|
mockRefreshMemory.mockResolvedValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'memory refreshed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await command.execute(mockContext, []);
|
||||||
|
|
||||||
|
expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
|
||||||
|
expect(response.name).toBe('memory refresh');
|
||||||
|
expect(response.data).toBe('memory refreshed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListMemoryCommand', () => {
|
||||||
|
it('executes listMemoryFiles and returns the content', async () => {
|
||||||
|
const command = new ListMemoryCommand();
|
||||||
|
mockListMemoryFiles.mockReturnValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'file1.md\nfile2.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await command.execute(mockContext, []);
|
||||||
|
|
||||||
|
expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config);
|
||||||
|
expect(response.name).toBe('memory list');
|
||||||
|
expect(response.data).toBe('file1.md\nfile2.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AddMemoryCommand', () => {
|
||||||
|
it('returns message content if addMemory returns a message', async () => {
|
||||||
|
const command = new AddMemoryCommand();
|
||||||
|
mockAddMemory.mockReturnValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'error message',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await command.execute(mockContext, []);
|
||||||
|
|
||||||
|
expect(mockAddMemory).toHaveBeenCalledWith('');
|
||||||
|
expect(response.name).toBe('memory add');
|
||||||
|
expect(response.data).toBe('error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes the save_memory tool if found', async () => {
|
||||||
|
const command = new AddMemoryCommand();
|
||||||
|
const fact = 'this is a new fact';
|
||||||
|
mockAddMemory.mockReturnValue({
|
||||||
|
type: 'tool',
|
||||||
|
toolName: 'save_memory',
|
||||||
|
toolArgs: { fact },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await command.execute(mockContext, [
|
||||||
|
'this',
|
||||||
|
'is',
|
||||||
|
'a',
|
||||||
|
'new',
|
||||||
|
'fact',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockAddMemory).toHaveBeenCalledWith(fact);
|
||||||
|
expect(mockConfig.getToolRegistry).toHaveBeenCalled();
|
||||||
|
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
|
||||||
|
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
|
||||||
|
{ fact },
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
sanitizationConfig: {
|
||||||
|
allowedEnvironmentVariables: [],
|
||||||
|
blockedEnvironmentVariables: [],
|
||||||
|
enableEnvironmentVariableRedaction: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
|
||||||
|
expect(response.name).toBe('memory add');
|
||||||
|
expect(response.data).toBe(`Added memory: "${fact}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an error if the tool is not found', async () => {
|
||||||
|
const command = new AddMemoryCommand();
|
||||||
|
const fact = 'another fact';
|
||||||
|
mockAddMemory.mockReturnValue({
|
||||||
|
type: 'tool',
|
||||||
|
toolName: 'save_memory',
|
||||||
|
toolArgs: { fact },
|
||||||
|
});
|
||||||
|
vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const response = await command.execute(mockContext, ['another', 'fact']);
|
||||||
|
|
||||||
|
expect(response.name).toBe('memory add');
|
||||||
|
expect(response.data).toBe('Error: Tool save_memory not found.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
118
packages/a2a-server/src/commands/memory.ts
Normal file
118
packages/a2a-server/src/commands/memory.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
addMemory,
|
||||||
|
listMemoryFiles,
|
||||||
|
refreshMemory,
|
||||||
|
showMemory,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandExecutionResponse,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const DEFAULT_SANITIZATION_CONFIG = {
|
||||||
|
allowedEnvironmentVariables: [],
|
||||||
|
blockedEnvironmentVariables: [],
|
||||||
|
enableEnvironmentVariableRedaction: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MemoryCommand implements Command {
|
||||||
|
readonly name = 'memory';
|
||||||
|
readonly description = 'Manage memory.';
|
||||||
|
readonly subCommands = [
|
||||||
|
new ShowMemoryCommand(),
|
||||||
|
new RefreshMemoryCommand(),
|
||||||
|
new ListMemoryCommand(),
|
||||||
|
new AddMemoryCommand(),
|
||||||
|
];
|
||||||
|
readonly topLevel = true;
|
||||||
|
readonly requiresWorkspace = true;
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
context: CommandContext,
|
||||||
|
_: string[],
|
||||||
|
): Promise<CommandExecutionResponse> {
|
||||||
|
return new ShowMemoryCommand().execute(context, _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShowMemoryCommand implements Command {
|
||||||
|
readonly name = 'memory show';
|
||||||
|
readonly description = 'Shows the current memory contents.';
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
context: CommandContext,
|
||||||
|
_: string[],
|
||||||
|
): Promise<CommandExecutionResponse> {
|
||||||
|
const result = showMemory(context.config);
|
||||||
|
return { name: this.name, data: result.content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefreshMemoryCommand implements Command {
|
||||||
|
readonly name = 'memory refresh';
|
||||||
|
readonly description = 'Refreshes the memory from the source.';
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
context: CommandContext,
|
||||||
|
_: string[],
|
||||||
|
): Promise<CommandExecutionResponse> {
|
||||||
|
const result = await refreshMemory(context.config);
|
||||||
|
return { name: this.name, data: result.content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListMemoryCommand implements Command {
|
||||||
|
readonly name = 'memory list';
|
||||||
|
readonly description = 'Lists the paths of the GEMINI.md files in use.';
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
context: CommandContext,
|
||||||
|
_: string[],
|
||||||
|
): Promise<CommandExecutionResponse> {
|
||||||
|
const result = listMemoryFiles(context.config);
|
||||||
|
return { name: this.name, data: result.content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.config.getToolRegistry();
|
||||||
|
const tool = toolRegistry.getTool(result.toolName);
|
||||||
|
if (tool) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||||
|
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||||
|
});
|
||||||
|
await refreshMemory(context.config);
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
data: `Added memory: "${textToAdd}"`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
data: `Error: Tool ${result.toolName} not found.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ export async function loadConfig(
|
|||||||
const workspaceDir = process.cwd();
|
const workspaceDir = process.cwd();
|
||||||
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
|
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
|
||||||
|
|
||||||
|
const folderTrust =
|
||||||
|
settings.folderTrust === true ||
|
||||||
|
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||||
|
|
||||||
const configParams: ConfigParameters = {
|
const configParams: ConfigParameters = {
|
||||||
sessionId: taskId,
|
sessionId: taskId,
|
||||||
model: settings.general?.previewFeatures
|
model: settings.general?.previewFeatures
|
||||||
@@ -72,7 +76,8 @@ export async function loadConfig(
|
|||||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||||
},
|
},
|
||||||
ideMode: false,
|
ideMode: false,
|
||||||
folderTrust: settings.folderTrust === true,
|
folderTrust,
|
||||||
|
trustedFolder: true,
|
||||||
extensionLoader,
|
extensionLoader,
|
||||||
checkpointing: process.env['CHECKPOINTING']
|
checkpointing: process.env['CHECKPOINTING']
|
||||||
? process.env['CHECKPOINTING'] === 'true'
|
? process.env['CHECKPOINTING'] === 'true'
|
||||||
@@ -83,16 +88,18 @@ export async function loadConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fileService = new FileDiscoveryService(workspaceDir);
|
const fileService = new FileDiscoveryService(workspaceDir);
|
||||||
const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
|
const { memoryContent, fileCount, filePaths } =
|
||||||
workspaceDir,
|
await loadServerHierarchicalMemory(
|
||||||
[workspaceDir],
|
workspaceDir,
|
||||||
false,
|
[workspaceDir],
|
||||||
fileService,
|
false,
|
||||||
extensionLoader,
|
fileService,
|
||||||
settings.folderTrust === true,
|
extensionLoader,
|
||||||
);
|
folderTrust,
|
||||||
|
);
|
||||||
configParams.userMemory = memoryContent;
|
configParams.userMemory = memoryContent;
|
||||||
configParams.geminiMdFileCount = fileCount;
|
configParams.geminiMdFileCount = fileCount;
|
||||||
|
configParams.geminiMdFilePaths = filePaths;
|
||||||
const config = new Config({
|
const config = new Config({
|
||||||
...configParams,
|
...configParams,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
|||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
refreshMemory,
|
||||||
refreshServerHierarchicalMemory,
|
refreshServerHierarchicalMemory,
|
||||||
SimpleExtensionLoader,
|
SimpleExtensionLoader,
|
||||||
type FileDiscoveryService,
|
type FileDiscoveryService,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js';
|
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const original =
|
const original =
|
||||||
@@ -28,10 +27,28 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
if (error instanceof Error) return error.message;
|
if (error instanceof Error) return error.message;
|
||||||
return String(error);
|
return String(error);
|
||||||
}),
|
}),
|
||||||
|
refreshMemory: vi.fn(async (config) => {
|
||||||
|
if (config.isJitContextEnabled()) {
|
||||||
|
await config.getContextManager()?.refresh();
|
||||||
|
const memoryContent = config.getUserMemory() || '';
|
||||||
|
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Memory refreshed successfully.',
|
||||||
|
};
|
||||||
|
}),
|
||||||
refreshServerHierarchicalMemory: vi.fn(),
|
refreshServerHierarchicalMemory: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockRefreshMemory = refreshMemory as Mock;
|
||||||
const mockRefreshServerHierarchicalMemory =
|
const mockRefreshServerHierarchicalMemory =
|
||||||
refreshServerHierarchicalMemory as Mock;
|
refreshServerHierarchicalMemory as Mock;
|
||||||
|
|
||||||
@@ -208,7 +225,7 @@ describe('memoryCommand', () => {
|
|||||||
} as unknown as LoadedSettings,
|
} as unknown as LoadedSettings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mockRefreshServerHierarchicalMemory.mockClear();
|
mockRefreshMemory.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use ContextManager.refresh when JIT is enabled', async () => {
|
it('should use ContextManager.refresh when JIT is enabled', async () => {
|
||||||
@@ -239,12 +256,13 @@ describe('memoryCommand', () => {
|
|||||||
it('should display success message when memory is refreshed with content (Legacy)', async () => {
|
it('should display success message when memory is refreshed with content (Legacy)', async () => {
|
||||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
const refreshResult: LoadServerHierarchicalMemoryResponse = {
|
const successMessage = {
|
||||||
memoryContent: 'new memory content',
|
type: 'message',
|
||||||
fileCount: 2,
|
messageType: MessageType.INFO,
|
||||||
filePaths: ['/path/one/GEMINI.md', '/path/two/GEMINI.md'],
|
content:
|
||||||
|
'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||||
};
|
};
|
||||||
mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult);
|
mockRefreshMemory.mockResolvedValue(successMessage);
|
||||||
|
|
||||||
await refreshCommand.action(mockContext, '');
|
await refreshCommand.action(mockContext, '');
|
||||||
|
|
||||||
@@ -256,7 +274,7 @@ describe('memoryCommand', () => {
|
|||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
@@ -270,12 +288,16 @@ describe('memoryCommand', () => {
|
|||||||
it('should display success message when memory is refreshed with no content', async () => {
|
it('should display success message when memory is refreshed with no content', async () => {
|
||||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
const refreshResult = { memoryContent: '', fileCount: 0, filePaths: [] };
|
const successMessage = {
|
||||||
mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult);
|
type: 'message',
|
||||||
|
messageType: MessageType.INFO,
|
||||||
|
content: 'Memory refreshed successfully. No memory content found.',
|
||||||
|
};
|
||||||
|
mockRefreshMemory.mockResolvedValue(successMessage);
|
||||||
|
|
||||||
await refreshCommand.action(mockContext, '');
|
await refreshCommand.action(mockContext, '');
|
||||||
|
|
||||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
@@ -290,11 +312,11 @@ describe('memoryCommand', () => {
|
|||||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
const error = new Error('Failed to read memory files.');
|
const error = new Error('Failed to read memory files.');
|
||||||
mockRefreshServerHierarchicalMemory.mockRejectedValue(error);
|
mockRefreshMemory.mockRejectedValue(error);
|
||||||
|
|
||||||
await refreshCommand.action(mockContext, '');
|
await refreshCommand.action(mockContext, '');
|
||||||
|
|
||||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||||
expect(mockSetUserMemory).not.toHaveBeenCalled();
|
expect(mockSetUserMemory).not.toHaveBeenCalled();
|
||||||
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
|
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
|
||||||
expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled();
|
expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled();
|
||||||
@@ -306,8 +328,6 @@ describe('memoryCommand', () => {
|
|||||||
},
|
},
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getErrorMessage).toHaveBeenCalledWith(error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not throw if config service is unavailable', async () => {
|
it('should not throw if config service is unavailable', async () => {
|
||||||
@@ -329,7 +349,7 @@ describe('memoryCommand', () => {
|
|||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
expect(mockRefreshMemory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
addMemory,
|
||||||
refreshServerHierarchicalMemory,
|
listMemoryFiles,
|
||||||
|
refreshMemory,
|
||||||
|
showMemory,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
@@ -24,18 +26,14 @@ export const memoryCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const memoryContent = context.services.config?.getUserMemory() || '';
|
const config = context.services.config;
|
||||||
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
|
if (!config) return;
|
||||||
|
const result = showMemory(config);
|
||||||
const messageContent =
|
|
||||||
memoryContent.length > 0
|
|
||||||
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
|
|
||||||
: 'Memory is currently empty.';
|
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: messageContent,
|
text: result.content,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -47,12 +45,10 @@ export const memoryCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: false,
|
autoExecute: false,
|
||||||
action: (context, args): SlashCommandActionReturn | void => {
|
action: (context, args): SlashCommandActionReturn | void => {
|
||||||
if (!args || args.trim() === '') {
|
const result = addMemory(args);
|
||||||
return {
|
|
||||||
type: 'message',
|
if (result.type === 'message') {
|
||||||
messageType: 'error',
|
return result;
|
||||||
content: 'Usage: /memory add <text to remember>',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
@@ -63,11 +59,7 @@ export const memoryCommand: SlashCommand = {
|
|||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return result;
|
||||||
type: 'tool',
|
|
||||||
toolName: 'save_memory',
|
|
||||||
toolArgs: { fact: args.trim() },
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -87,40 +79,21 @@ export const memoryCommand: SlashCommand = {
|
|||||||
try {
|
try {
|
||||||
const config = context.services.config;
|
const config = context.services.config;
|
||||||
if (config) {
|
if (config) {
|
||||||
let memoryContent = '';
|
const result = await refreshMemory(config);
|
||||||
let fileCount = 0;
|
|
||||||
|
|
||||||
if (config.isJitContextEnabled()) {
|
|
||||||
await config.getContextManager()?.refresh();
|
|
||||||
memoryContent = config.getUserMemory();
|
|
||||||
fileCount = config.getGeminiMdFileCount();
|
|
||||||
} else {
|
|
||||||
const result = await refreshServerHierarchicalMemory(config);
|
|
||||||
memoryContent = result.memoryContent;
|
|
||||||
fileCount = result.fileCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
await config.updateSystemInstructionIfInitialized();
|
|
||||||
|
|
||||||
const successMessage =
|
|
||||||
memoryContent.length > 0
|
|
||||||
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
|
|
||||||
: 'Memory refreshed successfully. No memory content found.';
|
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: successMessage,
|
text: result.content,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = getErrorMessage(error);
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: `Error refreshing memory: ${errorMessage}`,
|
text: `Error refreshing memory: ${(error as Error).message}`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -133,18 +106,14 @@ export const memoryCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const filePaths = context.services.config?.getGeminiMdFilePaths() || [];
|
const config = context.services.config;
|
||||||
const fileCount = filePaths.length;
|
if (!config) return;
|
||||||
|
const result = listMemoryFiles(config);
|
||||||
const messageContent =
|
|
||||||
fileCount > 0
|
|
||||||
? `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`
|
|
||||||
: 'No GEMINI.md files in use.';
|
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: messageContent,
|
text: result.content,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
205
packages/core/src/commands/memory.test.ts
Normal file
205
packages/core/src/commands/memory.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import {
|
||||||
|
addMemory,
|
||||||
|
listMemoryFiles,
|
||||||
|
refreshMemory,
|
||||||
|
showMemory,
|
||||||
|
} from './memory.js';
|
||||||
|
import * as memoryDiscovery from '../utils/memoryDiscovery.js';
|
||||||
|
|
||||||
|
vi.mock('../utils/memoryDiscovery.js', () => ({
|
||||||
|
refreshServerHierarchicalMemory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRefresh = vi.mocked(memoryDiscovery.refreshServerHierarchicalMemory);
|
||||||
|
|
||||||
|
describe('memory commands', () => {
|
||||||
|
let mockConfig: Config;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = {
|
||||||
|
getUserMemory: vi.fn(),
|
||||||
|
getGeminiMdFileCount: vi.fn(),
|
||||||
|
getGeminiMdFilePaths: vi.fn(),
|
||||||
|
isJitContextEnabled: vi.fn(),
|
||||||
|
updateSystemInstructionIfInitialized: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined),
|
||||||
|
} as unknown as Config;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showMemory', () => {
|
||||||
|
it('should show memory content if it exists', () => {
|
||||||
|
vi.mocked(mockConfig.getUserMemory).mockReturnValue(
|
||||||
|
'some memory content',
|
||||||
|
);
|
||||||
|
vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(1);
|
||||||
|
|
||||||
|
const result = showMemory(mockConfig);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toContain(
|
||||||
|
'Current memory content from 1 file(s)',
|
||||||
|
);
|
||||||
|
expect(result.content).toContain('some memory content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a message if memory is empty', () => {
|
||||||
|
vi.mocked(mockConfig.getUserMemory).mockReturnValue('');
|
||||||
|
vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(0);
|
||||||
|
|
||||||
|
const result = showMemory(mockConfig);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toBe('Memory is currently empty.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addMemory', () => {
|
||||||
|
it('should return a tool action to save memory', () => {
|
||||||
|
const result = addMemory('new memory');
|
||||||
|
expect(result.type).toBe('tool');
|
||||||
|
if (result.type === 'tool') {
|
||||||
|
expect(result.toolName).toBe('save_memory');
|
||||||
|
expect(result.toolArgs).toEqual({ fact: 'new memory' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim the arguments', () => {
|
||||||
|
const result = addMemory(' new memory ');
|
||||||
|
expect(result.type).toBe('tool');
|
||||||
|
if (result.type === 'tool') {
|
||||||
|
expect(result.toolArgs).toEqual({ fact: 'new memory' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error if args are empty', () => {
|
||||||
|
const result = addMemory('');
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('error');
|
||||||
|
expect(result.content).toBe('Usage: /memory add <text to remember>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error if args are just whitespace', () => {
|
||||||
|
const result = addMemory(' ');
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('error');
|
||||||
|
expect(result.content).toBe('Usage: /memory add <text to remember>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error if args are undefined', () => {
|
||||||
|
const result = addMemory(undefined);
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('error');
|
||||||
|
expect(result.content).toBe('Usage: /memory add <text to remember>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshMemory', () => {
|
||||||
|
it('should refresh memory and show success message', async () => {
|
||||||
|
mockRefresh.mockResolvedValue({
|
||||||
|
memoryContent: 'refreshed content',
|
||||||
|
fileCount: 2,
|
||||||
|
filePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await refreshMemory(mockConfig);
|
||||||
|
|
||||||
|
expect(mockRefresh).toHaveBeenCalledWith(mockConfig);
|
||||||
|
expect(
|
||||||
|
mockConfig.updateSystemInstructionIfInitialized,
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toBe(
|
||||||
|
'Memory refreshed successfully. Loaded 17 characters from 2 file(s).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a message if no memory content is found after refresh', async () => {
|
||||||
|
mockRefresh.mockResolvedValue({
|
||||||
|
memoryContent: '',
|
||||||
|
fileCount: 0,
|
||||||
|
filePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await refreshMemory(mockConfig);
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toBe(
|
||||||
|
'Memory refreshed successfully. No memory content found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listMemoryFiles', () => {
|
||||||
|
it('should list the memory files in use', () => {
|
||||||
|
const filePaths = ['/path/to/GEMINI.md', '/other/path/GEMINI.md'];
|
||||||
|
vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue(filePaths);
|
||||||
|
|
||||||
|
const result = listMemoryFiles(mockConfig);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toContain(
|
||||||
|
'There are 2 GEMINI.md file(s) in use:',
|
||||||
|
);
|
||||||
|
expect(result.content).toContain(filePaths.join('\n'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a message if no memory files are in use', () => {
|
||||||
|
vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue([]);
|
||||||
|
|
||||||
|
const result = listMemoryFiles(mockConfig);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toBe('No GEMINI.md files in use.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a message if file paths are undefined', () => {
|
||||||
|
vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue(
|
||||||
|
undefined as unknown as string[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = listMemoryFiles(mockConfig);
|
||||||
|
|
||||||
|
expect(result.type).toBe('message');
|
||||||
|
if (result.type === 'message') {
|
||||||
|
expect(result.messageType).toBe('info');
|
||||||
|
expect(result.content).toBe('No GEMINI.md files in use.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
packages/core/src/commands/memory.ts
Normal file
96
packages/core/src/commands/memory.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config } from '../config/config.js';
|
||||||
|
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||||
|
import type { MessageActionReturn, ToolActionReturn } from './types.js';
|
||||||
|
|
||||||
|
export function showMemory(config: Config): MessageActionReturn {
|
||||||
|
const memoryContent = config.getUserMemory() || '';
|
||||||
|
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (memoryContent.length > 0) {
|
||||||
|
content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`;
|
||||||
|
} else {
|
||||||
|
content = 'Memory is currently empty.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMemory(
|
||||||
|
args?: string,
|
||||||
|
): MessageActionReturn | ToolActionReturn {
|
||||||
|
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() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshMemory(
|
||||||
|
config: Config,
|
||||||
|
): Promise<MessageActionReturn> {
|
||||||
|
let memoryContent = '';
|
||||||
|
let fileCount = 0;
|
||||||
|
|
||||||
|
if (config.isJitContextEnabled()) {
|
||||||
|
await config.getContextManager()?.refresh();
|
||||||
|
memoryContent = config.getUserMemory();
|
||||||
|
fileCount = config.getGeminiMdFileCount();
|
||||||
|
} else {
|
||||||
|
const result = await refreshServerHierarchicalMemory(config);
|
||||||
|
memoryContent = result.memoryContent;
|
||||||
|
fileCount = result.fileCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.updateSystemInstructionIfInitialized();
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (memoryContent.length > 0) {
|
||||||
|
content = `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`;
|
||||||
|
} else {
|
||||||
|
content = 'Memory refreshed successfully. No memory content found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMemoryFiles(config: Config): MessageActionReturn {
|
||||||
|
const filePaths = config.getGeminiMdFilePaths() || [];
|
||||||
|
const fileCount = filePaths.length;
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
if (fileCount > 0) {
|
||||||
|
content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join(
|
||||||
|
'\n',
|
||||||
|
)}`;
|
||||||
|
} else {
|
||||||
|
content = 'No GEMINI.md files in use.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export * from './confirmation-bus/message-bus.js';
|
|||||||
export * from './commands/extensions.js';
|
export * from './commands/extensions.js';
|
||||||
export * from './commands/restore.js';
|
export * from './commands/restore.js';
|
||||||
export * from './commands/init.js';
|
export * from './commands/init.js';
|
||||||
|
export * from './commands/memory.js';
|
||||||
export * from './commands/types.js';
|
export * from './commands/types.js';
|
||||||
|
|
||||||
// Export Core Logic
|
// Export Core Logic
|
||||||
|
|||||||
Reference in New Issue
Block a user