Modify content generated describing the ide context to only include deltas after the initial update (#5880)

This commit is contained in:
Jacob Richman 2025-08-11 10:15:44 -07:00 committed by GitHub
parent aa5c80dec4
commit 2269f8a1a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 509 additions and 78 deletions

View File

@ -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<GeminiChat> = {
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<ContentGenerator> = {
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', () => {

View File

@ -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<void> {
@ -189,6 +193,7 @@ export class GeminiClient {
}
async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
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<string, unknown> = {};
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<string, unknown> = {};
const changes: Record<string, unknown> = {};
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({