diff options
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/services/ideContext.test.ts | 125 | ||||
| -rw-r--r-- | packages/core/src/services/ideContext.ts | 105 | ||||
| -rw-r--r-- | packages/core/src/tools/mcp-client.ts | 14 |
4 files changed, 245 insertions, 0 deletions
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df7db12c..5f1dc3e7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export * from './utils/quotaErrorDetection.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/ideContext.js'; // Export base tool definitions export * from './tools/tools.js'; diff --git a/packages/core/src/services/ideContext.test.ts b/packages/core/src/services/ideContext.test.ts new file mode 100644 index 00000000..0e6ff045 --- /dev/null +++ b/packages/core/src/services/ideContext.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createIdeContextStore } from './ideContext.js'; + +describe('ideContext - Active File', () => { + let ideContext: ReturnType<typeof createIdeContextStore>; + + beforeEach(() => { + // Create a fresh, isolated instance for each test + ideContext = createIdeContextStore(); + }); + + it('should return undefined initially for active file context', () => { + expect(ideContext.getActiveFileContext()).toBeUndefined(); + }); + + it('should set and retrieve the active file context', () => { + const testFile = { + filePath: '/path/to/test/file.ts', + cursor: { line: 5, character: 10 }, + }; + + ideContext.setActiveFileContext(testFile); + + const activeFile = ideContext.getActiveFileContext(); + expect(activeFile).toEqual(testFile); + }); + + it('should update the active file context when called multiple times', () => { + const firstFile = { + filePath: '/path/to/first.js', + cursor: { line: 1, character: 1 }, + }; + ideContext.setActiveFileContext(firstFile); + + const secondFile = { + filePath: '/path/to/second.py', + cursor: { line: 20, character: 30 }, + }; + ideContext.setActiveFileContext(secondFile); + + const activeFile = ideContext.getActiveFileContext(); + expect(activeFile).toEqual(secondFile); + }); + + it('should handle empty string for file path', () => { + const testFile = { + filePath: '', + cursor: { line: 0, character: 0 }, + }; + ideContext.setActiveFileContext(testFile); + expect(ideContext.getActiveFileContext()).toEqual(testFile); + }); + + it('should notify subscribers when active file context changes', () => { + const subscriber1 = vi.fn(); + const subscriber2 = vi.fn(); + + ideContext.subscribeToActiveFile(subscriber1); + ideContext.subscribeToActiveFile(subscriber2); + + const testFile = { + filePath: '/path/to/subscribed.ts', + cursor: { line: 15, character: 25 }, + }; + ideContext.setActiveFileContext(testFile); + + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber1).toHaveBeenCalledWith(testFile); + expect(subscriber2).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledWith(testFile); + + // Test with another update + const newFile = { + filePath: '/path/to/new.js', + cursor: { line: 1, character: 1 }, + }; + ideContext.setActiveFileContext(newFile); + + expect(subscriber1).toHaveBeenCalledTimes(2); + expect(subscriber1).toHaveBeenCalledWith(newFile); + expect(subscriber2).toHaveBeenCalledTimes(2); + expect(subscriber2).toHaveBeenCalledWith(newFile); + }); + + it('should stop notifying a subscriber after unsubscribe', () => { + const subscriber1 = vi.fn(); + const subscriber2 = vi.fn(); + + const unsubscribe1 = ideContext.subscribeToActiveFile(subscriber1); + ideContext.subscribeToActiveFile(subscriber2); + + ideContext.setActiveFileContext({ + filePath: '/path/to/file1.txt', + cursor: { line: 1, character: 1 }, + }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + + unsubscribe1(); + + ideContext.setActiveFileContext({ + filePath: '/path/to/file2.txt', + cursor: { line: 2, character: 2 }, + }); + expect(subscriber1).toHaveBeenCalledTimes(1); // Should not be called again + expect(subscriber2).toHaveBeenCalledTimes(2); + }); + + it('should allow the cursor to be optional', () => { + const testFile = { + filePath: '/path/to/test/file.ts', + }; + + ideContext.setActiveFileContext(testFile); + + const activeFile = ideContext.getActiveFileContext(); + expect(activeFile).toEqual(testFile); + }); +}); diff --git a/packages/core/src/services/ideContext.ts b/packages/core/src/services/ideContext.ts new file mode 100644 index 00000000..6bbe8cb9 --- /dev/null +++ b/packages/core/src/services/ideContext.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +/** + * The reserved server name for the IDE's MCP server. + */ +export const IDE_SERVER_NAME = '_ide_server'; + +/** + * Zod schema for validating a cursor position. + */ +export const CursorSchema = z.object({ + line: z.number(), + character: z.number(), +}); +export type Cursor = z.infer<typeof CursorSchema>; + +/** + * Zod schema for validating an active file context from the IDE. + */ +export const ActiveFileSchema = z.object({ + filePath: z.string(), + cursor: CursorSchema.optional(), +}); +export type ActiveFile = z.infer<typeof ActiveFileSchema>; + +/** + * Zod schema for validating the 'ide/activeFileChanged' notification from the IDE. + */ +export const ActiveFileNotificationSchema = z.object({ + method: z.literal('ide/activeFileChanged'), + params: ActiveFileSchema, +}); + +type ActiveFileSubscriber = (activeFile: ActiveFile | undefined) => void; + +/** + * Creates a new store for managing the IDE's active file context. + * This factory function encapsulates the state and logic, allowing for the creation + * of isolated instances, which is particularly useful for testing. + * + * @returns An object with methods to interact with the active file context. + */ +export function createIdeContextStore() { + let activeFileContext: ActiveFile | undefined = undefined; + const subscribers = new Set<ActiveFileSubscriber>(); + + /** + * Notifies all registered subscribers about the current active file context. + */ + function notifySubscribers(): void { + for (const subscriber of subscribers) { + subscriber(activeFileContext); + } + } + + /** + * Sets the active file context and notifies all registered subscribers of the change. + * @param newActiveFile The new active file context from the IDE. + */ + function setActiveFileContext(newActiveFile: ActiveFile): void { + activeFileContext = newActiveFile; + notifySubscribers(); + } + + /** + * Retrieves the current active file context. + * @returns The `ActiveFile` object if a file is active, otherwise `undefined`. + */ + function getActiveFileContext(): ActiveFile | undefined { + return activeFileContext; + } + + /** + * Subscribes to changes in the active file context. + * + * When the active file context changes, the provided `subscriber` function will be called. + * Note: The subscriber is not called with the current value upon subscription. + * + * @param subscriber The function to be called when the active file context changes. + * @returns A function that, when called, will unsubscribe the provided subscriber. + */ + function subscribeToActiveFile(subscriber: ActiveFileSubscriber): () => void { + subscribers.add(subscriber); + return () => { + subscribers.delete(subscriber); + }; + } + + return { + setActiveFileContext, + getActiveFileContext, + subscribeToActiveFile, + }; +} + +/** + * The default, shared instance of the IDE context store for the application. + */ +export const ideContext = createIdeContextStore(); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index eb82190b..beb70549 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -20,6 +20,11 @@ import { MCPServerConfig } from '../config/config.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { FunctionDeclaration, Type, mcpToTool } from '@google/genai'; import { sanitizeParameters, ToolRegistry } from './tool-registry.js'; +import { + ActiveFileNotificationSchema, + IDE_SERVER_NAME, + ideContext, +} from '../services/ideContext.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -211,6 +216,15 @@ export async function connectAndDiscover( updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); }; + if (mcpServerName === IDE_SERVER_NAME) { + mcpClient.setNotificationHandler( + ActiveFileNotificationSchema, + (notification) => { + ideContext.setActiveFileContext(notification.params); + }, + ); + } + const tools = await discoverTools( mcpServerName, mcpServerConfig, |
