fix(core): fix three JIT context bugs in read_file, read_many_files, and memoryDiscovery (#22679)

This commit is contained in:
Sandy Tao
2026-03-16 13:10:50 -07:00
committed by GitHub
parent dfe22aae21
commit b91f75cd6d
7 changed files with 221 additions and 12 deletions

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part, PartListUnion, PartUnion } from '@google/genai';
import type { Config } from '../config/config.js';
/**
@@ -63,3 +64,24 @@ export function appendJitContext(
}
return `${llmContent}${JIT_CONTEXT_PREFIX}${jitContext}${JIT_CONTEXT_SUFFIX}`;
}
/**
* Appends JIT context to non-string tool content (e.g., images, PDFs) by
* wrapping both the original content and the JIT context into a Part array.
*
* @param llmContent - The original non-string tool output content.
* @param jitContext - The discovered JIT context string.
* @returns A Part array containing the original content and JIT context.
*/
export function appendJitContextToParts(
llmContent: PartListUnion,
jitContext: string,
): PartUnion[] {
const jitPart: Part = {
text: `${JIT_CONTEXT_PREFIX}${jitContext}${JIT_CONTEXT_SUFFIX}`,
};
const existingParts: PartUnion[] = Array.isArray(llmContent)
? llmContent
: [llmContent];
return [...existingParts, jitPart];
}

View File

@@ -30,6 +30,15 @@ vi.mock('./jit-context.js', () => ({
if (!context) return content;
return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`;
}),
appendJitContextToParts: vi.fn().mockImplementation((content, context) => {
const jitPart = {
text: `\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`,
};
const existing = Array.isArray(content) ? content : [content];
return [...existing, jitPart];
}),
JIT_CONTEXT_PREFIX: '\n\n--- Newly Discovered Project Context ---\n',
JIT_CONTEXT_SUFFIX: '\n--- End Project Context ---',
}));
describe('ReadFileTool', () => {
@@ -637,5 +646,43 @@ describe('ReadFileTool', () => {
'Newly Discovered Project Context',
);
});
it('should append JIT context as Part array for non-string llmContent (binary files)', async () => {
const { discoverJitContext } = await import('./jit-context.js');
vi.mocked(discoverJitContext).mockResolvedValue(
'Auth rules: use httpOnly cookies.',
);
// Create a minimal valid PNG file (1x1 pixel)
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00,
0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
const filePath = path.join(tempRootDir, 'test-image.png');
await fsp.writeFile(filePath, pngHeader);
const invocation = tool.build({ file_path: filePath });
const result = await invocation.execute(abortSignal);
expect(discoverJitContext).toHaveBeenCalled();
// Result should be an array containing both the image part and JIT context
expect(Array.isArray(result.llmContent)).toBe(true);
const parts = result.llmContent as Array<Record<string, unknown>>;
const jitTextPart = parts.find(
(p) =>
typeof p['text'] === 'string' && p['text'].includes('Auth rules'),
);
expect(jitTextPart).toBeDefined();
expect(jitTextPart!['text']).toContain(
'Newly Discovered Project Context',
);
expect(jitTextPart!['text']).toContain(
'Auth rules: use httpOnly cookies.',
);
});
});
});

View File

@@ -20,7 +20,7 @@ import {
import { ToolErrorType } from './tool-error.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { PartUnion } from '@google/genai';
import type { PartListUnion } from '@google/genai';
import {
processSingleFileContent,
getSpecificMimeType,
@@ -34,7 +34,11 @@ import { READ_FILE_TOOL_NAME, READ_FILE_DISPLAY_NAME } from './tool-names.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { READ_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
import {
discoverJitContext,
appendJitContext,
appendJitContextToParts,
} from './jit-context.js';
/**
* Parameters for the ReadFile tool
@@ -135,7 +139,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
};
}
let llmContent: PartUnion;
let llmContent: PartListUnion;
if (result.isTruncated) {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
@@ -173,8 +177,12 @@ ${result.llmContent}`;
// Discover JIT subdirectory context for the accessed file path
const jitContext = await discoverJitContext(this.config, this.resolvedPath);
if (jitContext && typeof llmContent === 'string') {
llmContent = appendJitContext(llmContent, jitContext);
if (jitContext) {
if (typeof llmContent === 'string') {
llmContent = appendJitContext(llmContent, jitContext);
} else {
llmContent = appendJitContextToParts(llmContent, jitContext);
}
}
return {

View File

@@ -860,5 +860,62 @@ Content of file[1]
: String(result.llmContent);
expect(llmContent).not.toContain('Newly Discovered Project Context');
});
it('should discover JIT context sequentially to avoid duplicate shared parent context', async () => {
const { discoverJitContext } = await import('./jit-context.js');
// Simulate two subdirectories sharing a parent GEMINI.md.
// Sequential execution means the second call sees the parent already
// loaded, so it only returns its own leaf context.
const callOrder: string[] = [];
let firstCallDone = false;
vi.mocked(discoverJitContext).mockImplementation(async (_config, dir) => {
callOrder.push(dir);
if (!firstCallDone) {
// First call (whichever dir) loads the shared parent + its own leaf
firstCallDone = true;
return 'Parent context\nFirst leaf context';
}
// Second call only returns its own leaf (parent already loaded)
return 'Second leaf context';
});
// Create files in two sibling subdirectories
fs.mkdirSync(path.join(tempRootDir, 'subA'), { recursive: true });
fs.mkdirSync(path.join(tempRootDir, 'subB'), { recursive: true });
fs.writeFileSync(
path.join(tempRootDir, 'subA', 'a.ts'),
'const a = 1;',
'utf8',
);
fs.writeFileSync(
path.join(tempRootDir, 'subB', 'b.ts'),
'const b = 2;',
'utf8',
);
const invocation = tool.build({ include: ['subA/a.ts', 'subB/b.ts'] });
const result = await invocation.execute(new AbortController().signal);
// Verify both directories were discovered (order depends on Set iteration)
expect(callOrder).toHaveLength(2);
expect(callOrder).toEqual(
expect.arrayContaining([
expect.stringContaining('subA'),
expect.stringContaining('subB'),
]),
);
const llmContent = Array.isArray(result.llmContent)
? result.llmContent.join('')
: String(result.llmContent);
expect(llmContent).toContain('Parent context');
expect(llmContent).toContain('First leaf context');
expect(llmContent).toContain('Second leaf context');
// Parent context should appear only once (from the first call), not duplicated
const parentMatches = llmContent.match(/Parent context/g);
expect(parentMatches).toHaveLength(1);
});
});
});

View File

@@ -416,14 +416,19 @@ ${finalExclusionPatternsForDescription
}
}
// Discover JIT subdirectory context for all unique directories of processed files
// Discover JIT subdirectory context for all unique directories of processed files.
// Run sequentially so each call sees paths marked as loaded by the previous
// one, preventing shared parent GEMINI.md files from being injected twice.
const uniqueDirs = new Set(
Array.from(filesToConsider).map((f) => path.dirname(f)),
);
const jitResults = await Promise.all(
Array.from(uniqueDirs).map((dir) => discoverJitContext(this.config, dir)),
);
const jitParts = jitResults.filter(Boolean);
const jitParts: string[] = [];
for (const dir of uniqueDirs) {
const ctx = await discoverJitContext(this.config, dir);
if (ctx) {
jitParts.push(ctx);
}
}
if (jitParts.length > 0) {
contentParts.push(
`${JIT_CONTEXT_PREFIX}${jitParts.join('\n')}${JIT_CONTEXT_SUFFIX}`,