From 47e4b1ce8c4352ae5405580020de84a5db5347d7 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 3 Sep 2026 21:58:07 +0800 Subject: [PATCH 1/3] feat(agent-core-v2): drop the experimental gate from turn-level file history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/file-history-always-on.md | 5 ++ .../src/features/fileHistory/fileHistory.ts | 1 - .../fileHistory/fileHistoryFeature.ts | 1 - .../fileHistory/fileHistoryService.ts | 15 +----- .../src/features/fileHistory/flag.ts | 16 ------- packages/agent-core-v2/src/index.ts | 1 - .../features/fileHistory/fileHistory.test.ts | 46 ------------------- .../src/protocol/rest-file-history.ts | 1 - packages/kap-server/src/routes/fileHistory.ts | 1 - packages/kap-server/test/fileHistory.test.ts | 1 - 10 files changed, 6 insertions(+), 82 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 00000000000..8ecc65ee49d --- /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 136584d43b9..f38289ce770 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 deb2e2b101d..2b7c840f15e 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 ba822e0b0ca..08825d01f9f 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( @@ -219,7 +209,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 +244,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 +259,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 25ae7a141de..00000000000 --- 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 9879bd9103a..f37d4b40a0f 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/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts index 34a2be1c6e6..b0854f88567 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'; @@ -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(); @@ -522,14 +484,6 @@ 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 file = join(dir, 'notes.txt'); diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts index 4b9c3f9d9d5..e9bd91509e3 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 573e99bf0aa..a7f81b1f7cb 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 bee046d866d..4eee9db5e40 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, }); From 2ec8d6d4156992717d17b894c0cb46414e809617 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 4 Sep 2026 14:11:08 +0800 Subject: [PATCH 2/3] 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. --- .../persistence/backends/node-fs/blobStoreService.ts | 3 +-- .../test/features/fileHistory/fileHistory.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) 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 4820de9258a..d93700c8c9f 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 b0854f88567..65fae69e55a 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -37,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'; @@ -486,9 +486,10 @@ describe('AgentFileHistoryService', () => { describe('file history through real scripted turns', () => { 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' }); @@ -536,9 +537,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 }); } }); From 5f9ef046c350870fea1c47c79c6ddd232a72f54f Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 4 Sep 2026 15:33:05 +0800 Subject: [PATCH 3/3] 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. --- .../fileHistory/fileHistoryService.ts | 15 +++++++++---- .../features/fileHistory/fileHistory.test.ts | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 08825d01f9f..5f21ecbda1c 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -119,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; }); } 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 65fae69e55a..080b27ad3af 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -434,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');