Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ export interface IClaudeSlashCommandService {
* @param request - The user's request containing prompt and optional command
* @param stream - Response stream for sending messages to the chat
* @param token - Cancellation token
* @param toolInvocationToken - Token for invoking tools in the chat context
* @returns Object indicating whether the command was handled and the result
*/
tryHandleCommand(
request: IClaudeSlashCommandRequest,
stream: vscode.ChatResponseStream,
token: CancellationToken
token: CancellationToken,
toolInvocationToken?: vscode.ChatParticipantToolToken
): Promise<IClaudeSlashCommandResult>;

/**
Expand Down Expand Up @@ -69,13 +71,14 @@ export class ClaudeSlashCommandService extends Disposable implements IClaudeSlas
async tryHandleCommand(
request: IClaudeSlashCommandRequest,
stream: vscode.ChatResponseStream,
token: CancellationToken
token: CancellationToken,
toolInvocationToken?: vscode.ChatParticipantToolToken
): Promise<IClaudeSlashCommandResult> {
// 1. Check request.command (VS Code slash command selected via UI)
if (request.command) {
const handler = this._getHandler(request.command.toLowerCase());
if (handler) {
const result = await handler.handle(request.prompt, stream, token);
const result = await handler.handle(request.prompt, stream, token, toolInvocationToken);
return { handled: true, result: result ?? {} };
}
}
Expand All @@ -92,7 +95,7 @@ export class ClaudeSlashCommandService extends Disposable implements IClaudeSlas
return { handled: false };
}

const result = await handler.handle(args ?? '', stream, token);
const result = await handler.handle(args ?? '', stream, token, toolInvocationToken);
return { handled: true, result: result ?? {} };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ export interface IClaudeSlashCommandHandler {
* @param args - Arguments passed after the command name
* @param stream - Response stream for sending messages to the chat (undefined when invoked from Command Palette)
* @param token - Cancellation token
* @param toolInvocationToken - Token for invoking tools in the chat context (undefined when invoked from Command Palette)
* @returns Chat result or void
*/
handle(
args: string,
stream: vscode.ChatResponseStream | undefined,
token: CancellationToken
token: CancellationToken,
toolInvocationToken?: vscode.ChatParticipantToolToken
): Promise<vscode.ChatResult | void>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { ILogService } from '../../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
import { URI } from '../../../../../util/vs/base/common/uri';
import { LanguageModelTextPart } from '../../../../../vscodeTypes';
import { IAnswerResult } from '../../../../tools/common/askQuestionsTypes';
import { ToolName } from '../../../../tools/common/toolNames';
import { IToolsService } from '../../../../tools/common/toolsService';
import { IClaudeSlashCommandHandler, registerClaudeSlashCommand } from './claudeSlashCommandRegistry';

/**
Expand Down Expand Up @@ -67,13 +71,19 @@ export class MemorySlashCommand implements IClaudeSlashCommandHandler {
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@INativeEnvService private readonly envService: INativeEnvService,
@ILogService private readonly logService: ILogService,
@IToolsService private readonly toolsService: IToolsService,
) { }

async handle(
_args: string,
stream: vscode.ChatResponseStream | undefined,
_token: CancellationToken
_token: CancellationToken,
toolInvocationToken?: vscode.ChatParticipantToolToken
): Promise<vscode.ChatResult> {
if (toolInvocationToken) {
return this._handleWithAskQuestions(stream, toolInvocationToken);
}

stream?.markdown(vscode.l10n.t('Opening memory file picker...'));

// Fire and forget - picker runs in background
Expand All @@ -87,6 +97,74 @@ export class MemorySlashCommand implements IClaudeSlashCommandHandler {
return {};
}

private async _handleWithAskQuestions(
stream: vscode.ChatResponseStream | undefined,
toolInvocationToken: vscode.ChatParticipantToolToken
): Promise<vscode.ChatResult> {
const locations = this._getMemoryLocations();

// If only one location, open it directly without asking
if (locations.length === 1) {
await this._openOrCreateMemoryFile(locations[0]);
stream?.markdown(vscode.l10n.t('Opened memory file: {0}', locations[0].label));
return {};
}

// Build question options from memory locations
const options: { label: string; description: string; location: MemoryLocation }[] = [];
for (const location of locations) {
const exists = await this._fileExists(location.path);
options.push({
label: exists ? location.label : vscode.l10n.t('{0} (will be created)', location.label),
description: location.description,
location,
});
}

const questionHeader = vscode.l10n.t('Claude Memory');
try {
const result = await this.toolsService.invokeTool(ToolName.CoreAskQuestions, {
input: {
questions: [{
header: questionHeader,
question: vscode.l10n.t('Select memory file to edit'),
options: options.map(o => ({
label: o.label,
description: o.description,
})),
}],
},
toolInvocationToken,
}, CancellationToken.None);

const firstPart = result.content.at(0);
if (!(firstPart instanceof LanguageModelTextPart)) {
return {};
}

const toolResult: IAnswerResult = JSON.parse(firstPart.value);
const answer = toolResult.answers[questionHeader];
if (!answer || answer.skipped || answer.selected.length === 0) {
return {};
}

// Find the matching location by label
const selectedLabel = answer.selected[0];
const selectedOption = options.find(o => o.label === selectedLabel);
if (selectedOption) {
await this._openOrCreateMemoryFile(selectedOption.location);
stream?.markdown(vscode.l10n.t('Opened memory file: {0}', selectedOption.location.label));
}
} catch (error) {
this.logService.error('[MemorySlashCommand] Error using askQuestions tool:', error);
vscode.window.showErrorMessage(
vscode.l10n.t('Error opening memory file: {0}', error instanceof Error ? error.message : String(error))
);
}

return {};
}

private async _runPicker(): Promise<void> {
// Build list of memory locations
const locations = this._getMemoryLocations();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IWorkspaceService, NullWorkspaceService } from '../../../../../../platform/workspace/common/workspaceService';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../util/common/test/testUtils';
import { CancellationToken } from '../../../../../../util/vs/base/common/cancellation';
import { URI } from '../../../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelTextPart } from '../../../../../../vscodeTypes';
import { createExtensionUnitTestingServices } from '../../../../../test/node/services';
import { MockChatResponseStream } from '../../../../../test/node/testHelpers';
import { IAnswerResult } from '../../../../../tools/common/askQuestionsTypes';
import { ToolName } from '../../../../../tools/common/toolNames';
import { IToolsService } from '../../../../../tools/common/toolsService';
import { MemorySlashCommand } from '../memoryCommand';

describe('MemorySlashCommand', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let command: MemorySlashCommand;
let stream: MockChatResponseStream;
let invokeToolSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
const serviceCollection = store.add(createExtensionUnitTestingServices(store));

// Override the tools service before creating the accessor
invokeToolSpy = vi.fn();
serviceCollection.define(IToolsService, {
_serviceBrand: undefined,
invokeTool: invokeToolSpy,
} as Partial<IToolsService> as IToolsService);

// Configure workspace with a folder so we get multiple memory locations
const workspaceService = store.add(new NullWorkspaceService([URI.file('/test/workspace')]));
serviceCollection.define(IWorkspaceService, workspaceService);

const accessor = serviceCollection.createTestingAccessor();

const instantiationService = accessor.get(IInstantiationService);
command = instantiationService.createInstance(MemorySlashCommand);
stream = new MockChatResponseStream();
});

describe('with toolInvocationToken (chat context)', () => {
const mockToken = {} as any;

it('uses askQuestions tool when toolInvocationToken is provided', async () => {
const answerResult: IAnswerResult = {
answers: {
'Claude Memory': {
selected: ['User memory'],
freeText: null,
skipped: false,
},
},
};
invokeToolSpy.mockResolvedValue({
content: [new LanguageModelTextPart(JSON.stringify(answerResult))],
});

await command.handle('', stream, CancellationToken.None, mockToken);

expect(invokeToolSpy).toHaveBeenCalledOnce();
expect(invokeToolSpy).toHaveBeenCalledWith(
ToolName.CoreAskQuestions,
expect.objectContaining({
toolInvocationToken: mockToken,
input: expect.objectContaining({
questions: expect.arrayContaining([
expect.objectContaining({
question: expect.any(String),
options: expect.arrayContaining([
expect.objectContaining({ description: expect.stringContaining('CLAUDE.md') }),
expect.objectContaining({ description: '.claude/CLAUDE.md' }),
expect.objectContaining({ description: '.claude/CLAUDE.local.md' }),
]),
}),
]),
}),
}),
CancellationToken.None,
);
});

it('returns empty result when user skips the question', async () => {
const answerResult: IAnswerResult = {
answers: {
'Claude Memory': {
selected: [],
freeText: null,
skipped: true,
},
},
};
invokeToolSpy.mockResolvedValue({
content: [new LanguageModelTextPart(JSON.stringify(answerResult))],
});

const result = await command.handle('', stream, CancellationToken.None, mockToken);

expect(result).toEqual({});
});

it('returns empty result when askQuestions tool returns no text part', async () => {
invokeToolSpy.mockResolvedValue({
content: [],
});

const result = await command.handle('', stream, CancellationToken.None, mockToken);

expect(result).toEqual({});
});

it('handles errors from the askQuestions tool gracefully', async () => {
invokeToolSpy.mockRejectedValue(new Error('Tool failed'));

// In test environment, vscode.window.showErrorMessage is not available,
// so the error handler will itself throw. In production, it shows an error message.
await expect(command.handle('', stream, CancellationToken.None, mockToken)).rejects.toThrow();
});

it('opens file directly when only one memory location exists', async () => {
// Create a command with no workspace folders (only user memory available)
const serviceCollection = store.add(createExtensionUnitTestingServices(store));
const spy = vi.fn();
serviceCollection.define(IToolsService, {
_serviceBrand: undefined,
invokeTool: spy,
} as Partial<IToolsService> as IToolsService);
// NullWorkspaceService with no folders means only user memory location
serviceCollection.define(IWorkspaceService, store.add(new NullWorkspaceService([])));

const accessor = serviceCollection.createTestingAccessor();
const cmd = accessor.get(IInstantiationService).createInstance(MemorySlashCommand);

// Opening the file will throw because vscode.workspace.openTextDocument isn't available in tests
await expect(cmd.handle('', stream, CancellationToken.None, mockToken)).rejects.toThrow();

// askQuestions should NOT be called when there's only one location
expect(spy).not.toHaveBeenCalled();
});
});

// Note: tests for the "without token" (Command Palette / QuickPick) path are omitted
// because vscode.window is not available in the unit test shim. The pre-existing
// QuickPick behavior is unaffected by these changes.
});
Loading