From eba23edb93ec00aa11ffa288e2b89ebbf3d77a70 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 4 Sep 2026 15:57:59 +0800 Subject: [PATCH] feat(agent-core-v2): drop the experimental gate from turn-level file history (#3525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-core-v2): drop the experimental gate from turn-level file history The file_history flag and its KIMI_CODE_EXPERIMENTAL_FILE_HISTORY env var are removed; first-touch baselines, turn checkpoints, and the changes/turnRecorded/contentAt reads now run unconditionally, so the per-turn file diffs served by kap-server no longer depend on opt-in configuration. The changes REST response drops its enabled field — recorded alone tells an authoritative empty result apart from a turn whose records were never made, lost to a crash, or displaced by retention. * fix(agent-core-v2): probe blob existence with a stat, not a directory listing turnRecorded always answered false for turns whose snapshots lived under the file-history/ prefix directory: BlobStoreService.has listed the scope directory (a shallow readdir) and exact-matched the nested key against top-level entries, so keyed blobs were never found and the changes endpoint reported recorded=false for exactly the turns that had edits. Stat the key through storage.size instead, and pin the turn-recorded assertions against a real node-fs home so the harness covers the production layout. * fix(agent-core-v2): probe every keyed blob before calling a turn recorded Merging the start and end entries let an end entry with key null (a deleted or oversize-at-end file) shadow the start baseline, and probing only one surviving key missed partial loss in the other phase: a session displaced from the thirty-session window could still claim authority for turns whose snapshots were gone. turnRecorded now stats every distinct keyed blob across both checkpoints and reports the turn unrecorded when any is missing. --- .changeset/file-history-always-on.md | 5 ++ .../src/features/fileHistory/fileHistory.ts | 1 - .../fileHistory/fileHistoryFeature.ts | 1 - .../fileHistory/fileHistoryService.ts | 30 +++---- .../src/features/fileHistory/flag.ts | 16 ---- packages/agent-core-v2/src/index.ts | 1 - .../backends/node-fs/blobStoreService.ts | 3 +- .../features/fileHistory/fileHistory.test.ts | 78 +++++++------------ .../src/protocol/rest-file-history.ts | 1 - packages/kap-server/src/routes/fileHistory.ts | 1 - packages/kap-server/test/fileHistory.test.ts | 1 - 11 files changed, 48 insertions(+), 90 deletions(-) create mode 100644 .changeset/file-history-always-on.md delete mode 100644 packages/agent-core-v2/src/features/fileHistory/flag.ts diff --git a/.changeset/file-history-always-on.md b/.changeset/file-history-always-on.md new file mode 100644 index 0000000000..8ecc65ee49 --- /dev/null +++ b/.changeset/file-history-always-on.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Turn-level file history is now always on; the experimental file-history flag has been removed. diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index 136584d43b..f38289ce77 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -42,7 +42,6 @@ export interface FileHistoryContent { export interface IAgentFileHistoryService { readonly _serviceBrand: undefined; - enabled(): boolean; history(): FileHistoryState; settled(): Promise; captureForActiveTurn(path: string): Promise; diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts index deb2e2b101..2b7c840f15 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts @@ -1,7 +1,6 @@ import { Feature } from '#/features/feature'; import { registerFeature } from '#/features/featureRegistry'; -import './flag'; import { IAgentFileHistoryService } from './fileHistory'; import { AgentFileHistoryService } from './fileHistoryService'; diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index ba822e0b0c..5f21ecbda1 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -15,7 +15,6 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { TurnEnded } from '#/agent/loop/turnOps'; import { IEventBus } from '#/app/event/eventBus'; -import { IFlagService } from '#/app/flag/flag'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -40,7 +39,6 @@ import { fileHistoryKey, } from './fileHistoryOps'; import { touchFileHistorySession } from './fileHistoryRetention'; -import { FILE_HISTORY_FLAG_ID } from './flag'; export const FILE_HISTORY_MAX_FILE_BYTES = 4 * 1024 * 1024; export { FILE_HISTORY_BLOB_PREFIX } from './fileHistory'; @@ -58,7 +56,6 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IEventBus eventBus: IEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IFlagService private readonly flags: IFlagService, @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @IBlobStore private readonly blobs: IBlobStore, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @@ -89,17 +86,12 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor eventBus.subscribe(TurnEnded, (event) => { if (event.agentId !== this.agentCtx.agentId) return; if (this.activeTurnId === event.turnId) this.activeTurnId = undefined; - if (!this.enabled()) return; void this.enqueue(() => this.endCheckpoint(event.turnId)); }), ); this.effect(() => () => this.queue, 'fileHistory:drain'); } - enabled(): boolean { - return this.flags.enabled(FILE_HISTORY_FLAG_ID); - } - history(): FileHistoryState { return this.agentState.get(fileHistoryKey); } @@ -109,12 +101,10 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } changes(turnId: number): Promise { - if (!this.enabled()) return Promise.resolve([]); return this.enqueueValue(() => this.readChanges(turnId)); } turnRecorded(turnId: number): Promise { - if (!this.enabled()) return Promise.resolve(false); return this.enqueueValue(async () => { const state = this.history(); const index = state.checkpoints.findIndex( @@ -129,10 +119,17 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor index === state.checkpoints.length - 1 && this.activeTurnId === turnId; if (end === undefined && !live) return false; - const entries = { ...state.checkpoints[index]!.entries, ...end?.entries }; - const keyed = Object.values(entries).find((entry) => entry.key !== null); - if (keyed?.key === null || keyed?.key === undefined) return true; - return this.blobs.has(this.agentCtx.scope(), keyed.key); + const keys = new Set(); + for (const entry of [ + ...Object.values(state.checkpoints[index]!.entries), + ...Object.values(end?.entries ?? {}), + ]) { + if (entry.key !== null) keys.add(entry.key); + } + for (const key of keys) { + if (!(await this.blobs.has(this.agentCtx.scope(), key))) return false; + } + return true; }); } @@ -219,7 +216,6 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor path: string, phase: FileHistoryCheckpointPhase = 'start', ): Promise { - if (!this.enabled()) return Promise.resolve(undefined); return this.enqueueValue(() => this.readContentAt(turnId, path, phase)); } @@ -255,14 +251,12 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } private onWillExecuteTool(event: WillExecuteToolEvent): void { - if (!this.enabled()) return; const path = editTargetPath(event.execution.display); if (path === undefined) return; event.waitUntil(this.enqueue(() => this.capture(path, event.turnId))); } private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void { - if (!this.enabled()) return; const path = editTargetPath(event.execution.display); if (path === undefined) return; const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); @@ -272,7 +266,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor captureForActiveTurn(path: string): Promise { const turnId = this.activeTurnId; - if (!this.enabled() || turnId === undefined) return Promise.resolve(); + if (turnId === undefined) return Promise.resolve(); return this.enqueue(() => this.capture(path, turnId)); } diff --git a/packages/agent-core-v2/src/features/fileHistory/flag.ts b/packages/agent-core-v2/src/features/fileHistory/flag.ts deleted file mode 100644 index 25ae7a141d..0000000000 --- a/packages/agent-core-v2/src/features/fileHistory/flag.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; - -export const FILE_HISTORY_FLAG_ID = 'file_history'; -export const FILE_HISTORY_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_FILE_HISTORY'; - -export const fileHistoryFlag: FlagDefinitionInput = { - id: FILE_HISTORY_FLAG_ID, - title: 'Turn-level file history', - description: - 'Record each turn\'s edited files — their content from before the first edit and after the turn ends, kept for the last five editing turns of the thirty most recently active sessions per workspace — so per-turn file diffs come from real whole-file snapshots instead of tool-argument reconstruction. Enabling this stores plain copies of edited file content inside the session data directory.', - env: FILE_HISTORY_FLAG_ENV, - default: false, - surface: 'core', -}; - -registerFlagDefinition(fileHistoryFlag); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 9879bd9103..f37d4b40a0 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -330,7 +330,6 @@ import '#/features/plan/planFeature'; export * from '#/features/fileHistory/fileHistory'; export * from '#/features/fileHistory/fileHistoryOps'; export * from '#/features/fileHistory/fileHistoryService'; -export * from '#/features/fileHistory/flag'; import '#/features/fileHistory/fileHistoryFeature'; export * from '#/features/externalHooks/configSection'; export * from '#/features/externalHooks/app/externalHooksRunner'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts index 4820de9258..d93700c8c9 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts @@ -26,8 +26,7 @@ export class BlobStoreService implements IBlobStore { } async has(scope: string, key: string): Promise { - const keys = await this.storage.list(scope, key); - return keys.includes(key); + return (await this.storage.size(scope, key)) !== undefined; } async delete(scope: string, key: string): Promise { diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts index 34a2be1c6e..080b27ad3a 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -15,12 +15,10 @@ import { TurnEnded } from '#/agent/loop/turnOps'; import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IEventBus, type ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; -import type { IFlagService } from '#/app/flag/flag'; import { IAgentFileHistoryService } from '#/features/fileHistory/fileHistory'; import { AgentFileHistoryService, countLineDiff } from '#/features/fileHistory/fileHistoryService'; import { displacedCheckpoints } from '#/features/fileHistory/fileHistoryOps'; import type { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { FILE_HISTORY_FLAG_ENV } from '#/features/fileHistory/flag'; import type { ToolCall } from '#/kosong/contract/message'; import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -39,7 +37,7 @@ import type { RunnableToolExecution } from '#/tool/toolContract'; import { createFakeHostFs } from '../../tools/fixtures/fake-exec'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../../agent/toolExecutor/stubs'; import { registerTestAgentWire, registerTestEventDispatcher, testWireScope } from '../../wire/stubs'; -import { createTestAgent } from '../../harness'; +import { createTestAgent, homeDirServices } from '../../harness'; const SCOPE = 'wire'; const KEY = 'file-history-test'; @@ -56,7 +54,6 @@ describe('AgentFileHistoryService', () => { let blobs: IBlobStore; let scopeCtx: IAgentScopeContext; let files: Map; - let flagEnabled: boolean; beforeEach(() => { disposables = new DisposableStore(); @@ -79,7 +76,6 @@ describe('AgentFileHistoryService', () => { executorEvents = stubToolExecutorEvents(); blobs = new BlobStoreService(new InMemoryStorageService()); files = new Map(); - flagEnabled = true; }); afterEach(() => { @@ -119,7 +115,6 @@ describe('AgentFileHistoryService', () => { agentId === scopeCtx.agentId ? scopeCtx : makeAgentScopeContext({ agentId, agentScope: testWireScope(SCOPE, KEY) }); - const flags = { enabled: () => flagEnabled } as unknown as IFlagService; const workspace = { workDir: WORK_DIR, additionalDirs: [], @@ -138,7 +133,6 @@ describe('AgentFileHistoryService', () => { executorEvents.executor, eventBus, ix.get(IEventDispatcher), - flags, stubRuntime(), blobs, workspace, @@ -312,19 +306,6 @@ describe('AgentFileHistoryService', () => { ]); }); - it('does nothing while the flag is off', async () => { - flagEnabled = false; - const service = createService(); - setFile('/ws/a.txt', 'content\n'); - - startTurn(1); - await fireEdit(service, '/ws/a.txt', 1); - await service.settled(); - - const state = service.history(); - expect(state.checkpoints).toEqual([]); - expect(state.tracked).toEqual([]); - }); it('excludes user edits between turns via the end-of-turn checkpoint', async () => { const service = createService(); @@ -349,25 +330,6 @@ describe('AgentFileHistoryService', () => { expect(await service.contentAt(2, 'a.txt')).toBeUndefined(); }); - it('guards reads once the flag is turned off after data was recorded', async () => { - const service = createService(); - setFile('/ws/a.txt', 'content\n'); - - startTurn(1); - await fireEdit(service, '/ws/a.txt', 1); - setFile('/ws/a.txt', 'changed\n'); - endTurn(1); - startTurn(2); - await service.settled(); - expect(await service.changes(1)).toEqual([ - { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, - ]); - expect((await service.contentAt(1, 'a.txt'))?.content).toBe('content\n'); - - flagEnabled = false; - expect(await service.changes(1)).toEqual([]); - expect(await service.contentAt(1, 'a.txt')).toBeUndefined(); - }); it('reports an over-budget modified file as oversize with no counts', async () => { const service = createService(); @@ -472,6 +434,28 @@ describe('AgentFileHistoryService', () => { expect(await service.turnRecorded(3)).toBe(false); }); + it('reports a deletion turn unrecorded once its baseline blob is gone', async () => { + const service = createService(); + setFile('/ws/d.txt', 'gone\n'); + + startTurn(1); + await fireEdit(service, '/ws/d.txt', 1); + files.delete('/ws/d.txt'); + endTurn(1); + await service.settled(); + + expect(await service.changes(1)).toEqual([ + { path: 'd.txt', status: 'deleted', additions: 0, deletions: 1 }, + ]); + expect(await service.turnRecorded(1)).toBe(true); + + const keyed = Object.values( + service.history().checkpoints.find((c) => c.turnId === 1 && c.phase !== 'end')!.entries, + ).find((entry) => entry.key !== null); + await blobs.delete(scopeCtx.scope(), keyed!.key!); + expect(await service.turnRecorded(1)).toBe(false); + }); + it('keeps a shared baseline blob alive until its last window reference leaves', async () => { const service = createService(); setFile('/ws/s.txt', 'base\n'); @@ -522,19 +506,12 @@ describe('AgentFileHistoryService', () => { }); describe('file history through real scripted turns', () => { - beforeEach(() => { - process.env[FILE_HISTORY_FLAG_ENV] = '1'; - }); - - afterEach(() => { - delete process.env[FILE_HISTORY_FLAG_ENV]; - }); - it('checkpoints edits across turns and serves exact per-turn changes', async () => { const dir = await mkdtemp(join(tmpdir(), 'file-history-e2e-')); + const home = await mkdtemp(join(tmpdir(), 'file-history-home-')); const file = join(dir, 'notes.txt'); await writeFile(file, 'alpha\nbeta\n'); - const ctx = createTestAgent(); + const ctx = createTestAgent(homeDirServices(home)); try { await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -582,9 +559,14 @@ describe('file history through real scripted turns', () => { { path: file, status: 'modified', additions: 1, deletions: 1 }, ]); expect(await service.changes(1)).toEqual([]); + + expect(await service.turnRecorded(0)).toBe(true); + expect(await service.turnRecorded(1)).toBe(false); + expect(await service.turnRecorded(99)).toBe(false); } finally { await ctx.dispose(); await rm(dir, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); } }); diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts index 4b9c3f9d9d..e9bd91509e 100644 --- a/packages/kap-server/src/protocol/rest-file-history.ts +++ b/packages/kap-server/src/protocol/rest-file-history.ts @@ -24,7 +24,6 @@ export type WireFileHistoryChange = z.infer; export const fileHistoryChangesResponseSchema = z.object({ changes: z.array(fileHistoryChangeSchema), - enabled: z.boolean(), recorded: z.boolean(), }); export type FileHistoryChangesResponse = z.infer; diff --git a/packages/kap-server/src/routes/fileHistory.ts b/packages/kap-server/src/routes/fileHistory.ts index 573e99bf0a..a7f81b1f7c 100644 --- a/packages/kap-server/src/routes/fileHistory.ts +++ b/packages/kap-server/src/routes/fileHistory.ts @@ -60,7 +60,6 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope okEnvelope( { changes: await history.changes(req.query.turn_id), - enabled: history.enabled(), recorded: await history.turnRecorded(req.query.turn_id), }, req.id, diff --git a/packages/kap-server/test/fileHistory.test.ts b/packages/kap-server/test/fileHistory.test.ts index bee046d866..4eee9db5e4 100644 --- a/packages/kap-server/test/fileHistory.test.ts +++ b/packages/kap-server/test/fileHistory.test.ts @@ -92,7 +92,6 @@ describe('file history routes', () => { expect(changes.statusCode).toBe(200); expect((changes.json() as Envelope<{ changes: unknown[] }>).data).toEqual({ changes: [], - enabled: false, recorded: false, });