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..5f21ecbda1c 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 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/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 34a2be1c6e6..080b27ad3af 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 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, });