From 2269f8a1a839f2dbdb45f5abda7981fce50f654c Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 11 Aug 2025 10:15:44 -0700 Subject: [PATCH] Modify content generated describing the ide context to only include deltas after the initial update (#5880) --- packages/core/src/core/client.test.ts | 361 +++++++++++++++++++++++--- packages/core/src/core/client.ts | 226 +++++++++++++--- 2 files changed, 509 insertions(+), 78 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index ff901a8b..2e8d086f 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -201,6 +201,7 @@ describe('Gemini Client (client.ts)', () => { getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getIdeModeFeature: vi.fn().mockReturnValue(false), getIdeMode: vi.fn().mockReturnValue(true), + getDebugMode: vi.fn().mockReturnValue(false), getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue(['/test/dir']), }), @@ -449,8 +450,7 @@ describe('Gemini Client (client.ts)', () => { const mockChat = { addHistory: vi.fn(), }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client['chat'] = mockChat as any; + client['chat'] = mockChat as unknown as GeminiChat; const newContent = { role: 'user', @@ -667,7 +667,7 @@ describe('Gemini Client (client.ts)', () => { }); describe('sendMessageStream', () => { - it('should include IDE context when ideModeFeature is enabled', async () => { + it('should include editor context when ideModeFeature is enabled', async () => { // Arrange vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { @@ -725,21 +725,30 @@ describe('Gemini Client (client.ts)', () => { // Assert expect(ideContext.getIdeContext).toHaveBeenCalled(); const expectedContext = ` -This is the file that the user is looking at: -- Path: /path/to/active/file.ts -This is the cursor position in the file: -- Cursor Position: Line 5, Character 10 -This is the selected text in the file: -- hello -Here are some other files the user has open, with the most recent at the top: -- /path/to/recent/file1.ts -- /path/to/recent/file2.ts +Here is the user's editor context as a JSON object. This is for your information only. +\`\`\`json +${JSON.stringify( + { + activeFile: { + path: '/path/to/active/file.ts', + cursor: { + line: 5, + character: 10, + }, + selectedText: 'hello', + }, + otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'], + }, + null, + 2, +)} +\`\`\` `.trim(); - const expectedRequest = [{ text: expectedContext }, ...initialRequest]; - expect(mockTurnRunFn).toHaveBeenCalledWith( - expectedRequest, - expect.any(Object), - ); + const expectedRequest = [{ text: expectedContext }]; + expect(mockChat.addHistory).toHaveBeenCalledWith({ + role: 'user', + parts: expectedRequest, + }); }); it('should not add context if ideModeFeature is enabled but no open files', async () => { @@ -839,18 +848,29 @@ Here are some other files the user has open, with the most recent at the top: // Assert expect(ideContext.getIdeContext).toHaveBeenCalled(); const expectedContext = ` -This is the file that the user is looking at: -- Path: /path/to/active/file.ts -This is the cursor position in the file: -- Cursor Position: Line 5, Character 10 -This is the selected text in the file: -- hello +Here is the user's editor context as a JSON object. This is for your information only. +\`\`\`json +${JSON.stringify( + { + activeFile: { + path: '/path/to/active/file.ts', + cursor: { + line: 5, + character: 10, + }, + selectedText: 'hello', + }, + }, + null, + 2, +)} +\`\`\` `.trim(); - const expectedRequest = [{ text: expectedContext }, ...initialRequest]; - expect(mockTurnRunFn).toHaveBeenCalledWith( - expectedRequest, - expect.any(Object), - ); + const expectedRequest = [{ text: expectedContext }]; + expect(mockChat.addHistory).toHaveBeenCalledWith({ + role: 'user', + parts: expectedRequest, + }); }); it('should add context if ideModeFeature is enabled and there are open files but no active file', async () => { @@ -904,15 +924,22 @@ This is the selected text in the file: // Assert expect(ideContext.getIdeContext).toHaveBeenCalled(); const expectedContext = ` -Here are some files the user has open, with the most recent at the top: -- /path/to/recent/file1.ts -- /path/to/recent/file2.ts +Here is the user's editor context as a JSON object. This is for your information only. +\`\`\`json +${JSON.stringify( + { + otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'], + }, + null, + 2, +)} +\`\`\` `.trim(); - const expectedRequest = [{ text: expectedContext }, ...initialRequest]; - expect(mockTurnRunFn).toHaveBeenCalledWith( - expectedRequest, - expect.any(Object), - ); + const expectedRequest = [{ text: expectedContext }]; + expect(mockChat.addHistory).toHaveBeenCalledWith({ + role: 'user', + parts: expectedRequest, + }); }); it('should return the turn instance after the stream is complete', async () => { @@ -1190,6 +1217,268 @@ Here are some files the user has open, with the most recent at the top: `${eventCount} events generated (properly bounded by MAX_TURNS)`, ); }); + + describe('Editor context delta', () => { + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + + beforeEach(() => { + client['forceFullIdeContext'] = false; // Reset before each delta test + vi.spyOn(client, 'tryCompressChat').mockResolvedValue(null); + vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + setHistory: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ text: 'summary' }), + // Assume history is not empty for delta checks + getHistory: vi + .fn() + .mockReturnValue([ + { role: 'user', parts: [{ text: 'previous message' }] }, + ]), + }; + client['chat'] = mockChat as GeminiChat; + + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }), + generateContent: mockGenerateContentFn, + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + }); + + const testCases = [ + { + description: 'sends delta when active file changes', + previousActiveFile: { + path: '/path/to/old/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + shouldSendContext: true, + }, + { + description: 'sends delta when cursor line changes', + previousActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 1, character: 10 }, + selectedText: 'hello', + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + shouldSendContext: true, + }, + { + description: 'sends delta when cursor character changes', + previousActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 1 }, + selectedText: 'hello', + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + shouldSendContext: true, + }, + { + description: 'sends delta when selected text changes', + previousActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'world', + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + shouldSendContext: true, + }, + { + description: 'sends delta when selected text is added', + previousActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + shouldSendContext: true, + }, + { + description: 'sends delta when selected text is removed', + previousActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + }, + shouldSendContext: true, + }, + { + description: 'does not send context when nothing changes', + previousActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + currentActiveFile: { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }, + shouldSendContext: false, + }, + ]; + + it.each(testCases)( + '$description', + async ({ + previousActiveFile, + currentActiveFile, + shouldSendContext, + }) => { + // Setup previous context + client['lastSentIdeContext'] = { + workspaceState: { + openFiles: [ + { + path: previousActiveFile.path, + cursor: previousActiveFile.cursor, + selectedText: previousActiveFile.selectedText, + isActive: true, + timestamp: Date.now() - 1000, + }, + ], + }, + }; + + // Setup current context + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { ...currentActiveFile, isActive: true, timestamp: Date.now() }, + ], + }, + }); + + const stream = client.sendMessageStream( + [{ text: 'Hi' }], + new AbortController().signal, + 'prompt-id-delta', + ); + for await (const _ of stream) { + // consume stream + } + + const mockChat = client['chat'] as unknown as { + addHistory: (typeof vi)['fn']; + }; + + if (shouldSendContext) { + expect(mockChat.addHistory).toHaveBeenCalledWith( + expect.objectContaining({ + parts: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining( + "Here is a summary of changes in the user's editor context", + ), + }), + ]), + }), + ); + } else { + expect(mockChat.addHistory).not.toHaveBeenCalled(); + } + }, + ); + + it('sends full context when history is cleared, even if editor state is unchanged', async () => { + const activeFile = { + path: '/path/to/active/file.ts', + cursor: { line: 5, character: 10 }, + selectedText: 'hello', + }; + + // Setup previous context + client['lastSentIdeContext'] = { + workspaceState: { + openFiles: [ + { + path: activeFile.path, + cursor: activeFile.cursor, + selectedText: activeFile.selectedText, + isActive: true, + timestamp: Date.now() - 1000, + }, + ], + }, + }; + + // Setup current context (same as previous) + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { ...activeFile, isActive: true, timestamp: Date.now() }, + ], + }, + }); + + // Make history empty + const mockChat = client['chat'] as unknown as { + getHistory: ReturnType<(typeof vi)['fn']>; + addHistory: ReturnType<(typeof vi)['fn']>; + }; + mockChat.getHistory.mockReturnValue([]); + + const stream = client.sendMessageStream( + [{ text: 'Hi' }], + new AbortController().signal, + 'prompt-id-history-cleared', + ); + for await (const _ of stream) { + // consume stream + } + + expect(mockChat.addHistory).toHaveBeenCalledWith( + expect.objectContaining({ + parts: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining( + "Here is the user's editor context", + ), + }), + ]), + }), + ); + + // Also verify it's the full context, not a delta. + const call = mockChat.addHistory.mock.calls[0][0]; + const contextText = call.parts[0].text; + const contextJson = JSON.parse( + contextText.match(/```json\n(.*)\n```/s)![1], + ); + expect(contextJson).toHaveProperty('activeFile'); + expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts'); + }); + }); }); describe('generateContent', () => { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 13e60039..df3dbc4e 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -50,6 +50,7 @@ import { NextSpeakerCheckEvent, } from '../telemetry/types.js'; import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'; +import { IdeContext, File } from '../ide/ideContext.js'; function isThinkingSupported(model: string) { if (model.startsWith('gemini-2.5')) return true; @@ -112,6 +113,8 @@ export class GeminiClient { private readonly loopDetector: LoopDetectionService; private lastPromptId: string; + private lastSentIdeContext: IdeContext | undefined; + private forceFullIdeContext = true; constructor(private config: Config) { if (config.getProxy()) { @@ -164,6 +167,7 @@ export class GeminiClient { setHistory(history: Content[]) { this.getChat().setHistory(history); + this.forceFullIdeContext = true; } async setTools(): Promise { @@ -189,6 +193,7 @@ export class GeminiClient { } async startChat(extraHistory?: Content[]): Promise { + this.forceFullIdeContext = true; const envParts = await getEnvironmentContext(this.config); const toolRegistry = await this.config.getToolRegistry(); const toolDeclarations = toolRegistry.getFunctionDeclarations(); @@ -238,6 +243,174 @@ export class GeminiClient { } } + private getIdeContextParts(forceFullContext: boolean): { + contextParts: string[]; + newIdeContext: IdeContext | undefined; + } { + const currentIdeContext = ideContext.getIdeContext(); + if (!currentIdeContext) { + return { contextParts: [], newIdeContext: undefined }; + } + + if (forceFullContext || !this.lastSentIdeContext) { + // Send full context as JSON + const openFiles = currentIdeContext.workspaceState?.openFiles || []; + const activeFile = openFiles.find((f) => f.isActive); + const otherOpenFiles = openFiles + .filter((f) => !f.isActive) + .map((f) => f.path); + + const contextData: Record = {}; + + if (activeFile) { + contextData.activeFile = { + path: activeFile.path, + cursor: activeFile.cursor + ? { + line: activeFile.cursor.line, + character: activeFile.cursor.character, + } + : undefined, + selectedText: activeFile.selectedText || undefined, + }; + } + + if (otherOpenFiles.length > 0) { + contextData.otherOpenFiles = otherOpenFiles; + } + + if (Object.keys(contextData).length === 0) { + return { contextParts: [], newIdeContext: currentIdeContext }; + } + + const jsonString = JSON.stringify(contextData, null, 2); + const contextParts = [ + "Here is the user's editor context as a JSON object. This is for your information only.", + '```json', + jsonString, + '```', + ]; + + if (this.config.getDebugMode()) { + console.log(contextParts.join('\n')); + } + return { + contextParts, + newIdeContext: currentIdeContext, + }; + } else { + // Calculate and send delta as JSON + const delta: Record = {}; + const changes: Record = {}; + + const lastFiles = new Map( + (this.lastSentIdeContext.workspaceState?.openFiles || []).map( + (f: File) => [f.path, f], + ), + ); + const currentFiles = new Map( + (currentIdeContext.workspaceState?.openFiles || []).map((f: File) => [ + f.path, + f, + ]), + ); + + const openedFiles: string[] = []; + for (const [path] of currentFiles.entries()) { + if (!lastFiles.has(path)) { + openedFiles.push(path); + } + } + if (openedFiles.length > 0) { + changes.filesOpened = openedFiles; + } + + const closedFiles: string[] = []; + for (const [path] of lastFiles.entries()) { + if (!currentFiles.has(path)) { + closedFiles.push(path); + } + } + if (closedFiles.length > 0) { + changes.filesClosed = closedFiles; + } + + const lastActiveFile = ( + this.lastSentIdeContext.workspaceState?.openFiles || [] + ).find((f: File) => f.isActive); + const currentActiveFile = ( + currentIdeContext.workspaceState?.openFiles || [] + ).find((f: File) => f.isActive); + + if (currentActiveFile) { + if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) { + changes.activeFileChanged = { + path: currentActiveFile.path, + cursor: currentActiveFile.cursor + ? { + line: currentActiveFile.cursor.line, + character: currentActiveFile.cursor.character, + } + : undefined, + selectedText: currentActiveFile.selectedText || undefined, + }; + } else { + const lastCursor = lastActiveFile.cursor; + const currentCursor = currentActiveFile.cursor; + if ( + currentCursor && + (!lastCursor || + lastCursor.line !== currentCursor.line || + lastCursor.character !== currentCursor.character) + ) { + changes.cursorMoved = { + path: currentActiveFile.path, + cursor: { + line: currentCursor.line, + character: currentCursor.character, + }, + }; + } + + const lastSelectedText = lastActiveFile.selectedText || ''; + const currentSelectedText = currentActiveFile.selectedText || ''; + if (lastSelectedText !== currentSelectedText) { + changes.selectionChanged = { + path: currentActiveFile.path, + selectedText: currentSelectedText, + }; + } + } + } else if (lastActiveFile) { + changes.activeFileChanged = { + path: null, + previousPath: lastActiveFile.path, + }; + } + + if (Object.keys(changes).length === 0) { + return { contextParts: [], newIdeContext: currentIdeContext }; + } + + delta.changes = changes; + const jsonString = JSON.stringify(delta, null, 2); + const contextParts = [ + "Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.", + '```json', + jsonString, + '```', + ]; + + if (this.config.getDebugMode()) { + console.log(contextParts.join('\n')); + } + return { + contextParts, + newIdeContext: currentIdeContext, + }; + } + } + async *sendMessageStream( request: PartListUnion, signal: AbortSignal, @@ -273,49 +446,17 @@ export class GeminiClient { } if (this.config.getIdeModeFeature() && this.config.getIdeMode()) { - const ideContextState = ideContext.getIdeContext(); - const openFiles = ideContextState?.workspaceState?.openFiles; - - if (openFiles && openFiles.length > 0) { - const contextParts: string[] = []; - const firstFile = openFiles[0]; - const activeFile = firstFile.isActive ? firstFile : undefined; - - if (activeFile) { - contextParts.push( - `This is the file that the user is looking at:\n- Path: ${activeFile.path}`, - ); - if (activeFile.cursor) { - contextParts.push( - `This is the cursor position in the file:\n- Cursor Position: Line ${activeFile.cursor.line}, Character ${activeFile.cursor.character}`, - ); - } - if (activeFile.selectedText) { - contextParts.push( - `This is the selected text in the file:\n- ${activeFile.selectedText}`, - ); - } - } - - const otherOpenFiles = activeFile ? openFiles.slice(1) : openFiles; - - if (otherOpenFiles.length > 0) { - const recentFiles = otherOpenFiles - .map((file) => `- ${file.path}`) - .join('\n'); - const heading = activeFile - ? `Here are some other files the user has open, with the most recent at the top:` - : `Here are some files the user has open, with the most recent at the top:`; - contextParts.push(`${heading}\n${recentFiles}`); - } - - if (contextParts.length > 0) { - request = [ - { text: contextParts.join('\n') }, - ...(Array.isArray(request) ? request : [request]), - ]; - } + const { contextParts, newIdeContext } = this.getIdeContextParts( + this.forceFullIdeContext || this.getHistory().length === 0, + ); + if (contextParts.length > 0) { + this.getChat().addHistory({ + role: 'user', + parts: [{ text: contextParts.join('\n') }], + }); } + this.lastSentIdeContext = newIdeContext; + this.forceFullIdeContext = false; } const turn = new Turn(this.getChat(), prompt_id); @@ -648,6 +789,7 @@ export class GeminiClient { }, ...historyToKeep, ]); + this.forceFullIdeContext = true; const { totalTokens: newTokenCount } = await this.getContentGenerator().countTokens({