Skip to content
Merged
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
70 changes: 70 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,76 @@ describe('Server Config (config.ts)', () => {
expect(config.getSystemPrompt()).toBeUndefined();
});

describe('FileReadCache isolation', () => {
it('returns a distinct cache for child Configs created via Object.create', () => {
// Subagent / scoped-agent / fork construction all use
// `Object.create(parent)`, which does NOT run field initializers.
// Without explicit handling the child would resolve fileReadCache
// through the prototype chain back to the parent's instance, so a
// subagent's ReadFile would see the parent's recorded reads and
// return file_unchanged placeholders for files the subagent has
// never received in its own transcript.
const parent = new Config(baseParams);
const child = Object.create(parent) as Config;

const parentCache = parent.getFileReadCache();
const childCache = child.getFileReadCache();

expect(parentCache).toBeDefined();
expect(childCache).toBeDefined();
expect(childCache).not.toBe(parentCache);

parentCache.recordRead(
'/tmp/parent.ts',
{
dev: 1,
ino: 100,
mtimeMs: 1_000_000,
size: 42,
} as unknown as import('node:fs').Stats,
{ full: true, cacheable: true },
);

expect(parentCache.size()).toBe(1);
expect(childCache.size()).toBe(0);
});

it('returns the same cache instance on repeated getter calls within one Config', () => {
// Sanity: the lazy own-property initialization in
// getFileReadCache() must not allocate a fresh cache on every
// call — recorded entries would vanish between operations.
const config = new Config(baseParams);
expect(config.getFileReadCache()).toBe(config.getFileReadCache());
});
});

describe('startNewSession', () => {
it('clears the FileReadCache so a new session does not inherit prior reads', () => {
// Regression guard: the file-read cache backs ReadFile's
// file_unchanged placeholder, whose correctness depends on the
// model having seen the prior read earlier in the *current*
// conversation. /clear and resume both go through
// startNewSession(), so it must drop cache entries the new
// session has never seen.
const config = new Config(baseParams);
const cache = config.getFileReadCache();
cache.recordRead(
'/tmp/whatever.ts',
{
dev: 1,
ino: 100,
mtimeMs: 1_000_000,
size: 42,
} as unknown as import('node:fs').Stats,
{ full: true, cacheable: true },
);
expect(cache.size()).toBe(1);

config.startNewSession();
expect(cache.size()).toBe(0);
});
});

describe('initialize', () => {
it('should throw an error if checkpointing is enabled and GitService fails', async () => {
const gitError = new Error('Git is not installed');
Expand Down
67 changes: 67 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { PermissionManager } from '../permissions/permission-manager.js';
import { AutoApproveClassifier } from '../permissions/auto-approve-classifier.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { FileReadCache } from '../services/fileReadCache.js';
import {
DEFAULT_TELEMETRY_TARGET,
DEFAULT_OTLP_ENDPOINT,
Expand Down Expand Up @@ -432,6 +433,14 @@ export interface ConfigParameters {
telemetry?: TelemetrySettings;
gitCoAuthor?: boolean;
usageStatisticsEnabled?: boolean;
/**
* If true, disables the per-session FileReadCache short-circuit
* (file_unchanged placeholder). Useful for sessions that may undergo
* context compaction or transcript transformation, where the model
* cannot reliably retrieve a previously-emitted full file content
* from prior tool results. Defaults to false (cache active).
*/
fileReadCacheDisabled?: boolean;
fileFiltering?: {
respectGitIgnore?: boolean;
respectProtoIgnore?: boolean;
Expand Down Expand Up @@ -564,6 +573,11 @@ export class Config {
private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
// Field initializer runs once on the parent Config; child Configs
// built via Object.create(parent) intentionally do NOT pick this up
// — see getFileReadCache() for the per-instance lazy initialization
// that keeps subagent caches isolated from the parent's.
private fileReadCache: FileReadCache = new FileReadCache();
private extensionManager!: ExtensionManager;
private skillManager: SkillManager | null = null;
private permissionManager: PermissionManager | null = null;
Expand Down Expand Up @@ -610,6 +624,7 @@ export class Config {
private readonly telemetrySettings: TelemetrySettings;
private readonly gitCoAuthor: GitCoAuthorSettings;
private readonly usageStatisticsEnabled: boolean;
private readonly fileReadCacheDisabled: boolean;
private geminiClient!: GeminiClient;
private baseLlmClient!: BaseLlmClient;
private cronScheduler: CronScheduler | null = null;
Expand Down Expand Up @@ -755,6 +770,7 @@ export class Config {
email: 'qwen-coder@alibabacloud.com',
};
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
this.fileReadCacheDisabled = params.fileReadCacheDisabled ?? false;
this.outputLanguageFilePath = params.outputLanguageFilePath;

this.fileFiltering = {
Expand Down Expand Up @@ -1274,6 +1290,16 @@ export class Config {
this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;
// The file-read cache is session-scoped: its `file_unchanged`
// placeholder relies on the model having seen the prior full read
// earlier in the *current* conversation. Carrying entries across
// /clear or session resume would let a follow-up Read return the
// placeholder despite the new session never having received the
// file contents. Use the getter so the lazy own-property
// initialization in getFileReadCache() applies even for Configs
// constructed via Object.create — those should clear their own
// cache, not the parent's.
this.getFileReadCache().clear();
if (this.initialized) {
logStartSession(this, new StartSessionEvent(this));
}
Expand Down Expand Up @@ -2313,6 +2339,47 @@ export class Config {
return this.subagentManager;
}

/**
* Session-scoped cache that tracks Read / Edit / WriteFile operations
* on files. The cache must be **per-Config-instance** so that each
* subagent (which gets its own Config) does not inherit the parent's
* recorded reads via the prototype chain.
*
* The wrinkle: every subagent / scoped-agent / fork path in this
* codebase constructs its Config via `Object.create(parent)`. That
* does **not** run instance field initializers, so the parent's
* `fileReadCache` field is reachable on the child only by prototype
* lookup — i.e. child and parent end up sharing the same cache. The
* own-property check below detects "this instance was made by
* Object.create" and lazily attaches a fresh cache, ensuring
* isolation without requiring every Object.create site to remember
* to override the field.
*/
getFileReadCache(): FileReadCache {
if (!Object.prototype.hasOwnProperty.call(this, 'fileReadCache')) {
// The own-property write needs to bypass `private`'s structural
// check — the field is conceptually still private to the class,
// we just need TS to let us install an own copy on a child
// instance produced by `Object.create(parent)`.
(this as unknown as { fileReadCache: FileReadCache }).fileReadCache =
new FileReadCache();
}
return this.fileReadCache;
}

/**
* When true, ReadFile / Edit / WriteFile must bypass the session
* FileReadCache entirely and behave as if it did not exist (no
* `file_unchanged` placeholder, no future prior-read enforcement).
* Intended as an escape hatch for sessions where the cache's "model
* has already seen this content earlier in the conversation"
* assumption is unreliable — e.g. after context compaction or
* transcript transformation.
*/
getFileReadCacheDisabled(): boolean {
return this.fileReadCacheDisabled;
}

getSkillManager(): SkillManager | null {
return this.skillManager;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,9 @@ describe('Gemini Client (client.ts)', () => {
warn: vi.fn(),
error: vi.fn(),
}),
getFileReadCache: vi.fn().mockReturnValue({
clear: vi.fn(),
}),
} as unknown as Config;

client = new GeminiClient(mockConfig);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,14 @@ export class GeminiClient {
});

await this.startChat(newHistory);
// Compaction rewrites the prompt history: prior full-Read tool
// results may have been summarised away, but the FileReadCache
// still believes those reads are "in this conversation". A
// follow-up Read could then return the file_unchanged
// placeholder pointing at content the model can no longer
// retrieve from its own context. Clear the cache so post-
// compaction Reads re-emit the bytes.
this.config.getFileReadCache().clear();
uiTelemetryService.setLastPromptTokenCount(info.newTokenCount);
this.forceFullIdeContext = true;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export * from './services/chatRecordingService.js';
export * from './services/cronScheduler.js';
export { runEvolvePass } from './services/evolveService.js';
export * from './services/fileDiscoveryService.js';
export * from './services/fileReadCache.js';
export * from './services/fileSystemService.js';
export * from './services/gitService.js';
export * from './services/gitWorktreeService.js';
Expand Down
Loading
Loading