From cbd4eda24af72e5fcab99ddb54306f1bb7a473f2 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 12:42:04 +0800 Subject: [PATCH 01/19] feat(agent-core-v2): add experimental turn-level file history snapshots Back up the original content of every file the Edit/Write tools touch (whole-file blob on first touch per session) and version all tracked files at each main-agent turn boundary, deduplicating unchanged files by content hash. Per-turn change stats and per-checkpoint file content are computed on demand from the blobs; checkpoints are capped at 200 with version-1 originals always retained. Gated behind KIMI_CODE_EXPERIMENTAL_FILE_HISTORY (default off). --- .../agent-core-v2/docs/state-manifest.d.ts | 17 +- .../agent-core-v2/docs/wire-manifest.d.ts | 34 +- .../src/features/fileHistory/fileHistory.ts | 47 ++ .../fileHistory/fileHistoryFeature.ts | 17 + .../features/fileHistory/fileHistoryOps.ts | 84 ++++ .../fileHistory/fileHistoryService.ts | 420 ++++++++++++++++++ .../src/features/fileHistory/flag.ts | 16 + packages/agent-core-v2/src/index.ts | 5 + .../features/fileHistory/fileHistory.test.ts | 275 ++++++++++++ packages/agent-core-v2/test/index.test.ts | 2 + .../test/state/builtinReplayableKeys.ts | 2 + 11 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 packages/agent-core-v2/src/features/fileHistory/fileHistory.ts create mode 100644 packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts create mode 100644 packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts create mode 100644 packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts create mode 100644 packages/agent-core-v2/src/features/fileHistory/flag.ts create mode 100644 packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index ee469d519b4..b01131a6c63 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 79 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 80 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -59,6 +59,7 @@ // contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // externalHooks.stopHookContinuationUsed src/features/externalHooks/agent/agentExternalHooksService.ts +// fileHistory src/features/fileHistory/fileHistoryOps.ts // fullCompaction src/agent/fullCompaction/compactionOps.ts // fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts @@ -1493,6 +1494,20 @@ export interface AgentStateSnapshot { }>; // src/features/externalHooks/agent/agentExternalHooksService.ts 'externalHooks.stopHookContinuationUsed': boolean; + // src/features/fileHistory/fileHistoryOps.ts + // replayable · durable — folds: FileHistoryCheckpointed, FileHistoryTracked + 'fileHistory': /* FileHistoryState — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { + readonly checkpoints: readonly /* FileHistoryCheckpointRecord — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { + readonly turnId: number; + readonly entries: Readonly>; + }[]; + readonly tracked: readonly string[]; + }; // src/features/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; // src/features/plan/planOps.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 2654e8c9be5..f8b90dc7acc 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -24,7 +24,7 @@ // cross-reducers), blobs (the folding states whose blob codec offloads inline // media to blob storage), owner (the source file declaring the class). -// Index (55 record types) +// Index (57 record types) // config.update profile src/agent/profile/profileOps.ts // context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts // context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts @@ -34,6 +34,8 @@ // cron.add (none) src/features/cron/cronOps.ts // cron.cursor (none) src/features/cron/cronOps.ts // cron.delete (none) src/features/cron/cronOps.ts +// file_history.checkpoint fileHistory src/features/fileHistory/fileHistoryOps.ts +// file_history.tracked fileHistory src/features/fileHistory/fileHistoryOps.ts // forked (none) src/features/goal/goalOps.ts // full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts // full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts @@ -214,6 +216,34 @@ interface CronDeletePayload { ids: string[]; } +/** + * states: fileHistory + * owner: src/features/fileHistory/fileHistoryOps.ts + */ +interface FileHistoryCheckpointPayload { + _name: 'file_history.checkpoint'; + agentId: string; + turnId: number; + entries: Record; +} + +/** + * states: fileHistory + * owner: src/features/fileHistory/fileHistoryOps.ts + */ +interface FileHistoryTrackedPayload { + _name: 'file_history.tracked'; + agentId: string; + turnId: number; + path: string; + entry: { + key: string | null; + version: number; + contentHash?: string; + size?: number; + }; +} + /** * states: (none) * owner: src/features/goal/goalOps.ts @@ -837,6 +867,8 @@ interface WirePayloadMap { "cron.add": CronAddPayload; "cron.cursor": CronCursorPayload; "cron.delete": CronDeletePayload; + "file_history.checkpoint": FileHistoryCheckpointPayload; + "file_history.tracked": FileHistoryTrackedPayload; "forked": ForkedPayload; "full_compaction.begin": FullCompactionBeginPayload; "full_compaction.cancel": FullCompactionCancelPayload; diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts new file mode 100644 index 00000000000..35d3d07d485 --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -0,0 +1,47 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface FileBackupEntry { + readonly key: string | null; + readonly version: number; + readonly contentHash?: string; + readonly size?: number; +} + +export interface FileHistoryCheckpointRecord { + readonly turnId: number; + readonly entries: Readonly>; +} + +export interface FileHistoryState { + readonly checkpoints: readonly FileHistoryCheckpointRecord[]; + readonly tracked: readonly string[]; +} + +export type FileHistoryChangeStatus = 'added' | 'modified' | 'deleted'; + +export interface FileHistoryChange { + readonly path: string; + readonly status: FileHistoryChangeStatus; + readonly additions: number; + readonly deletions: number; + readonly binary?: boolean; +} + +export interface FileHistoryContent { + readonly version: number; + readonly content?: string; + readonly binary?: boolean; +} + +export interface IAgentFileHistoryService { + readonly _serviceBrand: undefined; + + enabled(): boolean; + history(): FileHistoryState; + settled(): Promise; + changes(turnId: number): Promise; + contentAt(turnId: number, path: string): Promise; +} + +export const IAgentFileHistoryService: ServiceIdentifier = + createDecorator('agentFileHistoryService'); diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts new file mode 100644 index 00000000000..deb2e2b101d --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts @@ -0,0 +1,17 @@ +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import './flag'; +import { IAgentFileHistoryService } from './fileHistory'; +import { AgentFileHistoryService } from './fileHistoryService'; + +export class FileHistoryFeature extends Feature { + static override readonly name = 'fileHistory'; + + constructor() { + super(); + this.contributeAgentService(IAgentFileHistoryService, AgentFileHistoryService); + } +} + +registerFeature(FileHistoryFeature); diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts new file mode 100644 index 00000000000..3b05bbeddd2 --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -0,0 +1,84 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +import type { FileBackupEntry, FileHistoryState } from './fileHistory'; + +export const FILE_HISTORY_CHECKPOINT_CAP = 200; + +const backupEntrySchema = z.object({ + key: z.string().nullable(), + version: z.number(), + contentHash: z.string().optional(), + size: z.number().optional(), +}); + +const fileHistoryTrackedSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + path: z.string(), + entry: backupEntrySchema, +}); + +export class FileHistoryTracked extends AgentEvent2> { + static override readonly type = 'file_history.tracked'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = fileHistoryTrackedSchema; +} +export interface FileHistoryTracked { + readonly agentId: string; + readonly turnId: number; + readonly path: string; + readonly entry: FileBackupEntry; +} + +const fileHistoryCheckpointedSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + entries: z.record(z.string(), backupEntrySchema), +}); + +export class FileHistoryCheckpointed extends AgentEvent2< + z.infer +> { + static override readonly type = 'file_history.checkpoint'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = fileHistoryCheckpointedSchema; +} +export interface FileHistoryCheckpointed { + readonly agentId: string; + readonly turnId: number; + readonly entries: Readonly>; +} + +export const fileHistoryKey = defineState( + 'fileHistory', + (): FileHistoryState => ({ checkpoints: [], tracked: [] }), +) + .replayable({ schema: z.custom() }) + .on(FileHistoryCheckpointed, (s, e) => { + const existing = s.checkpoints.find((c) => c.turnId === e.turnId); + if (existing !== undefined) { + existing.entries = { ...e.entries }; + return; + } + s.checkpoints.push({ turnId: e.turnId, entries: { ...e.entries } }); + if (s.checkpoints.length > FILE_HISTORY_CHECKPOINT_CAP) { + s.checkpoints.splice(0, s.checkpoints.length - FILE_HISTORY_CHECKPOINT_CAP); + } + }) + .on(FileHistoryTracked, (s, e) => { + if (!s.tracked.includes(e.path)) s.tracked.push(e.path); + let checkpoint = s.checkpoints.find((c) => c.turnId === e.turnId); + if (checkpoint === undefined) { + s.checkpoints.push({ turnId: e.turnId, entries: {} }); + checkpoint = s.checkpoints.at(-1); + } + if (checkpoint !== undefined && checkpoint.entries[e.path] === undefined) { + checkpoint.entries[e.path] = { ...e.entry }; + } + }); diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts new file mode 100644 index 00000000000..f289621cf6c --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -0,0 +1,420 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute, relative, resolve } from 'pathe'; + +import { Service } from '#/_base/di/service'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { IEventBus } from '#/app/event/eventBus'; +import { IFlagService } from '#/app/flag/flag'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IBlobStore } from '#/persistence/interface/blobStore'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { + IAgentFileHistoryService, + type FileBackupEntry, + type FileHistoryChange, + type FileHistoryCheckpointRecord, + type FileHistoryContent, + type FileHistoryState, +} from './fileHistory'; +import { + FILE_HISTORY_CHECKPOINT_CAP, + FileHistoryCheckpointed, + FileHistoryTracked, + fileHistoryKey, +} from './fileHistoryOps'; +import { FILE_HISTORY_FLAG_ID } from './flag'; + +export const FILE_HISTORY_MAX_FILE_BYTES = 4 * 1024 * 1024; +export const FILE_HISTORY_BLOB_PREFIX = 'file-history'; + +export class AgentFileHistoryService extends Service implements IAgentFileHistoryService { + declare readonly _serviceBrand: undefined; + + private queue: Promise = Promise.resolve(); + + constructor( + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, + @IAgentStateService private readonly agentState: IAgentStateService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IEventBus eventBus: IEventBus, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IFlagService private readonly flags: IFlagService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IBlobStore private readonly blobs: IBlobStore, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + ) { + super(); + this.agentState.contributeState(fileHistoryKey); + if (this.agentCtx.agentId !== MAIN_AGENT_ID) return; + + this._register( + toolExecutor.onWillExecuteTool((event) => this.onWillExecuteTool(event)), + ); + this._register( + eventBus.subscribe(TurnStarted, (event) => { + if (event.agentId !== this.agentCtx.agentId || !this.enabled()) return; + void this.enqueue(() => this.checkpoint(event.turnId)); + }), + ); + } + + enabled(): boolean { + return this.flags.enabled(FILE_HISTORY_FLAG_ID); + } + + history(): FileHistoryState { + return this.agentState.get(fileHistoryKey); + } + + settled(): Promise { + return this.queue; + } + + async changes(turnId: number): Promise { + await this.settled(); + const state = this.history(); + const index = state.checkpoints.findIndex((c) => c.turnId === turnId); + if (index < 0) return []; + const next = state.checkpoints[index + 1]; + + const paths = new Set(); + for (const path of Object.keys(state.checkpoints[index]!.entries)) paths.add(path); + if (next !== undefined) for (const path of Object.keys(next.entries)) paths.add(path); + else for (const path of state.tracked) paths.add(path); + + const changes: FileHistoryChange[] = []; + for (const path of [...paths].toSorted()) { + const before = entryAt(state.checkpoints, index, path); + const beforeBytes = await this.entryBytes(before); + let afterBytes: Uint8Array | undefined; + if (next !== undefined) { + afterBytes = await this.entryBytes(entryAt(state.checkpoints, index + 1, path)); + } else { + const current = await this.readCurrent(path); + if (current === 'unreadable') continue; + afterBytes = current === 'missing' ? undefined : current; + } + const change = diffChange(path, beforeBytes, afterBytes); + if (change !== undefined) changes.push(change); + } + return changes; + } + + async contentAt(turnId: number, path: string): Promise { + await this.settled(); + const state = this.history(); + const index = state.checkpoints.findIndex((c) => c.turnId === turnId); + if (index < 0) return undefined; + const entry = entryAt(state.checkpoints, index, this.pathKey(path)); + if (entry === undefined) return undefined; + if (entry.key === null) return { version: entry.version }; + const bytes = await this.blobs.get(this.agentCtx.scope(), entry.key); + if (bytes === undefined) return undefined; + const content = decodeText(bytes); + if (content === undefined) return { version: entry.version, binary: true }; + return { version: entry.version, content }; + } + + 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 enqueue(op: () => Promise): Promise { + const run = this.queue.then(op); + this.queue = run.catch((error) => { + onUnexpectedError(error); + }); + return run; + } + + private async capture(path: string, turnId: number): Promise { + const pathKey = this.pathKey(path); + const state = this.history(); + if (state.tracked.includes(pathKey)) return; + + const current = await this.readCurrent(pathKey); + if (current === 'unreadable') return; + const entry = + current === 'missing' ? { key: null, version: 1 } : await this.backup(pathKey, 1, current); + await this.dispatcher.dispatch( + new FileHistoryTracked({ agentId: this.agentCtx.agentId, turnId, path: pathKey, entry }), + ); + } + + private async checkpoint(turnId: number): Promise { + const state = this.history(); + if (state.checkpoints.some((c) => c.turnId === turnId)) return; + + const entries: Record = {}; + for (const pathKey of state.tracked) { + const latest = latestEntry(state.checkpoints, pathKey); + const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; + const current = await this.readCurrent(pathKey); + if (current === 'unreadable') { + if (latest !== undefined) entries[pathKey] = latest; + continue; + } + if (current === 'missing') { + entries[pathKey] = + latest?.key === null ? latest : { key: null, version: nextVersion }; + continue; + } + const contentHash = sha256(current); + if (latest !== undefined && latest.contentHash === contentHash) { + entries[pathKey] = latest; + continue; + } + entries[pathKey] = await this.backup(pathKey, nextVersion, current, contentHash); + } + + const evictable = evictableCheckpoints(state.checkpoints); + await this.dispatcher.dispatch( + new FileHistoryCheckpointed({ agentId: this.agentCtx.agentId, turnId, entries }), + ); + await this.evictBlobs(evictable, this.history().checkpoints); + } + + private async backup( + pathKey: string, + version: number, + content: Uint8Array, + contentHash?: string, + ): Promise { + const hash = contentHash ?? sha256(content); + const key = blobKey(pathKey, version); + await this.blobs.put(this.agentCtx.scope(), key, content); + return { key, version, contentHash: hash, size: content.byteLength }; + } + + private async evictBlobs( + evicted: readonly FileHistoryCheckpointRecord[], + retained: readonly FileHistoryCheckpointRecord[], + ): Promise { + if (evicted.length === 0) return; + const retainedKeys = new Set(); + for (const checkpoint of retained) { + for (const entry of Object.values(checkpoint.entries)) { + if (entry.key !== null) retainedKeys.add(entry.key); + } + } + for (const checkpoint of evicted) { + for (const entry of Object.values(checkpoint.entries)) { + if (entry.key === null || entry.version === 1 || retainedKeys.has(entry.key)) continue; + try { + await this.blobs.delete(this.agentCtx.scope(), entry.key); + } catch (error) { + onUnexpectedError(error); + } + } + } + } + + private async entryBytes(entry: FileBackupEntry | undefined): Promise { + if (entry === undefined || entry.key === null) return undefined; + return this.blobs.get(this.agentCtx.scope(), entry.key); + } + + private async readCurrent(pathKey: string): Promise { + const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); + let info; + try { + info = await this.fs.stat(absolute); + } catch (error) { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + return code === 'ENOENT' ? 'missing' : 'unreadable'; + } + if (!info.isFile || info.size > FILE_HISTORY_MAX_FILE_BYTES) return 'unreadable'; + try { + return await this.fs.readBytes(absolute); + } catch { + return 'unreadable'; + } + } + + private pathKey(path: string): string { + if (!isAbsolute(path)) return path; + const relativePath = relative(this.workspaceCtx.workDir, path); + if (relativePath === '' || relativePath === '..' || relativePath.startsWith('../')) return path; + return relativePath; + } +} + +function editTargetPath(display: ToolInputDisplay | undefined): string | undefined { + if (display === undefined || display.kind !== 'file_io') return undefined; + if (display.operation !== 'edit' && display.operation !== 'write') return undefined; + return display.path; +} + +function entryAt( + checkpoints: readonly FileHistoryCheckpointRecord[], + index: number, + path: string, +): FileBackupEntry | undefined { + for (let i = index; i >= 0; i -= 1) { + const entry = checkpoints[i]!.entries[path]; + if (entry !== undefined) return entry; + } + return undefined; +} + +function latestEntry( + checkpoints: readonly FileHistoryCheckpointRecord[], + path: string, +): FileBackupEntry | undefined { + return entryAt(checkpoints, checkpoints.length - 1, path); +} + +function maxVersion( + checkpoints: readonly FileHistoryCheckpointRecord[], + path: string, +): number { + let max = 0; + for (const checkpoint of checkpoints) { + const entry = checkpoint.entries[path]; + if (entry !== undefined && entry.version > max) max = entry.version; + } + return max; +} + +function evictableCheckpoints( + checkpoints: readonly FileHistoryCheckpointRecord[], +): readonly FileHistoryCheckpointRecord[] { + const overflow = checkpoints.length + 1 - FILE_HISTORY_CHECKPOINT_CAP; + return overflow > 0 ? checkpoints.slice(0, overflow) : []; +} + +function blobKey(pathKey: string, version: number): string { + const hash = createHash('sha256').update(pathKey, 'utf8').digest('hex').slice(0, 16); + return `${FILE_HISTORY_BLOB_PREFIX}/${hash}@v${String(version)}`; +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function decodeText(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +function diffChange( + path: string, + beforeBytes: Uint8Array | undefined, + afterBytes: Uint8Array | undefined, +): FileHistoryChange | undefined { + if (beforeBytes === undefined && afterBytes === undefined) return undefined; + const before = beforeBytes === undefined ? undefined : decodeText(beforeBytes); + const after = afterBytes === undefined ? undefined : decodeText(afterBytes); + const binary = + (beforeBytes !== undefined && before === undefined) || + (afterBytes !== undefined && after === undefined); + + if (beforeBytes === undefined) { + return binary + ? { path, status: 'added', additions: 0, deletions: 0, binary } + : { path, status: 'added', additions: countLines(after ?? ''), deletions: 0 }; + } + if (afterBytes === undefined) { + return binary + ? { path, status: 'deleted', additions: 0, deletions: 0, binary } + : { path, status: 'deleted', additions: 0, deletions: countLines(before ?? '') }; + } + if (binary) { + return bytesEqual(beforeBytes, afterBytes) + ? undefined + : { path, status: 'modified', additions: 0, deletions: 0, binary }; + } + if (before === after) return undefined; + const { additions, deletions } = countLineDiff(before ?? '', after ?? ''); + return { path, status: 'modified', additions, deletions }; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i += 1) if (a[i] !== b[i]) return false; + return true; +} + +function splitLines(content: string): string[] { + if (content === '') return []; + const lines = content.split('\n'); + if (lines.at(-1) === '') lines.pop(); + return lines; +} + +function countLines(content: string): number { + return splitLines(content).length; +} + +export function countLineDiff( + before: string, + after: string, +): { additions: number; deletions: number } { + const beforeLines = splitLines(before); + const afterLines = splitLines(after); + + let start = 0; + while ( + start < beforeLines.length && + start < afterLines.length && + beforeLines[start] === afterLines[start] + ) { + start += 1; + } + let beforeEnd = beforeLines.length; + let afterEnd = afterLines.length; + while ( + beforeEnd > start && + afterEnd > start && + beforeLines[beforeEnd - 1] === afterLines[afterEnd - 1] + ) { + beforeEnd -= 1; + afterEnd -= 1; + } + + const oldSlice = beforeLines.slice(start, beforeEnd); + const newSlice = afterLines.slice(start, afterEnd); + const common = lcsLength(oldSlice, newSlice); + return { + additions: newSlice.length - common, + deletions: oldSlice.length - common, + }; +} + +const LCS_CELL_BUDGET = 4_000_000; + +function lcsLength(a: readonly string[], b: readonly string[]): number { + if (a.length === 0 || b.length === 0) return 0; + if (a.length * b.length > LCS_CELL_BUDGET) { + const bSet = new Set(b); + return a.filter((line) => bSet.has(line)).length; + } + let previous = new Uint32Array(b.length + 1); + let current = new Uint32Array(b.length + 1); + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + current[j] = + a[i - 1] === b[j - 1] + ? previous[j - 1]! + 1 + : Math.max(previous[j]!, current[j - 1]!); + } + [previous, current] = [current, previous]; + } + return previous[b.length]!; +} diff --git a/packages/agent-core-v2/src/features/fileHistory/flag.ts b/packages/agent-core-v2/src/features/fileHistory/flag.ts new file mode 100644 index 00000000000..a9c18e7a61d --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/flag.ts @@ -0,0 +1,16 @@ +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: + 'Back up the original content of every file the session edits and version all tracked files at each turn boundary, so per-turn file diffs come from real whole-file snapshots instead of tool-argument reconstruction.', + 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 d5a646d23cd..12ad075a370 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -341,6 +341,11 @@ export * from '#/features/plan/planOps'; export * from '#/features/plan/planService'; import '#/features/dateChange/dateChangeFeature'; 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'; export * from '#/features/externalHooks/app/externalHooksRunnerService'; diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts new file mode 100644 index 00000000000..f0d1855faf7 --- /dev/null +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +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 { AgentFileHistoryService } from '#/features/fileHistory/fileHistoryService'; +import type { ToolCall } from '#/kosong/contract/message'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { BlobStoreService } from '#/persistence/backends/node-fs/blobStoreService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IBlobStore } from '#/persistence/interface/blobStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import type { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +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'; + +const SCOPE = 'wire'; +const KEY = 'file-history-test'; +const WORK_DIR = '/ws'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +describe('AgentFileHistoryService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let executorEvents: ToolExecutorEventStubs; + let eventBus: IEventBus; + let blobs: IBlobStore; + let scopeCtx: IAgentScopeContext; + let files: Map; + let flagEnabled: boolean; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.set(IEventBus, new SyncDescriptor(EventBusService)); + registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { + log: ix.get(IAppendLogStore), + eventBus: ix.get(IEventBus), + }); + scopeCtx = makeAgentScopeContext({ agentId: 'main', agentScope: testWireScope(SCOPE, KEY) }); + ix.stub(IAgentScopeContext, scopeCtx); + registerTestEventDispatcher(ix); + eventBus = ix.get(IEventBus); + const sessionBus = eventBus as Partial; + if (typeof sessionBus.activateAgent === 'function') { + sessionBus.activateAgent(scopeCtx.agentContext); + } + executorEvents = stubToolExecutorEvents(); + blobs = new BlobStoreService(new InMemoryStorageService()); + files = new Map(); + flagEnabled = true; + }); + + afterEach(() => { + disposables.dispose(); + }); + + function hostFs(): IHostFileSystem { + return createFakeHostFs({ + stat: async (path: string) => { + const content = files.get(path); + if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return { isFile: true, isDirectory: false, size: content.byteLength }; + }, + readBytes: async (path: string) => { + const content = files.get(path); + if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return content; + }, + }); + } + + function createService(agentId = 'main'): AgentFileHistoryService { + const ctx = + agentId === scopeCtx.agentId + ? scopeCtx + : makeAgentScopeContext({ agentId, agentScope: testWireScope(SCOPE, KEY) }); + const flags = { enabled: () => flagEnabled } as unknown as IFlagService; + const workspace = { + workDir: WORK_DIR, + additionalDirs: [], + } as unknown as ISessionWorkspaceContext; + return disposables.add( + new AgentFileHistoryService( + ctx, + ix.get(IAgentStateService), + executorEvents.executor, + eventBus, + ix.get(IEventDispatcher), + flags, + hostFs(), + blobs, + workspace, + ), + ); + } + + function setFile(path: string, content: string): void { + files.set(path, encoder.encode(content)); + } + + async function fireEdit(service: AgentFileHistoryService, path: string, turnId: number): Promise { + const toolCall: ToolCall = { type: 'function', id: `call-${String(turnId)}`, name: 'Edit', arguments: null }; + const execution: RunnableToolExecution = { + approvalRule: 'Edit', + display: { kind: 'file_io', operation: 'edit', path }, + execute: async () => ({ output: '' }), + }; + await executorEvents.fireWillExecute( + { turnId, toolCall, execution, args: {} }, + new AbortController().signal, + ); + await service.settled(); + } + + function startTurn(turnId: number): void { + eventBus.publish( + new TurnStarted({ agentId: 'main', turnId, origin: USER_PROMPT_ORIGIN }), + scopeCtx.agentContext, + ); + } + + async function blobText(key: string): Promise { + const bytes = await blobs.get(scopeCtx.scope(), key); + return bytes === undefined ? undefined : decoder.decode(bytes); + } + + it('backs up pre-edit content on first touch and versions changes at the next turn boundary', async () => { + const service = createService(); + setFile('/ws/a.txt', 'one\ntwo\n'); + + startTurn(1); + await fireEdit(service, '/ws/a.txt', 1); + + let state = service.history(); + expect(state.tracked).toEqual(['a.txt']); + const v1 = state.checkpoints.find((c) => c.turnId === 1)?.entries['a.txt']; + expect(v1?.version).toBe(1); + expect(await blobText(v1!.key!)).toBe('one\ntwo\n'); + + setFile('/ws/a.txt', 'one\nTWO\n'); + await fireEdit(service, '/ws/a.txt', 1); + state = service.history(); + expect(Object.values(state.checkpoints.find((c) => c.turnId === 1)!.entries)).toHaveLength(1); + + startTurn(2); + await service.settled(); + state = service.history(); + const v2 = state.checkpoints.find((c) => c.turnId === 2)?.entries['a.txt']; + expect(v2?.version).toBe(2); + expect(await blobText(v2!.key!)).toBe('one\nTWO\n'); + + expect(await service.changes(1)).toEqual([ + { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, + ]); + expect((await service.contentAt(1, 'a.txt'))?.content).toBe('one\ntwo\n'); + expect((await service.contentAt(2, '/ws/a.txt'))?.content).toBe('one\nTWO\n'); + }); + + it('merges overlapping edits within one turn into a single true diff', async () => { + const service = createService(); + setFile('/ws/a.txt', 'alpha\nbeta\n'); + + startTurn(1); + await fireEdit(service, '/ws/a.txt', 1); + setFile('/ws/a.txt', 'alpha\nbeta\ngamma\n'); + await fireEdit(service, '/ws/a.txt', 1); + setFile('/ws/a.txt', 'alpha\nGAMMA\n'); + await fireEdit(service, '/ws/a.txt', 1); + + startTurn(2); + await service.settled(); + + expect(await service.changes(1)).toEqual([ + { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, + ]); + }); + + it('reuses the previous backup when a tracked file is unchanged at a turn boundary', async () => { + const service = createService(); + setFile('/ws/b.txt', 'stable\n'); + + startTurn(1); + await fireEdit(service, '/ws/b.txt', 1); + startTurn(2); + startTurn(3); + await service.settled(); + + const state = service.history(); + const entryAtTurn2 = state.checkpoints.find((c) => c.turnId === 2)?.entries['b.txt']; + const entryAtTurn3 = state.checkpoints.find((c) => c.turnId === 3)?.entries['b.txt']; + expect(entryAtTurn2?.version).toBe(1); + expect(entryAtTurn3?.version).toBe(1); + const keys = await blobs.list(scopeCtx.scope(), 'file-history/'); + expect(keys).toHaveLength(1); + expect(await service.changes(1)).toEqual([]); + }); + + it('records file creation and deletion across turns', async () => { + const service = createService(); + + startTurn(1); + await fireEdit(service, '/ws/new.txt', 1); + let entry = service.history().checkpoints.find((c) => c.turnId === 1)?.entries['new.txt']; + expect(entry).toEqual({ key: null, version: 1 }); + + setFile('/ws/new.txt', 'created\n'); + startTurn(2); + await service.settled(); + expect(await service.changes(1)).toEqual([ + { path: 'new.txt', status: 'added', additions: 1, deletions: 0 }, + ]); + + files.delete('/ws/new.txt'); + startTurn(3); + await service.settled(); + entry = service.history().checkpoints.find((c) => c.turnId === 3)?.entries['new.txt']; + expect(entry?.key).toBeNull(); + expect(await service.changes(2)).toEqual([ + { path: 'new.txt', status: 'deleted', additions: 0, deletions: 1 }, + ]); + }); + + 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('stays inactive on subagents', async () => { + const service = createService('sub-1'); + setFile('/ws/a.txt', 'content\n'); + + startTurn(1); + await fireEdit(service, '/ws/a.txt', 1); + await service.settled(); + + expect(service.history().checkpoints).toEqual([]); + }); + + it('keeps files outside the workspace keyed by absolute path', async () => { + const service = createService(); + setFile('/elsewhere/notes.md', 'note\n'); + + startTurn(1); + await fireEdit(service, '/elsewhere/notes.md', 1); + + expect(service.history().tracked).toEqual(['/elsewhere/notes.md']); + }); +}); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index e904dffa1c5..6c71179771b 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -87,6 +87,8 @@ const V2_RECORD_TYPES: ReadonlySet = new Set([ 'interaction.request', 'interaction.resolved', 'plan.revision', + 'file_history.tracked', + 'file_history.checkpoint', 'interruptionReminder.recorded', 'plugin.session_start', 'runtime.set_binding', diff --git a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts index 68675c29f03..cbdaa8c7458 100644 --- a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts +++ b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts @@ -19,6 +19,7 @@ import { runtimeBindingKey } from '#/agent/runtimeBinding/runtimeBindingOps'; import { taskKey } from '#/agent/task/taskOps'; import { taskNotificationDeliveryKey } from '#/agent/task/taskService'; import { userToolKey } from '#/agent/userTool/userToolOps'; +import { fileHistoryKey } from '#/features/fileHistory/fileHistoryOps'; import { planKey } from '#/features/plan/planOps'; import { swarmKey } from '#/features/swarm/swarmOps'; import { towerKey, towerOwnerKey } from '#/features/tower/towerOps'; @@ -42,6 +43,7 @@ export const BUILTIN_REPLAYABLE_STATE_KEYS: readonly ReplayableStateKey[] = taskKey, taskNotificationDeliveryKey, userToolKey, + fileHistoryKey, planKey, swarmKey, towerKey, From f1b81d835a978e9f6a0d1dd076239fd5c7899c98 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 12:43:42 +0800 Subject: [PATCH 02/19] chore: add changeset for turn-level file history --- .changeset/file-history-turn-snapshots.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/file-history-turn-snapshots.md diff --git a/.changeset/file-history-turn-snapshots.md b/.changeset/file-history-turn-snapshots.md new file mode 100644 index 00000000000..2d7dfa6e258 --- /dev/null +++ b/.changeset/file-history-turn-snapshots.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add experimental turn-level file snapshots: the daemon backs up the original content of every file it edits and versions all tracked files at each turn boundary. Enable with KIMI_CODE_EXPERIMENTAL_FILE_HISTORY=1. From d660ad509f1394b8f5377ebd1c48c1309dc5de0f Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 12:53:14 +0800 Subject: [PATCH 03/19] feat(klient,kap-server): expose turn-level file history over the wire Map the file_history.tracked / file_history.checkpoint engine events to transcript markers in the kap-server projector, and add the agentFileHistoryService wire contract to klient: agent(id).getFileChanges and agent(id).getFileContentAt read per-turn file changes and checkpoint content, validated end to end by contract parity and the memory/ipc conformance suites. --- .changeset/klient-file-history-reads.md | 5 +++ .../src/services/transcript/coreEventMap.ts | 14 +++++++ .../test/services/transcript.test.ts | 40 +++++++++++++++++++ packages/klient/src/contract/agent/schemas.ts | 15 +++++++ .../klient/src/contract/agent/services.ts | 11 +++++ packages/klient/src/contract/index.ts | 2 + packages/klient/src/core/facade/agent.ts | 32 +++++++++++++++ .../src/transports/memory/serviceRegistry.ts | 2 + packages/klient/test/contract-parity.ts | 8 ++++ packages/klient/test/helpers/conformance.ts | 22 ++++++++++ 10 files changed, 151 insertions(+) create mode 100644 .changeset/klient-file-history-reads.md diff --git a/.changeset/klient-file-history-reads.md b/.changeset/klient-file-history-reads.md new file mode 100644 index 00000000000..84206c91a51 --- /dev/null +++ b/.changeset/klient-file-history-reads.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/klient": minor +--- + +Add `agent(id).getFileChanges({ turnId })` and `agent(id).getFileContentAt({ turnId, path })`, reading the daemon's experimental turn-level file snapshots over the wire. diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index a46e602d170..5b5c56ff17a 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -48,6 +48,10 @@ import type { ToolResultEvent, } from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; import type { AgentStatusUpdated } from '@moonshot-ai/agent-core-v2/agent/usage/usageEvents'; +import type { + FileHistoryCheckpointed, + FileHistoryTracked, +} from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistoryOps'; import type { PlanRevision } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; import type { SubagentSuspended } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; import type { @@ -93,6 +97,11 @@ export interface ProjectorInteraction { type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; +type FileHistoryTrackedEvent = { readonly type: 'file_history.tracked' } & FileHistoryTracked; +type FileHistoryCheckpointedEvent = { + readonly type: 'file_history.checkpoint'; +} & FileHistoryCheckpointed; + type AgentActivityUpdatedEvent = { readonly type: 'agent.activity.updated' } & AgentActivityUpdated; type PromptAcceptedEvent = { readonly type: 'prompt.accepted' } & PromptAccepted; type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued; @@ -105,6 +114,8 @@ type TurnSteerEvent = { readonly type: 'turn.steer' } & TurnSteer; export type ProjectorBusEvent = | PlanRevisionEvent + | FileHistoryTrackedEvent + | FileHistoryCheckpointedEvent | ({ readonly type: 'turn.started' } & TurnStarted) | ({ readonly type: 'turn.ended' } & TurnEnded) | ({ readonly type: 'turn.step.started' } & TurnStepStarted) @@ -240,6 +251,9 @@ export class AgentTranscriptProjector { switch (event.type) { case 'plan.revision': return this.onPlanRevision(event); + case 'file_history.tracked': + case 'file_history.checkpoint': + return [this.markerOp(event.type, restOf(event))]; case 'turn.started': return this.onTurnStarted(event); case 'turn.ended': diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 78aba43ddf3..22d2121d7c6 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1426,6 +1426,46 @@ describe('AgentTranscriptProjector', () => { ).toHaveLength(2); }); + it('projects file_history events as markers with their manifest payloads', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + + tx.apply( + projector.map( + ev({ + type: 'file_history.tracked', + agentId: 'main', + turnId: 3, + path: 'src/a.ts', + entry: { key: 'file-history/abc123@v1', version: 1, contentHash: 'deadbeef', size: 12 }, + }), + ), + ); + tx.apply( + projector.map( + ev({ + type: 'file_history.checkpoint', + agentId: 'main', + turnId: 4, + entries: { 'src/a.ts': { key: 'file-history/abc123@v2', version: 2 } }, + }), + ), + ); + + const markers = tx + .getItems() + .filter((item) => item.kind === 'marker' && item.marker.startsWith('file_history.')); + expect(markers).toHaveLength(2); + expect(markers[0]).toMatchObject({ + marker: 'file_history.tracked', + payload: { turnId: 3, path: 'src/a.ts', entry: { version: 1 } }, + }); + expect(markers[1]).toMatchObject({ + marker: 'file_history.checkpoint', + payload: { turnId: 4, entries: { 'src/a.ts': { version: 2 } } }, + }); + }); + it('projects skill / plugin-command / cron / compaction / hook / undo markers', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts index 35cd686b42b..a53dc81f6d6 100644 --- a/packages/klient/src/contract/agent/schemas.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -230,3 +230,18 @@ export const getTaskOutputPayloadSchema = z.object({ taskId: z.string(), tail: z.number().optional(), }); + +/** Mirrors `FileHistoryChange` / `FileHistoryContent` from the engine's `features/fileHistory/fileHistory.ts`. */ +export const fileHistoryChangeSchema = z.object({ + path: z.string(), + status: z.enum(['added', 'modified', 'deleted']), + additions: z.number(), + deletions: z.number(), + binary: z.boolean().optional(), +}); + +export const fileHistoryContentSchema = z.object({ + version: z.number(), + content: z.string().optional(), + binary: z.boolean().optional(), +}); diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index 54755a7ab54..eb4a84f1154 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -14,6 +14,8 @@ import { activateSkillPayloadSchema, agentCommandInfoSchema, agentTaskInfoSchema, + fileHistoryChangeSchema, + fileHistoryContentSchema, permissionModeSchema, planDataSchema, promptLaunchResultSchema, @@ -101,6 +103,15 @@ export const agentPlanContract = { cancel: { input: z.tuple([z.string().optional()]), output: noResult }, } satisfies ServiceContract; +export const agentFileHistoryContract = { + enabled: { input: z.tuple([]), output: z.boolean() }, + changes: { input: z.tuple([z.number()]), output: z.array(fileHistoryChangeSchema) }, + contentAt: { + input: z.tuple([z.number(), z.string()]), + output: maybe(fileHistoryContentSchema), + }, +} satisfies ServiceContract; + /** `McpServerEntry` from the engine's `mcpCore/connection-manager`. */ export const mcpServerEntrySchema = z.object({ name: z.string(), diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index d16cfaf2280..848bce82e6e 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -11,6 +11,7 @@ import { agentActivityViewContract } from './agent/activity.js'; import { agentCommandContract, agentContextMemoryContract, + agentFileHistoryContract, agentFullCompactionContract, agentLoopContract, agentMcpContract, @@ -88,6 +89,7 @@ export const globalContract: KlientContract = { agentProfileService: agentProfileContract, agentUsageService: agentUsageContract, agentPlanService: agentPlanContract, + agentFileHistoryService: agentFileHistoryContract, agentTaskService: agentTaskContract, agentMcpService: agentMcpContract, agentFullCompactionService: agentFullCompactionContract, diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index f3a25b64110..45dee1d3d3e 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -14,6 +14,7 @@ import type { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp' import type { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/runtimeBinding/runtimeBinding'; import type { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; import type { ISessionTokenCountingService } from '@moonshot-ai/agent-core-v2/session/tokenCounting/sessionTokenCounting'; +import type { IAgentFileHistoryService } from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistory'; import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; @@ -42,6 +43,10 @@ export type AgentContextData = { export type AgentCommandInfo = Awaited>[number]; export type RuntimeBinding = ReturnType; export type PlanData = Awaited>; +export type FileHistoryChange = Awaited>[number]; +export type FileHistoryContent = NonNullable< + Awaited> +>; export type AgentTaskInfo = Awaited>[number]; export type McpServerEntry = ReturnType[number]; @@ -102,6 +107,22 @@ export interface AgentFacade { * Throws when there is nothing to compact or a turn is active. */ compact(input?: { instruction?: string }): Promise; + /** + * Per-turn file changes computed from the daemon's turn-level file + * snapshots (the `file_history` experimental flag). Empty when the flag is + * off, the turn is unknown, or the turn touched no tracked files. + */ + getFileChanges(input: { turnId: number }): Promise; + /** + * A file's content as captured at a turn's checkpoint. `undefined` when the + * file was not tracked at that turn; `content` is absent (with `binary` + * set, or for a file that did not exist yet) when there is no UTF-8 text to + * return. + */ + getFileContentAt(input: { + turnId: number; + path: string; + }): Promise; } export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { @@ -179,5 +200,16 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac call(scope, 'agentFullCompactionService', 'begin', [ { source: 'manual', instruction: input?.instruction }, ]) as Promise, + getFileChanges: (input) => + call(scope, 'agentFileHistoryService', 'changes', [input.turnId]) as Promise< + readonly FileHistoryChange[] + >, + getFileContentAt: async (input) => { + const result = (await call(scope, 'agentFileHistoryService', 'contentAt', [ + input.turnId, + input.path, + ])) as FileHistoryContent | null | undefined; + return result ?? undefined; + }, }; } diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 38863dc02bf..f49bad0efcb 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -39,6 +39,7 @@ import { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/ru import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; import { ISessionTokenCountingService } from '@moonshot-ai/agent-core-v2/session/tokenCounting/sessionTokenCounting'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; +import { IAgentFileHistoryService } from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistory'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; @@ -84,6 +85,7 @@ export const serviceTokens: Readonly>> agentProfileService: IAgentProfileService, agentUsageService: ISessionUsageService, agentPlanService: IAgentPlanService, + agentFileHistoryService: IAgentFileHistoryService, agentTaskService: IAgentTaskService, agentMcpService: IAgentMcpService, agentFullCompactionService: IAgentFullCompactionService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index aca43e1f1c9..d7ff873e579 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -31,6 +31,10 @@ import type { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/promp import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; import type { SkillRuntime } from '@moonshot-ai/agent-core-v2/features/skill/skillAgentRuntime'; import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; +import type { + FileHistoryChange, + FileHistoryContent, +} from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistory'; import type { PlanData } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; import type { SkillSummary } from '@moonshot-ai/agent-core-v2/features/skill/catalog/types'; @@ -182,6 +186,8 @@ import { cancelPlanPayloadSchema, cancelShellCommandPayloadSchema, emptyPayloadSchema, + fileHistoryChangeSchema, + fileHistoryContentSchema, getTaskOutputPayloadSchema, getTasksPayloadSchema, planDataSchema, @@ -699,6 +705,8 @@ const _agentCommandInfo: AssertWire = true; const _runCommandPayload: AssertWire = true; const _planData: AssertWire = true; +const _fileHistoryChange: AssertWire = true; +const _fileHistoryContent: AssertWire = true; const _cancelPlanPayload: AssertWire = true; const _getTasksPayload: AssertWire = true; // The wire task union mirrors the protocol `TaskInfo`; the engine's diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index 4249ac26f67..b85a35bec80 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -560,6 +560,28 @@ export function defineKlientConformance( } }); + it('agent file history reads dispatch and normalize across the wire', async () => { + const created = await target.klient.global.sessions.create({ + workDir: process.cwd(), + title: 'conformance file history', + }); + const session = getLiveSessionById(target.app.accessor, created.id); + if (session === undefined) throw new Error('conformance session was not materialized'); + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + try { + const agent = target.klient.session(created.id).agent('main'); + // The file_history flag is off and no turn ran: both reads take the + // empty path, which still exercises dispatch, schema validation, and + // the ipc transport's null → undefined normalization. + await expect(agent.getFileChanges({ turnId: 1 })).resolves.toEqual([]); + await expect( + agent.getFileContentAt({ turnId: 1, path: 'missing.txt' }), + ).resolves.toBeUndefined(); + } finally { + await target.klient.session(created.id).close(); + } + }); + it('propagates prompt id conflicts with the same 40927 error', async () => { const created = await target.klient.global.sessions.create({ workDir: process.cwd(), From 638ec89e6f9c752cc809ae99f6b7ba73aef74dca Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 13:01:16 +0800 Subject: [PATCH 04/19] feat(kap-server): serve turn-level file history over REST Add GET /sessions/{session_id}/file-history/changes and /content, backed by the main agent's file history service, and suppress the raw file_history.* events at transcript grade now that the projector emits them as markers. --- .../src/protocol/rest-file-history.ts | 37 ++++++ packages/kap-server/src/routes/fileHistory.ts | 101 ++++++++++++++ .../src/routes/registerApiV1Routes.ts | 5 + .../ws/v1/sessionEventBroadcaster.ts | 2 + .../apiSurface.snapshot.test.ts.snap | 8 ++ packages/kap-server/test/fileHistory.test.ts | 124 ++++++++++++++++++ 6 files changed, 277 insertions(+) create mode 100644 packages/kap-server/src/protocol/rest-file-history.ts create mode 100644 packages/kap-server/src/routes/fileHistory.ts create mode 100644 packages/kap-server/test/fileHistory.test.ts diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts new file mode 100644 index 00000000000..3a9e2281a16 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-file-history.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const fileHistoryChangesQuerySchema = z.object({ + turn_id: z.coerce.number().int().nonnegative(), +}); +export type FileHistoryChangesQuery = z.infer; + +export const fileHistoryContentQuerySchema = z.object({ + turn_id: z.coerce.number().int().nonnegative(), + path: z.string().min(1), +}); +export type FileHistoryContentQuery = z.infer; + +export const fileHistoryChangeSchema = z.object({ + path: z.string(), + status: z.enum(['added', 'modified', 'deleted']), + additions: z.number(), + deletions: z.number(), + binary: z.boolean().optional(), +}); +export type WireFileHistoryChange = z.infer; + +export const fileHistoryChangesResponseSchema = z.object({ + changes: z.array(fileHistoryChangeSchema), +}); +export type FileHistoryChangesResponse = z.infer; + +export const fileHistoryContentEntrySchema = z.object({ + version: z.number(), + content: z.string().optional(), + binary: z.boolean().optional(), +}); + +export const fileHistoryContentResponseSchema = z.object({ + content: fileHistoryContentEntrySchema.nullable(), +}); +export type FileHistoryContentResponse = z.infer; diff --git a/packages/kap-server/src/routes/fileHistory.ts b/packages/kap-server/src/routes/fileHistory.ts new file mode 100644 index 00000000000..85bcbf82fb4 --- /dev/null +++ b/packages/kap-server/src/routes/fileHistory.ts @@ -0,0 +1,101 @@ +import { + IAgentFileHistoryService, + getLiveSessionById, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + fileHistoryChangesQuerySchema, + fileHistoryChangesResponseSchema, + fileHistoryContentQuerySchema, + fileHistoryContentResponseSchema, +} from '../protocol/rest-file-history'; +import { ensureMainAgent } from '../transport/mainAgent'; + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +interface FileHistoryRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope): void { + const changesRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/file-history/changes', + params: sessionIdParamSchema, + querystring: fileHistoryChangesQuerySchema, + success: { data: fileHistoryChangesResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: "List one turn's file changes from the turn-level file history", + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const session = getLiveSessionById(core.accessor, session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} is not live`, req.id), + ); + return; + } + const agent = await ensureMainAgent(session); + const history = agent.accessor.get(IAgentFileHistoryService); + reply.send(okEnvelope({ changes: await history.changes(req.query.turn_id) }, req.id)); + }, + ); + app.get( + changesRoute.path, + changesRoute.options, + changesRoute.handler as Parameters[2], + ); + + const contentRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/file-history/content', + params: sessionIdParamSchema, + querystring: fileHistoryContentQuerySchema, + success: { data: fileHistoryContentResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: "A file's content as captured at a turn's file-history checkpoint", + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const session = getLiveSessionById(core.accessor, session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} is not live`, req.id), + ); + return; + } + const agent = await ensureMainAgent(session); + const history = agent.accessor.get(IAgentFileHistoryService); + const content = await history.contentAt(req.query.turn_id, req.query.path); + reply.send(okEnvelope({ content: content ?? null }, req.id)); + }, + ); + app.get( + contentRoute.path, + contentRoute.options, + contentRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index c187f748640..ec4e72d03c8 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -15,6 +15,7 @@ import { registerAuthRoute } from './auth'; import { registerCapabilitiesRoutes } from './capabilities'; import { registerConfigRoutes } from './config'; import { registerConnectionsRoutes } from './connections'; +import { registerFileHistoryRoutes } from './fileHistory'; import { registerFilesRoutes } from './files'; import { registerFsRoutes } from './fs'; import { registerGuiStoreRoutes } from './guiStore'; @@ -168,6 +169,10 @@ export async function registerApiV1Routes( registerFsRoutes(apiV1 as unknown as Parameters[0], core); registerGuiStoreRoutes(apiV1 as unknown as Parameters[0], opts.guiStore); registerToolsRoutes(apiV1 as unknown as Parameters[0], core); + registerFileHistoryRoutes( + apiV1 as unknown as Parameters[0], + core, + ); if (opts.enableTerminals !== false) { registerTerminalsRoutes( apiV1 as unknown as Parameters[0], diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index d24712e70ac..2ba3c0189eb 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -1145,6 +1145,8 @@ const TRANSCRIPT_PROJECTED_EVENT_TYPES: ReadonlySet = new Set([ 'warning', 'goal.updated', 'plan.revision', + 'file_history.tracked', + 'file_history.checkpoint', 'context.spliced', 'agent.status.updated', 'hook.result', diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index a2a5f4b78c6..14a3890db96 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -188,6 +188,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/children", ], + [ + "GET", + "/api/v1/sessions/{session_id}/file-history/changes", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/file-history/content", + ], [ "GET", "/api/v1/sessions/{session_id}/fs/{*}", diff --git a/packages/kap-server/test/fileHistory.test.ts b/packages/kap-server/test/fileHistory.test.ts new file mode 100644 index 00000000000..2b3582cb672 --- /dev/null +++ b/packages/kap-server/test/fileHistory.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +let home: string; +let server: RunningServer | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-file-history-')); +}); + +afterEach(async () => { + try { + await server?.close(); + } catch { + } + server = undefined; + rmSync(home, { recursive: true, force: true }); +}); + +async function boot(): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + return server; +} + +interface InjectResponse { + statusCode: number; + body: string; + json: () => unknown; +} + +interface AppLike { + inject: (req: unknown) => Promise; +} + +function appOf(r: RunningServer): AppLike { + const app = r.app as unknown as AppLike; + return { + inject(req: unknown): Promise { + const request = req as { headers?: Record }; + return app.inject({ + ...request, + headers: { + ...request.headers, + authorization: `Bearer ${r.authTokenService.getToken()}`, + }, + }); + }, + }; +} + +interface Envelope { + code: number; + msg: string; + data: T | null; +} + +async function createSession(r: RunningServer): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd: home } }, + headers: { 'content-type': 'application/json' }, + }); + const envelope = res.json() as Envelope<{ id: string }>; + if (envelope.code !== 0 || envelope.data === null) { + throw new Error(`failed to create session: ${res.body}`); + } + return envelope.data.id; +} + +describe('file history routes', () => { + it('serves empty changes and null content for a live session without history', async () => { + const r = await boot(); + const sessionId = await createSession(r); + + const changes = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/file-history/changes?turn_id=1`, + }); + expect(changes.statusCode).toBe(200); + expect((changes.json() as Envelope<{ changes: unknown[] }>).data).toEqual({ changes: [] }); + + const content = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/file-history/content?turn_id=1&path=a.txt`, + }); + expect(content.statusCode).toBe(200); + expect((content.json() as Envelope<{ content: unknown }>).data).toEqual({ content: null }); + }); + + it('rejects a session that is not live', async () => { + const r = await boot(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions/does-not-exist/file-history/changes?turn_id=1', + }); + const envelope = res.json() as Envelope; + expect(envelope.code).not.toBe(0); + expect(envelope.data).toBeNull(); + }); + + it('rejects a malformed turn_id', async () => { + const r = await boot(); + const sessionId = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/file-history/changes?turn_id=abc`, + }); + const envelope = res.json() as Envelope; + expect(envelope.code).not.toBe(0); + }); +}); From b713069785cff54f4e9aecce5984e4532976c7e3 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 13:23:34 +0800 Subject: [PATCH 05/19] test(agent-core-v2): cover turn-level file history through real scripted turns Drive real Edit/Write tool executions through the scripted-generate agent harness with the flag enabled: overlapping edits in one turn produce a single exact per-turn diff, a Write-created file records a null v1 and an added change, and checkpoint content reads return the true byte-for-byte file states. --- .../features/fileHistory/fileHistory.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) 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 f0d1855faf7..e01e0ea99bc 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; @@ -10,7 +14,9 @@ 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 } from '#/features/fileHistory/fileHistoryService'; +import { FILE_HISTORY_FLAG_ENV } from '#/features/fileHistory/flag'; import type { ToolCall } from '#/kosong/contract/message'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; @@ -26,6 +32,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'; const SCOPE = 'wire'; const KEY = 'file-history-test'; @@ -273,3 +280,107 @@ describe('AgentFileHistoryService', () => { expect(service.history().tracked).toEqual(['/elsewhere/notes.md']); }); }); + +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'); + await writeFile(file, 'alpha\nbeta\n'); + const ctx = createTestAgent(); + try { + await ctx.rpc.setPermission({ mode: 'yolo' }); + + const editCall = (id: string, oldString: string, newString: string): ToolCall => ({ + type: 'function', + id, + name: 'Edit', + arguments: JSON.stringify({ path: file, old_string: oldString, new_string: newString }), + }); + const readCall: ToolCall = { + type: 'function', + id: 'call_r1', + name: 'Read', + arguments: JSON.stringify({ path: file }), + }; + ctx.mockNextResponse({ type: 'text', text: 'Reading.' }, readCall); + ctx.mockNextResponse({ type: 'text', text: 'First edit.' }, editCall('call_e1', 'beta', 'gamma')); + ctx.mockNextResponse({ type: 'text', text: 'Second edit.' }, editCall('call_e2', 'gamma', 'delta')); + ctx.mockNextResponse({ type: 'text', text: 'Done.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Edit the file twice' }] }); + await ctx.untilTurnEnd(); + + ctx.mockNextResponse({ type: 'text', text: 'Nothing else.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Thanks' }] }); + await ctx.untilTurnEnd(); + + const service = ctx.get(IAgentFileHistoryService); + await service.settled(); + expect(await readFile(file, 'utf8')).toBe('alpha\ndelta\n'); + + const state = service.history(); + expect(state.tracked).toEqual([file]); + const checkpoint1 = state.checkpoints.find((c) => c.turnId === 0); + const checkpoint2 = state.checkpoints.find((c) => c.turnId === 1); + expect(checkpoint1?.entries[file]?.version).toBe(1); + expect(checkpoint2?.entries[file]?.version).toBe(2); + + expect((await service.contentAt(0, file))?.content).toBe('alpha\nbeta\n'); + expect((await service.contentAt(1, file))?.content).toBe('alpha\ndelta\n'); + + expect(await service.changes(0)).toEqual([ + { path: file, status: 'modified', additions: 1, deletions: 1 }, + ]); + expect(await service.changes(1)).toEqual([]); + } finally { + await ctx.dispose(); + await rm(dir, { recursive: true, force: true }); + } + }); + + it('records a Write-created file as added with its real content', async () => { + const dir = await mkdtemp(join(tmpdir(), 'file-history-e2e-')); + const file = join(dir, 'fresh.txt'); + const ctx = createTestAgent(); + try { + await ctx.rpc.setPermission({ mode: 'yolo' }); + + const writeCall: ToolCall = { + type: 'function', + id: 'call_w1', + name: 'Write', + arguments: JSON.stringify({ path: file, content: 'one\ntwo\nthree\n' }), + }; + ctx.mockNextResponse({ type: 'text', text: 'Writing.' }, writeCall); + ctx.mockNextResponse({ type: 'text', text: 'Done.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Create the file' }] }); + await ctx.untilTurnEnd(); + + ctx.mockNextResponse({ type: 'text', text: 'Idle.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Thanks' }] }); + await ctx.untilTurnEnd(); + + const service = ctx.get(IAgentFileHistoryService); + await service.settled(); + + const state = service.history(); + expect(state.checkpoints.find((c) => c.turnId === 0)?.entries[file]).toEqual({ + key: null, + version: 1, + }); + expect(await service.changes(0)).toEqual([ + { path: file, status: 'added', additions: 3, deletions: 0 }, + ]); + } finally { + await ctx.dispose(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); From 7494bbdf379b0f3d5fc12fc2dbf4b743418b3930 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 13:43:47 +0800 Subject: [PATCH 06/19] fix(agent-core-v2): address file-history review findings Read capture and checkpoint content through the agent's active runtime filesystem (matching how Edit/Write execute) instead of the daemon host filesystem, so remote-runtime edits snapshot the right files. Guard the changes/contentAt reads behind the experimental flag so replay-restored checkpoints stay hidden while the feature is off. Count line multiplicity in the over-budget diff approximation so repetitive large files can no longer produce negative addition counts. --- .../fileHistory/fileHistoryService.ts | 48 +++++++++++++------ .../features/fileHistory/fileHistory.test.ts | 38 ++++++++++++++- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index f289621cf6c..b6490737aff 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -4,6 +4,7 @@ import { isAbsolute, relative, resolve } from 'pathe'; import { Service } from '#/_base/di/service'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -11,7 +12,6 @@ import type { WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { IEventBus } from '#/app/event/eventBus'; import { IFlagService } from '#/app/flag/flag'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -49,7 +49,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor @IEventBus eventBus: IEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IFlagService private readonly flags: IFlagService, - @IHostFileSystem private readonly fs: IHostFileSystem, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @IBlobStore private readonly blobs: IBlobStore, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, ) { @@ -81,6 +81,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } async changes(turnId: number): Promise { + if (!this.enabled()) return []; await this.settled(); const state = this.history(); const index = state.checkpoints.findIndex((c) => c.turnId === turnId); @@ -111,6 +112,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } async contentAt(turnId: number, path: string): Promise { + if (!this.enabled()) return undefined; await this.settled(); const state = this.history(); const index = state.checkpoints.findIndex((c) => c.turnId === turnId); @@ -229,18 +231,25 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private async readCurrent(pathKey: string): Promise { const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); - let info; + const lease = this.runtime.acquire(['fs']); try { - info = await this.fs.stat(absolute); - } catch (error) { - const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; - return code === 'ENOENT' ? 'missing' : 'unreadable'; - } - if (!info.isFile || info.size > FILE_HISTORY_MAX_FILE_BYTES) return 'unreadable'; - try { - return await this.fs.readBytes(absolute); - } catch { - return 'unreadable'; + const fs = lease.runtime.fs; + if (fs === undefined) return 'unreadable'; + let info; + try { + info = await fs.stat(absolute); + } catch (error) { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + return code === 'ENOENT' ? 'missing' : 'unreadable'; + } + if (!info.isFile || info.size > FILE_HISTORY_MAX_FILE_BYTES) return 'unreadable'; + try { + return await fs.readBytes(absolute); + } catch { + return 'unreadable'; + } + } finally { + lease.dispose(); } } @@ -402,8 +411,17 @@ const LCS_CELL_BUDGET = 4_000_000; function lcsLength(a: readonly string[], b: readonly string[]): number { if (a.length === 0 || b.length === 0) return 0; if (a.length * b.length > LCS_CELL_BUDGET) { - const bSet = new Set(b); - return a.filter((line) => bSet.has(line)).length; + const remaining = new Map(); + for (const line of b) remaining.set(line, (remaining.get(line) ?? 0) + 1); + let common = 0; + for (const line of a) { + const left = remaining.get(line) ?? 0; + if (left > 0) { + common += 1; + remaining.set(line, left - 1); + } + } + return common; } let previous = new Uint32Array(b.length + 1); let current = new Uint32Array(b.length + 1); 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 e01e0ea99bc..b298dc8e259 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -15,9 +15,10 @@ 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 } from '#/features/fileHistory/fileHistoryService'; +import { AgentFileHistoryService, countLineDiff } from '#/features/fileHistory/fileHistoryService'; 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'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; @@ -79,6 +80,12 @@ describe('AgentFileHistoryService', () => { disposables.dispose(); }); + function stubRuntime(): IAgentRuntimeService { + return { + acquire: () => ({ runtime: { fs: hostFs() }, dispose: () => {} }), + } as unknown as IAgentRuntimeService; + } + function hostFs(): IHostFileSystem { return createFakeHostFs({ stat: async (path: string) => { @@ -112,7 +119,7 @@ describe('AgentFileHistoryService', () => { eventBus, ix.get(IEventDispatcher), flags, - hostFs(), + stubRuntime(), blobs, workspace, ), @@ -259,6 +266,33 @@ describe('AgentFileHistoryService', () => { expect(state.tracked).toEqual([]); }); + 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'); + 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('keeps over-budget diff approximations non-negative on repetitive files', () => { + const before = [...Array.from({ length: 3000 }, () => 'dup'), 'end-old'].join('\n'); + const after = ['start-new', ...Array.from({ length: 2100 }, () => 'dup')].join('\n'); + const diff = countLineDiff(before, after); + expect(diff.additions).toBe(1); + expect(diff.deletions).toBe(901); + }); + it('stays inactive on subagents', async () => { const service = createService('sub-1'); setFile('/ws/a.txt', 'content\n'); From fce5febb79c1b059d0b8d269abbfdb31ab23939b Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 14:49:09 +0800 Subject: [PATCH 07/19] feat(agent-core-v2): checkpoint at turn end for exact per-turn attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take a second checkpoint on TurnEnded so a turn's changes pair its start checkpoint with its own end state instead of the next turn's start — edits the user makes between turns no longer count into the previous turn's diff. The checkpoint event gains an optional phase (persisted records without one replay as start), contentAt accepts a phase to read either boundary, and the checkpoint window doubles to keep the same turn depth. --- .../agent-core-v2/docs/state-manifest.d.ts | 1 + .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../src/features/fileHistory/fileHistory.ts | 9 ++++- .../features/fileHistory/fileHistoryOps.ts | 29 ++++++++++++---- .../fileHistory/fileHistoryService.ts | 33 +++++++++++++++---- .../features/fileHistory/fileHistory.test.ts | 31 +++++++++++++++++ .../src/protocol/rest-file-history.ts | 1 + packages/kap-server/src/routes/fileHistory.ts | 2 +- .../test/services/transcript.test.ts | 2 +- .../klient/src/contract/agent/services.ts | 2 +- packages/klient/src/core/facade/agent.ts | 16 ++++++--- 11 files changed, 106 insertions(+), 21 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index b01131a6c63..90305822e48 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1499,6 +1499,7 @@ export interface AgentStateSnapshot { 'fileHistory': /* FileHistoryState — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { readonly checkpoints: readonly /* FileHistoryCheckpointRecord — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { readonly turnId: number; + readonly phase?: 'start' | 'end'; readonly entries: Readonly; } diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index 35d3d07d485..5189424b8c9 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -7,8 +7,11 @@ export interface FileBackupEntry { readonly size?: number; } +export type FileHistoryCheckpointPhase = 'start' | 'end'; + export interface FileHistoryCheckpointRecord { readonly turnId: number; + readonly phase?: FileHistoryCheckpointPhase; readonly entries: Readonly>; } @@ -40,7 +43,11 @@ export interface IAgentFileHistoryService { history(): FileHistoryState; settled(): Promise; changes(turnId: number): Promise; - contentAt(turnId: number, path: string): Promise; + contentAt( + turnId: number, + path: string, + phase?: FileHistoryCheckpointPhase, + ): Promise; } export const IAgentFileHistoryService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts index 3b05bbeddd2..56ea13ba060 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -4,9 +4,13 @@ import { z } from 'zod'; import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; -import type { FileBackupEntry, FileHistoryState } from './fileHistory'; +import type { + FileBackupEntry, + FileHistoryCheckpointPhase, + FileHistoryState, +} from './fileHistory'; -export const FILE_HISTORY_CHECKPOINT_CAP = 200; +export const FILE_HISTORY_CHECKPOINT_CAP = 400; const backupEntrySchema = z.object({ key: z.string().nullable(), @@ -38,6 +42,7 @@ export interface FileHistoryTracked { const fileHistoryCheckpointedSchema = z.object({ agentId: z.string(), turnId: z.number(), + phase: z.enum(['start', 'end']).optional(), entries: z.record(z.string(), backupEntrySchema), }); @@ -52,30 +57,42 @@ export class FileHistoryCheckpointed extends AgentEvent2< export interface FileHistoryCheckpointed { readonly agentId: string; readonly turnId: number; + readonly phase?: FileHistoryCheckpointPhase; readonly entries: Readonly>; } +export function checkpointPhaseOf(record: { + readonly phase?: FileHistoryCheckpointPhase; +}): FileHistoryCheckpointPhase { + return record.phase ?? 'start'; +} + export const fileHistoryKey = defineState( 'fileHistory', (): FileHistoryState => ({ checkpoints: [], tracked: [] }), ) .replayable({ schema: z.custom() }) .on(FileHistoryCheckpointed, (s, e) => { - const existing = s.checkpoints.find((c) => c.turnId === e.turnId); + const phase = checkpointPhaseOf(e); + const existing = s.checkpoints.find( + (c) => c.turnId === e.turnId && checkpointPhaseOf(c) === phase, + ); if (existing !== undefined) { existing.entries = { ...e.entries }; return; } - s.checkpoints.push({ turnId: e.turnId, entries: { ...e.entries } }); + s.checkpoints.push({ turnId: e.turnId, phase, entries: { ...e.entries } }); if (s.checkpoints.length > FILE_HISTORY_CHECKPOINT_CAP) { s.checkpoints.splice(0, s.checkpoints.length - FILE_HISTORY_CHECKPOINT_CAP); } }) .on(FileHistoryTracked, (s, e) => { if (!s.tracked.includes(e.path)) s.tracked.push(e.path); - let checkpoint = s.checkpoints.find((c) => c.turnId === e.turnId); + let checkpoint = s.checkpoints.find( + (c) => c.turnId === e.turnId && checkpointPhaseOf(c) === 'start', + ); if (checkpoint === undefined) { - s.checkpoints.push({ turnId: e.turnId, entries: {} }); + s.checkpoints.push({ turnId: e.turnId, phase: 'start', entries: {} }); checkpoint = s.checkpoints.at(-1); } if (checkpoint !== undefined && checkpoint.entries[e.path] === undefined) { diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index b6490737aff..29b341562f6 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -10,6 +10,7 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks'; import { TurnStarted } from '#/agent/loop/turnEvents'; +import { TurnEnded } from '#/agent/loop/turnOps'; import { IEventBus } from '#/app/event/eventBus'; import { IFlagService } from '#/app/flag/flag'; import { IBlobStore } from '#/persistence/interface/blobStore'; @@ -22,6 +23,7 @@ import { IAgentFileHistoryService, type FileBackupEntry, type FileHistoryChange, + type FileHistoryCheckpointPhase, type FileHistoryCheckpointRecord, type FileHistoryContent, type FileHistoryState, @@ -30,6 +32,7 @@ import { FILE_HISTORY_CHECKPOINT_CAP, FileHistoryCheckpointed, FileHistoryTracked, + checkpointPhaseOf, fileHistoryKey, } from './fileHistoryOps'; import { FILE_HISTORY_FLAG_ID } from './flag'; @@ -63,7 +66,13 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor this._register( eventBus.subscribe(TurnStarted, (event) => { if (event.agentId !== this.agentCtx.agentId || !this.enabled()) return; - void this.enqueue(() => this.checkpoint(event.turnId)); + void this.enqueue(() => this.checkpoint(event.turnId, 'start')); + }), + ); + this._register( + eventBus.subscribe(TurnEnded, (event) => { + if (event.agentId !== this.agentCtx.agentId || !this.enabled()) return; + void this.enqueue(() => this.checkpoint(event.turnId, 'end')); }), ); } @@ -84,7 +93,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor if (!this.enabled()) return []; await this.settled(); const state = this.history(); - const index = state.checkpoints.findIndex((c) => c.turnId === turnId); + const index = state.checkpoints.findIndex( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', + ); if (index < 0) return []; const next = state.checkpoints[index + 1]; @@ -111,11 +122,17 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return changes; } - async contentAt(turnId: number, path: string): Promise { + async contentAt( + turnId: number, + path: string, + phase: FileHistoryCheckpointPhase = 'start', + ): Promise { if (!this.enabled()) return undefined; await this.settled(); const state = this.history(); - const index = state.checkpoints.findIndex((c) => c.turnId === turnId); + const index = state.checkpoints.findIndex( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === phase, + ); if (index < 0) return undefined; const entry = entryAt(state.checkpoints, index, this.pathKey(path)); if (entry === undefined) return undefined; @@ -156,9 +173,11 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor ); } - private async checkpoint(turnId: number): Promise { + private async checkpoint(turnId: number, phase: FileHistoryCheckpointPhase): Promise { const state = this.history(); - if (state.checkpoints.some((c) => c.turnId === turnId)) return; + if (state.checkpoints.some((c) => c.turnId === turnId && checkpointPhaseOf(c) === phase)) { + return; + } const entries: Record = {}; for (const pathKey of state.tracked) { @@ -184,7 +203,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const evictable = evictableCheckpoints(state.checkpoints); await this.dispatcher.dispatch( - new FileHistoryCheckpointed({ agentId: this.agentCtx.agentId, turnId, entries }), + new FileHistoryCheckpointed({ agentId: this.agentCtx.agentId, turnId, phase, entries }), ); await this.evictBlobs(evictable, this.history().checkpoints); } 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 b298dc8e259..fd9c59a6ed8 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -10,6 +10,7 @@ import { TestInstantiationService } from '#/_base/di/test'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { TurnStarted } from '#/agent/loop/turnEvents'; +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'; @@ -151,6 +152,13 @@ describe('AgentFileHistoryService', () => { ); } + function endTurn(turnId: number): void { + eventBus.publish( + new TurnEnded({ agentId: 'main', turnId, reason: 'completed' }), + scopeCtx.agentContext, + ); + } + async function blobText(key: string): Promise { const bytes = await blobs.get(scopeCtx.scope(), key); return bytes === undefined ? undefined : decoder.decode(bytes); @@ -266,6 +274,29 @@ describe('AgentFileHistoryService', () => { expect(state.tracked).toEqual([]); }); + it('excludes user edits between turns via the end-of-turn checkpoint', async () => { + const service = createService(); + setFile('/ws/a.txt', 'alpha\n'); + + startTurn(1); + await fireEdit(service, '/ws/a.txt', 1); + setFile('/ws/a.txt', 'alpha\nagent\n'); + endTurn(1); + await service.settled(); + + setFile('/ws/a.txt', 'alpha\nagent\nuser\n'); + startTurn(2); + endTurn(2); + await service.settled(); + + expect(await service.changes(1)).toEqual([ + { path: 'a.txt', status: 'modified', additions: 1, deletions: 0 }, + ]); + expect(await service.changes(2)).toEqual([]); + expect((await service.contentAt(1, 'a.txt', 'end'))?.content).toBe('alpha\nagent\n'); + expect((await service.contentAt(2, 'a.txt'))?.content).toBe('alpha\nagent\nuser\n'); + }); + it('guards reads once the flag is turned off after data was recorded', async () => { const service = createService(); setFile('/ws/a.txt', 'content\n'); diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts index 3a9e2281a16..43de6ed1fbc 100644 --- a/packages/kap-server/src/protocol/rest-file-history.ts +++ b/packages/kap-server/src/protocol/rest-file-history.ts @@ -8,6 +8,7 @@ export type FileHistoryChangesQuery = z.infer; diff --git a/packages/kap-server/src/routes/fileHistory.ts b/packages/kap-server/src/routes/fileHistory.ts index 85bcbf82fb4..b76177d33e0 100644 --- a/packages/kap-server/src/routes/fileHistory.ts +++ b/packages/kap-server/src/routes/fileHistory.ts @@ -89,7 +89,7 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope } const agent = await ensureMainAgent(session); const history = agent.accessor.get(IAgentFileHistoryService); - const content = await history.contentAt(req.query.turn_id, req.query.path); + const content = await history.contentAt(req.query.turn_id, req.query.path, req.query.phase); reply.send(okEnvelope({ content: content ?? null }, req.id)); }, ); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 22d2121d7c6..31052268dfe 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1427,7 +1427,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects file_history events as markers with their manifest payloads', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply( diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index eb4a84f1154..88fe2bedb04 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -107,7 +107,7 @@ export const agentFileHistoryContract = { enabled: { input: z.tuple([]), output: z.boolean() }, changes: { input: z.tuple([z.number()]), output: z.array(fileHistoryChangeSchema) }, contentAt: { - input: z.tuple([z.number(), z.string()]), + input: z.tuple([z.number(), z.string(), z.enum(['start', 'end']).optional()]), output: maybe(fileHistoryContentSchema), }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index 45dee1d3d3e..cacacb78ad3 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -122,6 +122,10 @@ export interface AgentFacade { getFileContentAt(input: { turnId: number; path: string; + /** Which of the turn's two checkpoints to read (default 'start'): 'start' + is the file before the turn's edits, 'end' the file as the turn left + it. */ + phase?: 'start' | 'end'; }): Promise; } @@ -205,10 +209,14 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac readonly FileHistoryChange[] >, getFileContentAt: async (input) => { - const result = (await call(scope, 'agentFileHistoryService', 'contentAt', [ - input.turnId, - input.path, - ])) as FileHistoryContent | null | undefined; + const args: unknown[] = + input.phase === undefined + ? [input.turnId, input.path] + : [input.turnId, input.path, input.phase]; + const result = (await call(scope, 'agentFileHistoryService', 'contentAt', args)) as + | FileHistoryContent + | null + | undefined; return result ?? undefined; }, }; From ca01d1e2f5bdcc868affebea2db49fb73e7891a1 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 15:31:56 +0800 Subject: [PATCH 08/19] fix(agent-core-v2): harden file-history checkpoints per review Delta-encode the durable checkpoint events (unchanged entries fold back in from the previous checkpoint) so wire growth stops scaling with turns times tracked files. Make the over-budget diff approximation order-aware (greedy common subsequence) so reordered large files no longer report zero-change stats. Store and read path-keyed entries prototype-free so filenames like __proto__ record correctly. --- .../features/fileHistory/fileHistoryOps.ts | 20 +++++++-- .../fileHistory/fileHistoryService.ts | 43 +++++++++++-------- .../features/fileHistory/fileHistory.test.ts | 9 ++++ 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts index 56ea13ba060..bd3f9a2459d 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -67,6 +67,17 @@ export function checkpointPhaseOf(record: { return record.phase ?? 'start'; } +function cloneEntries( + entries: Readonly>, +): Record { + const clone: Record = Object.create(null) as Record< + string, + FileBackupEntry + >; + for (const [path, entry] of Object.entries(entries)) clone[path] = entry; + return clone; +} + export const fileHistoryKey = defineState( 'fileHistory', (): FileHistoryState => ({ checkpoints: [], tracked: [] }), @@ -74,14 +85,17 @@ export const fileHistoryKey = defineState( .replayable({ schema: z.custom() }) .on(FileHistoryCheckpointed, (s, e) => { const phase = checkpointPhaseOf(e); + const base = s.checkpoints.at(-1)?.entries; + const merged = cloneEntries(base ?? {}); + for (const [path, entry] of Object.entries(e.entries)) merged[path] = { ...entry }; const existing = s.checkpoints.find( (c) => c.turnId === e.turnId && checkpointPhaseOf(c) === phase, ); if (existing !== undefined) { - existing.entries = { ...e.entries }; + existing.entries = merged; return; } - s.checkpoints.push({ turnId: e.turnId, phase, entries: { ...e.entries } }); + s.checkpoints.push({ turnId: e.turnId, phase, entries: merged }); if (s.checkpoints.length > FILE_HISTORY_CHECKPOINT_CAP) { s.checkpoints.splice(0, s.checkpoints.length - FILE_HISTORY_CHECKPOINT_CAP); } @@ -95,7 +109,7 @@ export const fileHistoryKey = defineState( s.checkpoints.push({ turnId: e.turnId, phase: 'start', entries: {} }); checkpoint = s.checkpoints.at(-1); } - if (checkpoint !== undefined && checkpoint.entries[e.path] === undefined) { + if (checkpoint !== undefined && !Object.hasOwn(checkpoint.entries, e.path)) { checkpoint.entries[e.path] = { ...e.entry }; } }); diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 29b341562f6..47f5a5a828d 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -179,25 +179,21 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return; } - const entries: Record = {}; + const entries: Record = Object.create(null) as Record< + string, + FileBackupEntry + >; for (const pathKey of state.tracked) { const latest = latestEntry(state.checkpoints, pathKey); const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; const current = await this.readCurrent(pathKey); - if (current === 'unreadable') { - if (latest !== undefined) entries[pathKey] = latest; - continue; - } + if (current === 'unreadable') continue; if (current === 'missing') { - entries[pathKey] = - latest?.key === null ? latest : { key: null, version: nextVersion }; + if (latest?.key !== null) entries[pathKey] = { key: null, version: nextVersion }; continue; } const contentHash = sha256(current); - if (latest !== undefined && latest.contentHash === contentHash) { - entries[pathKey] = latest; - continue; - } + if (latest !== undefined && latest.contentHash === contentHash) continue; entries[pathKey] = await this.backup(pathKey, nextVersion, current, contentHash); } @@ -292,8 +288,8 @@ function entryAt( path: string, ): FileBackupEntry | undefined { for (let i = index; i >= 0; i -= 1) { - const entry = checkpoints[i]!.entries[path]; - if (entry !== undefined) return entry; + const record = checkpoints[i]!.entries; + if (Object.hasOwn(record, path)) return record[path]; } return undefined; } @@ -311,7 +307,7 @@ function maxVersion( ): number { let max = 0; for (const checkpoint of checkpoints) { - const entry = checkpoint.entries[path]; + const entry = Object.hasOwn(checkpoint.entries, path) ? checkpoint.entries[path] : undefined; if (entry !== undefined && entry.version > max) max = entry.version; } return max; @@ -430,14 +426,23 @@ const LCS_CELL_BUDGET = 4_000_000; function lcsLength(a: readonly string[], b: readonly string[]): number { if (a.length === 0 || b.length === 0) return 0; if (a.length * b.length > LCS_CELL_BUDGET) { - const remaining = new Map(); - for (const line of b) remaining.set(line, (remaining.get(line) ?? 0) + 1); let common = 0; + const positions = new Map(); + for (let j = b.length - 1; j >= 0; j -= 1) { + const line = b[j]!; + const list = positions.get(line); + if (list === undefined) positions.set(line, [j]); + else list.push(j); + } + let cursor = 0; for (const line of a) { - const left = remaining.get(line) ?? 0; - if (left > 0) { + const list = positions.get(line); + if (list === undefined) continue; + while (list.length > 0 && list[list.length - 1]! < cursor) list.pop(); + const match = list.pop(); + if (match !== undefined) { common += 1; - remaining.set(line, left - 1); + cursor = match + 1; } } return common; 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 fd9c59a6ed8..f6c6d18d37e 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -324,6 +324,15 @@ describe('AgentFileHistoryService', () => { expect(diff.deletions).toBe(901); }); + it('keeps over-budget diff approximations order-aware on reordered files', () => { + const lines = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`); + const before = [...lines, 'tail-old'].join('\n'); + const after = ['head-new', ...lines.toReversed()].join('\n'); + const diff = countLineDiff(before, after); + expect(diff.additions).toBeGreaterThan(2000); + expect(diff.deletions).toBeGreaterThan(2000); + }); + it('stays inactive on subagents', async () => { const service = createService('sub-1'); setFile('/ws/a.txt', 'content\n'); From 3e015341563f914e96c10c305f6663ebe3f2b369 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 15:50:52 +0800 Subject: [PATCH 09/19] fix(agent-core-v2): map file-history reads through the runtime workspace Resolve relative snapshot keys against the acquired runtime's mapped workspace root (mapRoots) instead of the daemon host workDir, and treat host-absolute keys as unreadable on a relocated runtime so remote sessions cannot record host-path deletions. Strengthen the over-budget diff approximation with a unique-line LIS anchor pass (max of both legal common-subsequence bounds), fixing rotations that the greedy matcher collapsed to a single common line. --- .../fileHistory/fileHistoryService.ts | 88 ++++++++++++++----- .../features/fileHistory/fileHistory.test.ts | 20 ++++- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 47f5a5a828d..4ce5196aeae 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -245,11 +245,23 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } private async readCurrent(pathKey: string): Promise { - const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); const lease = this.runtime.acquire(['fs']); try { const fs = lease.runtime.fs; if (fs === undefined) return 'unreadable'; + const runtime = lease.runtime; + const hostWorkDir = resolve(this.workspaceCtx.workDir); + const mappedWorkDir = runtime.path.resolve( + runtime.workspace.mapRoots({ workDir: this.workspaceCtx.workDir, additionalDirs: [] }) + .workDir, + ); + let absolute: string; + if (isAbsolute(pathKey)) { + if (mappedWorkDir !== hostWorkDir) return 'unreadable'; + absolute = pathKey; + } else { + absolute = runtime.path.resolve(mappedWorkDir, pathKey); + } let info; try { info = await fs.stat(absolute); @@ -423,29 +435,63 @@ export function countLineDiff( const LCS_CELL_BUDGET = 4_000_000; +function greedyCommonLength(a: readonly string[], b: readonly string[]): number { + let common = 0; + const positions = new Map(); + for (let j = b.length - 1; j >= 0; j -= 1) { + const line = b[j]!; + const list = positions.get(line); + if (list === undefined) positions.set(line, [j]); + else list.push(j); + } + let cursor = 0; + for (const line of a) { + const list = positions.get(line); + if (list === undefined) continue; + while (list.length > 0 && list[list.length - 1]! < cursor) list.pop(); + const match = list.pop(); + if (match !== undefined) { + common += 1; + cursor = match + 1; + } + } + return common; +} + +function uniqueAnchorCommonLength(a: readonly string[], b: readonly string[]): number { + const countIn = (lines: readonly string[]): Map => { + const counts = new Map(); + for (const line of lines) counts.set(line, (counts.get(line) ?? 0) + 1); + return counts; + }; + const aCounts = countIn(a); + const bPosition = new Map(); + const bCounts = countIn(b); + for (let j = 0; j < b.length; j += 1) { + const line = b[j]!; + if (bCounts.get(line) === 1 && aCounts.get(line) === 1) bPosition.set(line, j); + } + const sequence: number[] = []; + for (const line of a) { + const j = bPosition.get(line); + if (j === undefined) continue; + let low = 0; + let high = sequence.length; + while (low < high) { + const mid = (low + high) >> 1; + if (sequence[mid]! < j) low = mid + 1; + else high = mid; + } + sequence[low] = j; + } + return sequence.length; +} + + function lcsLength(a: readonly string[], b: readonly string[]): number { if (a.length === 0 || b.length === 0) return 0; if (a.length * b.length > LCS_CELL_BUDGET) { - let common = 0; - const positions = new Map(); - for (let j = b.length - 1; j >= 0; j -= 1) { - const line = b[j]!; - const list = positions.get(line); - if (list === undefined) positions.set(line, [j]); - else list.push(j); - } - let cursor = 0; - for (const line of a) { - const list = positions.get(line); - if (list === undefined) continue; - while (list.length > 0 && list[list.length - 1]! < cursor) list.pop(); - const match = list.pop(); - if (match !== undefined) { - common += 1; - cursor = match + 1; - } - } - return common; + return Math.max(greedyCommonLength(a, b), uniqueAnchorCommonLength(a, b)); } let previous = new Uint32Array(b.length + 1); let current = new Uint32Array(b.length + 1); 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 f6c6d18d37e..dc7a7933d61 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, posix } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -83,7 +83,14 @@ describe('AgentFileHistoryService', () => { function stubRuntime(): IAgentRuntimeService { return { - acquire: () => ({ runtime: { fs: hostFs() }, dispose: () => {} }), + acquire: () => ({ + runtime: { + fs: hostFs(), + path: posix, + workspace: { mapRoots: (roots: unknown) => roots }, + }, + dispose: () => {}, + }), } as unknown as IAgentRuntimeService; } @@ -324,6 +331,15 @@ describe('AgentFileHistoryService', () => { expect(diff.deletions).toBe(901); }); + it('keeps over-budget diff approximations accurate on rotations', () => { + const body = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`); + const before = ['moved', ...body].join('\n'); + const after = [...body, 'moved'].join('\n'); + const diff = countLineDiff(before, after); + expect(diff.additions).toBe(1); + expect(diff.deletions).toBe(1); + }); + it('keeps over-budget diff approximations order-aware on reordered files', () => { const lines = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`); const before = [...lines, 'tail-old'].join('\n'); From d5b8638e06d7278eeee670f3d678336fda918922 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:00:54 +0800 Subject: [PATCH 10/19] fix(agent-core-v2): report over-budget file pairs as oversize instead of approximating Drop the ordered-greedy and unique-anchor approximations: when a modified pair exceeds the exact-LCS budget the change now carries an oversize flag with no counts, so consumers show the file without fabricated stats rather than numbers a bounded approximation can get badly wrong. --- .../src/features/fileHistory/fileHistory.ts | 1 + .../fileHistory/fileHistoryService.ts | 64 ++----------------- .../features/fileHistory/fileHistory.test.ts | 40 ++++++------ .../src/protocol/rest-file-history.ts | 1 + packages/klient/src/contract/agent/schemas.ts | 1 + 5 files changed, 30 insertions(+), 77 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index 5189424b8c9..86697d82502 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -28,6 +28,7 @@ export interface FileHistoryChange { readonly additions: number; readonly deletions: number; readonly binary?: boolean; + readonly oversize?: boolean; } export interface FileHistoryContent { diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 4ce5196aeae..38eb90fd484 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -377,8 +377,11 @@ function diffChange( : { path, status: 'modified', additions: 0, deletions: 0, binary }; } if (before === after) return undefined; - const { additions, deletions } = countLineDiff(before ?? '', after ?? ''); - return { path, status: 'modified', additions, deletions }; + const counted = countLineDiff(before ?? '', after ?? ''); + if (counted === undefined) { + return { path, status: 'modified', additions: 0, deletions: 0, oversize: true }; + } + return { path, status: 'modified', additions: counted.additions, deletions: counted.deletions }; } function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { @@ -401,7 +404,7 @@ function countLines(content: string): number { export function countLineDiff( before: string, after: string, -): { additions: number; deletions: number } { +): { additions: number; deletions: number } | undefined { const beforeLines = splitLines(before); const afterLines = splitLines(after); @@ -426,6 +429,7 @@ export function countLineDiff( const oldSlice = beforeLines.slice(start, beforeEnd); const newSlice = afterLines.slice(start, afterEnd); + if (oldSlice.length * newSlice.length > LCS_CELL_BUDGET) return undefined; const common = lcsLength(oldSlice, newSlice); return { additions: newSlice.length - common, @@ -435,64 +439,10 @@ export function countLineDiff( const LCS_CELL_BUDGET = 4_000_000; -function greedyCommonLength(a: readonly string[], b: readonly string[]): number { - let common = 0; - const positions = new Map(); - for (let j = b.length - 1; j >= 0; j -= 1) { - const line = b[j]!; - const list = positions.get(line); - if (list === undefined) positions.set(line, [j]); - else list.push(j); - } - let cursor = 0; - for (const line of a) { - const list = positions.get(line); - if (list === undefined) continue; - while (list.length > 0 && list[list.length - 1]! < cursor) list.pop(); - const match = list.pop(); - if (match !== undefined) { - common += 1; - cursor = match + 1; - } - } - return common; -} - -function uniqueAnchorCommonLength(a: readonly string[], b: readonly string[]): number { - const countIn = (lines: readonly string[]): Map => { - const counts = new Map(); - for (const line of lines) counts.set(line, (counts.get(line) ?? 0) + 1); - return counts; - }; - const aCounts = countIn(a); - const bPosition = new Map(); - const bCounts = countIn(b); - for (let j = 0; j < b.length; j += 1) { - const line = b[j]!; - if (bCounts.get(line) === 1 && aCounts.get(line) === 1) bPosition.set(line, j); - } - const sequence: number[] = []; - for (const line of a) { - const j = bPosition.get(line); - if (j === undefined) continue; - let low = 0; - let high = sequence.length; - while (low < high) { - const mid = (low + high) >> 1; - if (sequence[mid]! < j) low = mid + 1; - else high = mid; - } - sequence[low] = j; - } - return sequence.length; -} function lcsLength(a: readonly string[], b: readonly string[]): number { if (a.length === 0 || b.length === 0) return 0; - if (a.length * b.length > LCS_CELL_BUDGET) { - return Math.max(greedyCommonLength(a, b), uniqueAnchorCommonLength(a, b)); - } let previous = new Uint32Array(b.length + 1); let current = new Uint32Array(b.length + 1); for (let i = 1; i <= a.length; i += 1) { 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 dc7a7933d61..b7d4fca241e 100644 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts @@ -323,30 +323,30 @@ describe('AgentFileHistoryService', () => { expect(await service.contentAt(1, 'a.txt')).toBeUndefined(); }); - it('keeps over-budget diff approximations non-negative on repetitive files', () => { + it('reports an over-budget modified file as oversize with no counts', async () => { + const service = createService(); + const bigA = Array.from({ length: 2500 }, (_, i) => `a-${String(i)}`).join('\n'); + const bigB = Array.from({ length: 2500 }, (_, i) => `b-${String(i)}`).join('\n'); + setFile('/ws/big.txt', bigA); + + startTurn(1); + await fireEdit(service, '/ws/big.txt', 1); + setFile('/ws/big.txt', bigB); + startTurn(2); + await service.settled(); + + expect(await service.changes(1)).toEqual([ + { path: 'big.txt', status: 'modified', additions: 0, deletions: 0, oversize: true }, + ]); + }); + + it('declines to count over-budget file pairs instead of approximating', () => { const before = [...Array.from({ length: 3000 }, () => 'dup'), 'end-old'].join('\n'); const after = ['start-new', ...Array.from({ length: 2100 }, () => 'dup')].join('\n'); - const diff = countLineDiff(before, after); - expect(diff.additions).toBe(1); - expect(diff.deletions).toBe(901); - }); + expect(countLineDiff(before, after)).toBeUndefined(); - it('keeps over-budget diff approximations accurate on rotations', () => { const body = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`); - const before = ['moved', ...body].join('\n'); - const after = [...body, 'moved'].join('\n'); - const diff = countLineDiff(before, after); - expect(diff.additions).toBe(1); - expect(diff.deletions).toBe(1); - }); - - it('keeps over-budget diff approximations order-aware on reordered files', () => { - const lines = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`); - const before = [...lines, 'tail-old'].join('\n'); - const after = ['head-new', ...lines.toReversed()].join('\n'); - const diff = countLineDiff(before, after); - expect(diff.additions).toBeGreaterThan(2000); - expect(diff.deletions).toBeGreaterThan(2000); + expect(countLineDiff(['moved', ...body].join('\n'), [...body, 'moved'].join('\n'))).toBeUndefined(); }); it('stays inactive on subagents', async () => { diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts index 43de6ed1fbc..9cc03265a27 100644 --- a/packages/kap-server/src/protocol/rest-file-history.ts +++ b/packages/kap-server/src/protocol/rest-file-history.ts @@ -18,6 +18,7 @@ export const fileHistoryChangeSchema = z.object({ additions: z.number(), deletions: z.number(), binary: z.boolean().optional(), + oversize: z.boolean().optional(), }); export type WireFileHistoryChange = z.infer; diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts index a53dc81f6d6..bc8707ac27e 100644 --- a/packages/klient/src/contract/agent/schemas.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -238,6 +238,7 @@ export const fileHistoryChangeSchema = z.object({ additions: z.number(), deletions: z.number(), binary: z.boolean().optional(), + oversize: z.boolean().optional(), }); export const fileHistoryContentSchema = z.object({ From 314491baa0005f92f5a276f41338f0834cb70d05 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:12:48 +0800 Subject: [PATCH 11/19] chore: drop feature test files from this PR --- .../features/fileHistory/fileHistory.test.ts | 476 ------------------ packages/kap-server/test/fileHistory.test.ts | 124 ----- .../test/services/transcript.test.ts | 40 -- packages/klient/test/contract-parity.ts | 8 - packages/klient/test/helpers/conformance.ts | 22 - 5 files changed, 670 deletions(-) delete mode 100644 packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts delete mode 100644 packages/kap-server/test/fileHistory.test.ts diff --git a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts b/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts deleted file mode 100644 index b7d4fca241e..00000000000 --- a/packages/agent-core-v2/test/features/fileHistory/fileHistory.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, posix } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { TurnStarted } from '#/agent/loop/turnEvents'; -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 { 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'; -import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; -import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; -import { BlobStoreService } from '#/persistence/backends/node-fs/blobStoreService'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { IBlobStore } from '#/persistence/interface/blobStore'; -import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import type { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { IEventDispatcher } from '#/state/eventDispatcher'; -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'; - -const SCOPE = 'wire'; -const KEY = 'file-history-test'; -const WORK_DIR = '/ws'; - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -describe('AgentFileHistoryService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let executorEvents: ToolExecutorEventStubs; - let eventBus: IEventBus; - let blobs: IBlobStore; - let scopeCtx: IAgentScopeContext; - let files: Map; - let flagEnabled: boolean; - - beforeEach(() => { - disposables = new DisposableStore(); - ix = disposables.add(new TestInstantiationService()); - ix.stub(IFileSystemStorageService, new InMemoryStorageService()); - ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.set(IEventBus, new SyncDescriptor(EventBusService)); - registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { - log: ix.get(IAppendLogStore), - eventBus: ix.get(IEventBus), - }); - scopeCtx = makeAgentScopeContext({ agentId: 'main', agentScope: testWireScope(SCOPE, KEY) }); - ix.stub(IAgentScopeContext, scopeCtx); - registerTestEventDispatcher(ix); - eventBus = ix.get(IEventBus); - const sessionBus = eventBus as Partial; - if (typeof sessionBus.activateAgent === 'function') { - sessionBus.activateAgent(scopeCtx.agentContext); - } - executorEvents = stubToolExecutorEvents(); - blobs = new BlobStoreService(new InMemoryStorageService()); - files = new Map(); - flagEnabled = true; - }); - - afterEach(() => { - disposables.dispose(); - }); - - function stubRuntime(): IAgentRuntimeService { - return { - acquire: () => ({ - runtime: { - fs: hostFs(), - path: posix, - workspace: { mapRoots: (roots: unknown) => roots }, - }, - dispose: () => {}, - }), - } as unknown as IAgentRuntimeService; - } - - function hostFs(): IHostFileSystem { - return createFakeHostFs({ - stat: async (path: string) => { - const content = files.get(path); - if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - return { isFile: true, isDirectory: false, size: content.byteLength }; - }, - readBytes: async (path: string) => { - const content = files.get(path); - if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - return content; - }, - }); - } - - function createService(agentId = 'main'): AgentFileHistoryService { - const ctx = - agentId === scopeCtx.agentId - ? scopeCtx - : makeAgentScopeContext({ agentId, agentScope: testWireScope(SCOPE, KEY) }); - const flags = { enabled: () => flagEnabled } as unknown as IFlagService; - const workspace = { - workDir: WORK_DIR, - additionalDirs: [], - } as unknown as ISessionWorkspaceContext; - return disposables.add( - new AgentFileHistoryService( - ctx, - ix.get(IAgentStateService), - executorEvents.executor, - eventBus, - ix.get(IEventDispatcher), - flags, - stubRuntime(), - blobs, - workspace, - ), - ); - } - - function setFile(path: string, content: string): void { - files.set(path, encoder.encode(content)); - } - - async function fireEdit(service: AgentFileHistoryService, path: string, turnId: number): Promise { - const toolCall: ToolCall = { type: 'function', id: `call-${String(turnId)}`, name: 'Edit', arguments: null }; - const execution: RunnableToolExecution = { - approvalRule: 'Edit', - display: { kind: 'file_io', operation: 'edit', path }, - execute: async () => ({ output: '' }), - }; - await executorEvents.fireWillExecute( - { turnId, toolCall, execution, args: {} }, - new AbortController().signal, - ); - await service.settled(); - } - - function startTurn(turnId: number): void { - eventBus.publish( - new TurnStarted({ agentId: 'main', turnId, origin: USER_PROMPT_ORIGIN }), - scopeCtx.agentContext, - ); - } - - function endTurn(turnId: number): void { - eventBus.publish( - new TurnEnded({ agentId: 'main', turnId, reason: 'completed' }), - scopeCtx.agentContext, - ); - } - - async function blobText(key: string): Promise { - const bytes = await blobs.get(scopeCtx.scope(), key); - return bytes === undefined ? undefined : decoder.decode(bytes); - } - - it('backs up pre-edit content on first touch and versions changes at the next turn boundary', async () => { - const service = createService(); - setFile('/ws/a.txt', 'one\ntwo\n'); - - startTurn(1); - await fireEdit(service, '/ws/a.txt', 1); - - let state = service.history(); - expect(state.tracked).toEqual(['a.txt']); - const v1 = state.checkpoints.find((c) => c.turnId === 1)?.entries['a.txt']; - expect(v1?.version).toBe(1); - expect(await blobText(v1!.key!)).toBe('one\ntwo\n'); - - setFile('/ws/a.txt', 'one\nTWO\n'); - await fireEdit(service, '/ws/a.txt', 1); - state = service.history(); - expect(Object.values(state.checkpoints.find((c) => c.turnId === 1)!.entries)).toHaveLength(1); - - startTurn(2); - await service.settled(); - state = service.history(); - const v2 = state.checkpoints.find((c) => c.turnId === 2)?.entries['a.txt']; - expect(v2?.version).toBe(2); - expect(await blobText(v2!.key!)).toBe('one\nTWO\n'); - - expect(await service.changes(1)).toEqual([ - { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, - ]); - expect((await service.contentAt(1, 'a.txt'))?.content).toBe('one\ntwo\n'); - expect((await service.contentAt(2, '/ws/a.txt'))?.content).toBe('one\nTWO\n'); - }); - - it('merges overlapping edits within one turn into a single true diff', async () => { - const service = createService(); - setFile('/ws/a.txt', 'alpha\nbeta\n'); - - startTurn(1); - await fireEdit(service, '/ws/a.txt', 1); - setFile('/ws/a.txt', 'alpha\nbeta\ngamma\n'); - await fireEdit(service, '/ws/a.txt', 1); - setFile('/ws/a.txt', 'alpha\nGAMMA\n'); - await fireEdit(service, '/ws/a.txt', 1); - - startTurn(2); - await service.settled(); - - expect(await service.changes(1)).toEqual([ - { path: 'a.txt', status: 'modified', additions: 1, deletions: 1 }, - ]); - }); - - it('reuses the previous backup when a tracked file is unchanged at a turn boundary', async () => { - const service = createService(); - setFile('/ws/b.txt', 'stable\n'); - - startTurn(1); - await fireEdit(service, '/ws/b.txt', 1); - startTurn(2); - startTurn(3); - await service.settled(); - - const state = service.history(); - const entryAtTurn2 = state.checkpoints.find((c) => c.turnId === 2)?.entries['b.txt']; - const entryAtTurn3 = state.checkpoints.find((c) => c.turnId === 3)?.entries['b.txt']; - expect(entryAtTurn2?.version).toBe(1); - expect(entryAtTurn3?.version).toBe(1); - const keys = await blobs.list(scopeCtx.scope(), 'file-history/'); - expect(keys).toHaveLength(1); - expect(await service.changes(1)).toEqual([]); - }); - - it('records file creation and deletion across turns', async () => { - const service = createService(); - - startTurn(1); - await fireEdit(service, '/ws/new.txt', 1); - let entry = service.history().checkpoints.find((c) => c.turnId === 1)?.entries['new.txt']; - expect(entry).toEqual({ key: null, version: 1 }); - - setFile('/ws/new.txt', 'created\n'); - startTurn(2); - await service.settled(); - expect(await service.changes(1)).toEqual([ - { path: 'new.txt', status: 'added', additions: 1, deletions: 0 }, - ]); - - files.delete('/ws/new.txt'); - startTurn(3); - await service.settled(); - entry = service.history().checkpoints.find((c) => c.turnId === 3)?.entries['new.txt']; - expect(entry?.key).toBeNull(); - expect(await service.changes(2)).toEqual([ - { path: 'new.txt', status: 'deleted', additions: 0, deletions: 1 }, - ]); - }); - - 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(); - setFile('/ws/a.txt', 'alpha\n'); - - startTurn(1); - await fireEdit(service, '/ws/a.txt', 1); - setFile('/ws/a.txt', 'alpha\nagent\n'); - endTurn(1); - await service.settled(); - - setFile('/ws/a.txt', 'alpha\nagent\nuser\n'); - startTurn(2); - endTurn(2); - await service.settled(); - - expect(await service.changes(1)).toEqual([ - { path: 'a.txt', status: 'modified', additions: 1, deletions: 0 }, - ]); - expect(await service.changes(2)).toEqual([]); - expect((await service.contentAt(1, 'a.txt', 'end'))?.content).toBe('alpha\nagent\n'); - expect((await service.contentAt(2, 'a.txt'))?.content).toBe('alpha\nagent\nuser\n'); - }); - - 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'); - 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(); - const bigA = Array.from({ length: 2500 }, (_, i) => `a-${String(i)}`).join('\n'); - const bigB = Array.from({ length: 2500 }, (_, i) => `b-${String(i)}`).join('\n'); - setFile('/ws/big.txt', bigA); - - startTurn(1); - await fireEdit(service, '/ws/big.txt', 1); - setFile('/ws/big.txt', bigB); - startTurn(2); - await service.settled(); - - expect(await service.changes(1)).toEqual([ - { path: 'big.txt', status: 'modified', additions: 0, deletions: 0, oversize: true }, - ]); - }); - - it('declines to count over-budget file pairs instead of approximating', () => { - const before = [...Array.from({ length: 3000 }, () => 'dup'), 'end-old'].join('\n'); - const after = ['start-new', ...Array.from({ length: 2100 }, () => 'dup')].join('\n'); - expect(countLineDiff(before, after)).toBeUndefined(); - - const body = Array.from({ length: 3000 }, (_, i) => `line-${String(i)}`); - expect(countLineDiff(['moved', ...body].join('\n'), [...body, 'moved'].join('\n'))).toBeUndefined(); - }); - - it('stays inactive on subagents', async () => { - const service = createService('sub-1'); - setFile('/ws/a.txt', 'content\n'); - - startTurn(1); - await fireEdit(service, '/ws/a.txt', 1); - await service.settled(); - - expect(service.history().checkpoints).toEqual([]); - }); - - it('keeps files outside the workspace keyed by absolute path', async () => { - const service = createService(); - setFile('/elsewhere/notes.md', 'note\n'); - - startTurn(1); - await fireEdit(service, '/elsewhere/notes.md', 1); - - expect(service.history().tracked).toEqual(['/elsewhere/notes.md']); - }); -}); - -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'); - await writeFile(file, 'alpha\nbeta\n'); - const ctx = createTestAgent(); - try { - await ctx.rpc.setPermission({ mode: 'yolo' }); - - const editCall = (id: string, oldString: string, newString: string): ToolCall => ({ - type: 'function', - id, - name: 'Edit', - arguments: JSON.stringify({ path: file, old_string: oldString, new_string: newString }), - }); - const readCall: ToolCall = { - type: 'function', - id: 'call_r1', - name: 'Read', - arguments: JSON.stringify({ path: file }), - }; - ctx.mockNextResponse({ type: 'text', text: 'Reading.' }, readCall); - ctx.mockNextResponse({ type: 'text', text: 'First edit.' }, editCall('call_e1', 'beta', 'gamma')); - ctx.mockNextResponse({ type: 'text', text: 'Second edit.' }, editCall('call_e2', 'gamma', 'delta')); - ctx.mockNextResponse({ type: 'text', text: 'Done.' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Edit the file twice' }] }); - await ctx.untilTurnEnd(); - - ctx.mockNextResponse({ type: 'text', text: 'Nothing else.' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Thanks' }] }); - await ctx.untilTurnEnd(); - - const service = ctx.get(IAgentFileHistoryService); - await service.settled(); - expect(await readFile(file, 'utf8')).toBe('alpha\ndelta\n'); - - const state = service.history(); - expect(state.tracked).toEqual([file]); - const checkpoint1 = state.checkpoints.find((c) => c.turnId === 0); - const checkpoint2 = state.checkpoints.find((c) => c.turnId === 1); - expect(checkpoint1?.entries[file]?.version).toBe(1); - expect(checkpoint2?.entries[file]?.version).toBe(2); - - expect((await service.contentAt(0, file))?.content).toBe('alpha\nbeta\n'); - expect((await service.contentAt(1, file))?.content).toBe('alpha\ndelta\n'); - - expect(await service.changes(0)).toEqual([ - { path: file, status: 'modified', additions: 1, deletions: 1 }, - ]); - expect(await service.changes(1)).toEqual([]); - } finally { - await ctx.dispose(); - await rm(dir, { recursive: true, force: true }); - } - }); - - it('records a Write-created file as added with its real content', async () => { - const dir = await mkdtemp(join(tmpdir(), 'file-history-e2e-')); - const file = join(dir, 'fresh.txt'); - const ctx = createTestAgent(); - try { - await ctx.rpc.setPermission({ mode: 'yolo' }); - - const writeCall: ToolCall = { - type: 'function', - id: 'call_w1', - name: 'Write', - arguments: JSON.stringify({ path: file, content: 'one\ntwo\nthree\n' }), - }; - ctx.mockNextResponse({ type: 'text', text: 'Writing.' }, writeCall); - ctx.mockNextResponse({ type: 'text', text: 'Done.' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Create the file' }] }); - await ctx.untilTurnEnd(); - - ctx.mockNextResponse({ type: 'text', text: 'Idle.' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Thanks' }] }); - await ctx.untilTurnEnd(); - - const service = ctx.get(IAgentFileHistoryService); - await service.settled(); - - const state = service.history(); - expect(state.checkpoints.find((c) => c.turnId === 0)?.entries[file]).toEqual({ - key: null, - version: 1, - }); - expect(await service.changes(0)).toEqual([ - { path: file, status: 'added', additions: 3, deletions: 0 }, - ]); - } finally { - await ctx.dispose(); - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/kap-server/test/fileHistory.test.ts b/packages/kap-server/test/fileHistory.test.ts deleted file mode 100644 index 2b3582cb672..00000000000 --- a/packages/kap-server/test/fileHistory.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; - -let home: string; -let server: RunningServer | undefined; - -beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-file-history-')); -}); - -afterEach(async () => { - try { - await server?.close(); - } catch { - } - server = undefined; - rmSync(home, { recursive: true, force: true }); -}); - -async function boot(): Promise { - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '127.0.0.1', - port: 0, - homeDir: home, - logLevel: 'silent', - }); - return server; -} - -interface InjectResponse { - statusCode: number; - body: string; - json: () => unknown; -} - -interface AppLike { - inject: (req: unknown) => Promise; -} - -function appOf(r: RunningServer): AppLike { - const app = r.app as unknown as AppLike; - return { - inject(req: unknown): Promise { - const request = req as { headers?: Record }; - return app.inject({ - ...request, - headers: { - ...request.headers, - authorization: `Bearer ${r.authTokenService.getToken()}`, - }, - }); - }, - }; -} - -interface Envelope { - code: number; - msg: string; - data: T | null; -} - -async function createSession(r: RunningServer): Promise { - const res = await appOf(r).inject({ - method: 'POST', - url: '/api/v1/sessions', - payload: { metadata: { cwd: home } }, - headers: { 'content-type': 'application/json' }, - }); - const envelope = res.json() as Envelope<{ id: string }>; - if (envelope.code !== 0 || envelope.data === null) { - throw new Error(`failed to create session: ${res.body}`); - } - return envelope.data.id; -} - -describe('file history routes', () => { - it('serves empty changes and null content for a live session without history', async () => { - const r = await boot(); - const sessionId = await createSession(r); - - const changes = await appOf(r).inject({ - method: 'GET', - url: `/api/v1/sessions/${sessionId}/file-history/changes?turn_id=1`, - }); - expect(changes.statusCode).toBe(200); - expect((changes.json() as Envelope<{ changes: unknown[] }>).data).toEqual({ changes: [] }); - - const content = await appOf(r).inject({ - method: 'GET', - url: `/api/v1/sessions/${sessionId}/file-history/content?turn_id=1&path=a.txt`, - }); - expect(content.statusCode).toBe(200); - expect((content.json() as Envelope<{ content: unknown }>).data).toEqual({ content: null }); - }); - - it('rejects a session that is not live', async () => { - const r = await boot(); - const res = await appOf(r).inject({ - method: 'GET', - url: '/api/v1/sessions/does-not-exist/file-history/changes?turn_id=1', - }); - const envelope = res.json() as Envelope; - expect(envelope.code).not.toBe(0); - expect(envelope.data).toBeNull(); - }); - - it('rejects a malformed turn_id', async () => { - const r = await boot(); - const sessionId = await createSession(r); - const res = await appOf(r).inject({ - method: 'GET', - url: `/api/v1/sessions/${sessionId}/file-history/changes?turn_id=abc`, - }); - const envelope = res.json() as Envelope; - expect(envelope.code).not.toBe(0); - }); -}); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 31052268dfe..78aba43ddf3 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1426,46 +1426,6 @@ describe('AgentTranscriptProjector', () => { ).toHaveLength(2); }); - it('projects file_history events as markers with their manifest payloads', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.map( - ev({ - type: 'file_history.tracked', - agentId: 'main', - turnId: 3, - path: 'src/a.ts', - entry: { key: 'file-history/abc123@v1', version: 1, contentHash: 'deadbeef', size: 12 }, - }), - ), - ); - tx.apply( - projector.map( - ev({ - type: 'file_history.checkpoint', - agentId: 'main', - turnId: 4, - entries: { 'src/a.ts': { key: 'file-history/abc123@v2', version: 2 } }, - }), - ), - ); - - const markers = tx - .getItems() - .filter((item) => item.kind === 'marker' && item.marker.startsWith('file_history.')); - expect(markers).toHaveLength(2); - expect(markers[0]).toMatchObject({ - marker: 'file_history.tracked', - payload: { turnId: 3, path: 'src/a.ts', entry: { version: 1 } }, - }); - expect(markers[1]).toMatchObject({ - marker: 'file_history.checkpoint', - payload: { turnId: 4, entries: { 'src/a.ts': { version: 2 } } }, - }); - }); - it('projects skill / plugin-command / cron / compaction / hook / undo markers', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index d7ff873e579..aca43e1f1c9 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -31,10 +31,6 @@ import type { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/promp import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; import type { SkillRuntime } from '@moonshot-ai/agent-core-v2/features/skill/skillAgentRuntime'; import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; -import type { - FileHistoryChange, - FileHistoryContent, -} from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistory'; import type { PlanData } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; import type { SkillSummary } from '@moonshot-ai/agent-core-v2/features/skill/catalog/types'; @@ -186,8 +182,6 @@ import { cancelPlanPayloadSchema, cancelShellCommandPayloadSchema, emptyPayloadSchema, - fileHistoryChangeSchema, - fileHistoryContentSchema, getTaskOutputPayloadSchema, getTasksPayloadSchema, planDataSchema, @@ -705,8 +699,6 @@ const _agentCommandInfo: AssertWire = true; const _runCommandPayload: AssertWire = true; const _planData: AssertWire = true; -const _fileHistoryChange: AssertWire = true; -const _fileHistoryContent: AssertWire = true; const _cancelPlanPayload: AssertWire = true; const _getTasksPayload: AssertWire = true; // The wire task union mirrors the protocol `TaskInfo`; the engine's diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts index b85a35bec80..4249ac26f67 100644 --- a/packages/klient/test/helpers/conformance.ts +++ b/packages/klient/test/helpers/conformance.ts @@ -560,28 +560,6 @@ export function defineKlientConformance( } }); - it('agent file history reads dispatch and normalize across the wire', async () => { - const created = await target.klient.global.sessions.create({ - workDir: process.cwd(), - title: 'conformance file history', - }); - const session = getLiveSessionById(target.app.accessor, created.id); - if (session === undefined) throw new Error('conformance session was not materialized'); - await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); - try { - const agent = target.klient.session(created.id).agent('main'); - // The file_history flag is off and no turn ran: both reads take the - // empty path, which still exercises dispatch, schema validation, and - // the ipc transport's null → undefined normalization. - await expect(agent.getFileChanges({ turnId: 1 })).resolves.toEqual([]); - await expect( - agent.getFileContentAt({ turnId: 1, path: 'missing.txt' }), - ).resolves.toBeUndefined(); - } finally { - await target.klient.session(created.id).close(); - } - }); - it('propagates prompt id conflicts with the same 40927 error', async () => { const created = await target.klient.global.sessions.create({ workDir: process.cwd(), From 8853c8b4804665df7b2d8b1380d36eead723d6fc Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:22:39 +0800 Subject: [PATCH 12/19] fix(agent-core-v2): key remote-runtime snapshots by their mapped-relative paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit/Write resolve their display path through the runtime workspace, so on a relocated runtime the captured path is a mapped absolute — the previous defense rejected it outright and remote edits recorded no history at all. Translate paths under the mapped workspace root back to the same workspace-relative keys local runtimes produce (host-relative first, then mapped-relative), and skip only paths outside both roots. --- .../fileHistory/fileHistoryService.ts | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 38eb90fd484..6c3b9f58780 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -134,7 +134,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor (c) => c.turnId === turnId && checkpointPhaseOf(c) === phase, ); if (index < 0) return undefined; - const entry = entryAt(state.checkpoints, index, this.pathKey(path)); + const pathKey = this.pathKey(path); + if (pathKey === undefined) return undefined; + const entry = entryAt(state.checkpoints, index, pathKey); if (entry === undefined) return undefined; if (entry.key === null) return { version: entry.version }; const bytes = await this.blobs.get(this.agentCtx.scope(), entry.key); @@ -161,6 +163,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private async capture(path: string, turnId: number): Promise { const pathKey = this.pathKey(path); + if (pathKey === undefined) return; const state = this.history(); if (state.tracked.includes(pathKey)) return; @@ -280,14 +283,37 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } } - private pathKey(path: string): string { + private pathKey(path: string): string | undefined { if (!isAbsolute(path)) return path; - const relativePath = relative(this.workspaceCtx.workDir, path); - if (relativePath === '' || relativePath === '..' || relativePath.startsWith('../')) return path; - return relativePath; + const hostRelative = relative(resolve(this.workspaceCtx.workDir), path); + if (containedRelative(hostRelative)) return hostRelative; + const lease = this.runtime.acquire(); + try { + const runtime = lease.runtime; + const mappedWorkDir = runtime.path.resolve( + runtime.workspace.mapRoots({ workDir: this.workspaceCtx.workDir, additionalDirs: [] }) + .workDir, + ); + if (mappedWorkDir === resolve(this.workspaceCtx.workDir)) return path; + const mappedRelative = runtime.path.relative(mappedWorkDir, path); + if (containedRelative(mappedRelative)) return mappedRelative.replaceAll('\\', '/'); + return undefined; + } finally { + lease.dispose(); + } } } +function containedRelative(relativePath: string): boolean { + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith('../') && + !relativePath.startsWith('..\\') && + !isAbsolute(relativePath) + ); +} + function editTargetPath(display: ToolInputDisplay | undefined): string | undefined { if (display === undefined || display.kind !== 'file_io') return undefined; if (display.operation !== 'edit' && display.operation !== 'write') return undefined; From aebcdfe96a0e88d49dc44ce675d74fbdeb4c93b5 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:27:59 +0800 Subject: [PATCH 13/19] chore(agent-core-v2): drop speculative remote-runtime path mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only runtime implementation today is local, whose mapRoots is the identity — the remote relocation scenarios cannot occur yet. Keep the lease-acquired filesystem read (correct locally too) and return snapshot keys to plain host-relative resolution; the mapped-root translation returns when a non-local runtime actually lands. --- .../fileHistory/fileHistoryService.ts | 50 +++---------------- 1 file changed, 6 insertions(+), 44 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 6c3b9f58780..b07dbc42170 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -134,9 +134,7 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor (c) => c.turnId === turnId && checkpointPhaseOf(c) === phase, ); if (index < 0) return undefined; - const pathKey = this.pathKey(path); - if (pathKey === undefined) return undefined; - const entry = entryAt(state.checkpoints, index, pathKey); + const entry = entryAt(state.checkpoints, index, this.pathKey(path)); if (entry === undefined) return undefined; if (entry.key === null) return { version: entry.version }; const bytes = await this.blobs.get(this.agentCtx.scope(), entry.key); @@ -163,7 +161,6 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private async capture(path: string, turnId: number): Promise { const pathKey = this.pathKey(path); - if (pathKey === undefined) return; const state = this.history(); if (state.tracked.includes(pathKey)) return; @@ -248,23 +245,11 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } private async readCurrent(pathKey: string): Promise { + const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); const lease = this.runtime.acquire(['fs']); try { const fs = lease.runtime.fs; if (fs === undefined) return 'unreadable'; - const runtime = lease.runtime; - const hostWorkDir = resolve(this.workspaceCtx.workDir); - const mappedWorkDir = runtime.path.resolve( - runtime.workspace.mapRoots({ workDir: this.workspaceCtx.workDir, additionalDirs: [] }) - .workDir, - ); - let absolute: string; - if (isAbsolute(pathKey)) { - if (mappedWorkDir !== hostWorkDir) return 'unreadable'; - absolute = pathKey; - } else { - absolute = runtime.path.resolve(mappedWorkDir, pathKey); - } let info; try { info = await fs.stat(absolute); @@ -283,37 +268,14 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } } - private pathKey(path: string): string | undefined { + private pathKey(path: string): string { if (!isAbsolute(path)) return path; - const hostRelative = relative(resolve(this.workspaceCtx.workDir), path); - if (containedRelative(hostRelative)) return hostRelative; - const lease = this.runtime.acquire(); - try { - const runtime = lease.runtime; - const mappedWorkDir = runtime.path.resolve( - runtime.workspace.mapRoots({ workDir: this.workspaceCtx.workDir, additionalDirs: [] }) - .workDir, - ); - if (mappedWorkDir === resolve(this.workspaceCtx.workDir)) return path; - const mappedRelative = runtime.path.relative(mappedWorkDir, path); - if (containedRelative(mappedRelative)) return mappedRelative.replaceAll('\\', '/'); - return undefined; - } finally { - lease.dispose(); - } + const relativePath = relative(this.workspaceCtx.workDir, path); + if (relativePath === '' || relativePath === '..' || relativePath.startsWith('../')) return path; + return relativePath; } } -function containedRelative(relativePath: string): boolean { - return ( - relativePath !== '' && - relativePath !== '..' && - !relativePath.startsWith('../') && - !relativePath.startsWith('..\\') && - !isAbsolute(relativePath) - ); -} - function editTargetPath(display: ToolInputDisplay | undefined): string | undefined { if (display === undefined || display.kind !== 'file_io') return undefined; if (display.operation !== 'edit' && display.operation !== 'write') return undefined; From baceeaf8d6fe2e585e2cf66f5d9db92accb92b13 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:46:04 +0800 Subject: [PATCH 14/19] fix(agent-core-v2,kap-server): harden file-history reads per review Resume persisted sessions in the REST routes instead of requiring a live one, serialize changes/contentAt through the same queue as writes so checkpoint eviction cannot delete blobs mid-read, require the paired end checkpoint for a turn's diff (a crash-orphaned start no longer attributes the next turn's window), and record an oversize sentinel when a tracked file grows past the byte cap so the turn reports an oversize change instead of none. --- .../agent-core-v2/docs/state-manifest.d.ts | 1 + .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../src/features/fileHistory/fileHistory.ts | 1 + .../features/fileHistory/fileHistoryOps.ts | 1 + .../fileHistory/fileHistoryService.ts | 66 ++++++++++++++----- packages/kap-server/src/routes/fileHistory.ts | 10 +-- 6 files changed, 59 insertions(+), 21 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 90305822e48..caaf49138a7 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1505,6 +1505,7 @@ export interface AgentStateSnapshot { readonly version: number; readonly contentHash?: string; readonly size?: number; + readonly oversize?: boolean; }>>; }[]; readonly tracked: readonly string[]; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 5233e178eee..7690b68c4eb 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -242,6 +242,7 @@ interface FileHistoryTrackedPayload { version: number; contentHash?: string; size?: number; + oversize?: boolean; }; } diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index 86697d82502..eed1a0b7c43 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -5,6 +5,7 @@ export interface FileBackupEntry { readonly version: number; readonly contentHash?: string; readonly size?: number; + readonly oversize?: boolean; } export type FileHistoryCheckpointPhase = 'start' | 'end'; diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts index bd3f9a2459d..ebea771dbdb 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -17,6 +17,7 @@ const backupEntrySchema = z.object({ version: z.number(), contentHash: z.string().optional(), size: z.number().optional(), + oversize: z.boolean().optional(), }); const fileHistoryTrackedSchema = z.object({ diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index b07dbc42170..f583564a62e 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -89,28 +89,41 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return this.queue; } - async changes(turnId: number): Promise { - if (!this.enabled()) return []; - await this.settled(); + changes(turnId: number): Promise { + if (!this.enabled()) return Promise.resolve([]); + return this.enqueueValue(() => this.readChanges(turnId)); + } + + private async readChanges(turnId: number): Promise { const state = this.history(); const index = state.checkpoints.findIndex( (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', ); if (index < 0) return []; - const next = state.checkpoints[index + 1]; + const end = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'end', + ); + const live = end === undefined && index === state.checkpoints.length - 1; + if (end === undefined && !live) return []; const paths = new Set(); for (const path of Object.keys(state.checkpoints[index]!.entries)) paths.add(path); - if (next !== undefined) for (const path of Object.keys(next.entries)) paths.add(path); + if (end !== undefined) for (const path of Object.keys(end.entries)) paths.add(path); else for (const path of state.tracked) paths.add(path); + const endIndex = end === undefined ? -1 : state.checkpoints.indexOf(end); const changes: FileHistoryChange[] = []; for (const path of [...paths].toSorted()) { const before = entryAt(state.checkpoints, index, path); + const after = end !== undefined ? entryAt(state.checkpoints, endIndex, path) : undefined; + if (before?.oversize === true || after?.oversize === true) { + changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + continue; + } const beforeBytes = await this.entryBytes(before); let afterBytes: Uint8Array | undefined; - if (next !== undefined) { - afterBytes = await this.entryBytes(entryAt(state.checkpoints, index + 1, path)); + if (end !== undefined) { + afterBytes = await this.entryBytes(after); } else { const current = await this.readCurrent(path); if (current === 'unreadable') continue; @@ -122,20 +135,27 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return changes; } - async contentAt( + contentAt( turnId: number, path: string, phase: FileHistoryCheckpointPhase = 'start', ): Promise { - if (!this.enabled()) return undefined; - await this.settled(); + if (!this.enabled()) return Promise.resolve(undefined); + return this.enqueueValue(() => this.readContentAt(turnId, path, phase)); + } + + private async readContentAt( + turnId: number, + path: string, + phase: FileHistoryCheckpointPhase, + ): Promise { const state = this.history(); const index = state.checkpoints.findIndex( (c) => c.turnId === turnId && checkpointPhaseOf(c) === phase, ); if (index < 0) return undefined; const entry = entryAt(state.checkpoints, index, this.pathKey(path)); - if (entry === undefined) return undefined; + if (entry === undefined || entry.oversize === true) return undefined; if (entry.key === null) return { version: entry.version }; const bytes = await this.blobs.get(this.agentCtx.scope(), entry.key); if (bytes === undefined) return undefined; @@ -152,10 +172,17 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } private enqueue(op: () => Promise): Promise { + return this.enqueueValue(op); + } + + private enqueueValue(op: () => Promise): Promise { const run = this.queue.then(op); - this.queue = run.catch((error) => { - onUnexpectedError(error); - }); + this.queue = run.then( + () => undefined, + (error) => { + onUnexpectedError(error); + }, + ); return run; } @@ -187,9 +214,16 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const latest = latestEntry(state.checkpoints, pathKey); const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; const current = await this.readCurrent(pathKey); - if (current === 'unreadable') continue; + if (current === 'unreadable') { + if (latest?.oversize !== true) { + entries[pathKey] = { key: null, version: nextVersion, oversize: true }; + } + continue; + } if (current === 'missing') { - if (latest?.key !== null) entries[pathKey] = { key: null, version: nextVersion }; + if (latest === undefined || latest.key !== null || latest.oversize === true) { + entries[pathKey] = { key: null, version: nextVersion }; + } continue; } const contentHash = sha256(current); diff --git a/packages/kap-server/src/routes/fileHistory.ts b/packages/kap-server/src/routes/fileHistory.ts index b76177d33e0..8b9245bf0eb 100644 --- a/packages/kap-server/src/routes/fileHistory.ts +++ b/packages/kap-server/src/routes/fileHistory.ts @@ -1,6 +1,6 @@ import { IAgentFileHistoryService, - getLiveSessionById, + resumeSessionById, type Scope, } from '@moonshot-ai/agent-core-v2'; import { z } from 'zod'; @@ -47,10 +47,10 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope }, async (req, reply) => { const { session_id } = req.params; - const session = getLiveSessionById(core.accessor, session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( - errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} is not live`, req.id), + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), ); return; } @@ -80,10 +80,10 @@ export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope }, async (req, reply) => { const { session_id } = req.params; - const session = getLiveSessionById(core.accessor, session_id); + const session = await resumeSessionById(core.accessor, session_id); if (session === undefined) { reply.send( - errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} is not live`, req.id), + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), ); return; } From d5a0259315076b76463fac2fb6e1ef972c232f61 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:53:13 +0800 Subject: [PATCH 15/19] chore: drop the klient file-history surface from this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing consumes it yet — the app client reads the REST endpoints. The facade returns as its own change when an SDK consumer exists. --- .changeset/klient-file-history-reads.md | 5 --- packages/klient/src/contract/agent/schemas.ts | 16 -------- .../klient/src/contract/agent/services.ts | 11 ----- packages/klient/src/contract/index.ts | 2 - packages/klient/src/core/facade/agent.ts | 40 ------------------- .../src/transports/memory/serviceRegistry.ts | 2 - 6 files changed, 76 deletions(-) delete mode 100644 .changeset/klient-file-history-reads.md diff --git a/.changeset/klient-file-history-reads.md b/.changeset/klient-file-history-reads.md deleted file mode 100644 index 84206c91a51..00000000000 --- a/.changeset/klient-file-history-reads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/klient": minor ---- - -Add `agent(id).getFileChanges({ turnId })` and `agent(id).getFileContentAt({ turnId, path })`, reading the daemon's experimental turn-level file snapshots over the wire. diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts index bc8707ac27e..35cd686b42b 100644 --- a/packages/klient/src/contract/agent/schemas.ts +++ b/packages/klient/src/contract/agent/schemas.ts @@ -230,19 +230,3 @@ export const getTaskOutputPayloadSchema = z.object({ taskId: z.string(), tail: z.number().optional(), }); - -/** Mirrors `FileHistoryChange` / `FileHistoryContent` from the engine's `features/fileHistory/fileHistory.ts`. */ -export const fileHistoryChangeSchema = z.object({ - path: z.string(), - status: z.enum(['added', 'modified', 'deleted']), - additions: z.number(), - deletions: z.number(), - binary: z.boolean().optional(), - oversize: z.boolean().optional(), -}); - -export const fileHistoryContentSchema = z.object({ - version: z.number(), - content: z.string().optional(), - binary: z.boolean().optional(), -}); diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts index 88fe2bedb04..54755a7ab54 100644 --- a/packages/klient/src/contract/agent/services.ts +++ b/packages/klient/src/contract/agent/services.ts @@ -14,8 +14,6 @@ import { activateSkillPayloadSchema, agentCommandInfoSchema, agentTaskInfoSchema, - fileHistoryChangeSchema, - fileHistoryContentSchema, permissionModeSchema, planDataSchema, promptLaunchResultSchema, @@ -103,15 +101,6 @@ export const agentPlanContract = { cancel: { input: z.tuple([z.string().optional()]), output: noResult }, } satisfies ServiceContract; -export const agentFileHistoryContract = { - enabled: { input: z.tuple([]), output: z.boolean() }, - changes: { input: z.tuple([z.number()]), output: z.array(fileHistoryChangeSchema) }, - contentAt: { - input: z.tuple([z.number(), z.string(), z.enum(['start', 'end']).optional()]), - output: maybe(fileHistoryContentSchema), - }, -} satisfies ServiceContract; - /** `McpServerEntry` from the engine's `mcpCore/connection-manager`. */ export const mcpServerEntrySchema = z.object({ name: z.string(), diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 848bce82e6e..d16cfaf2280 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -11,7 +11,6 @@ import { agentActivityViewContract } from './agent/activity.js'; import { agentCommandContract, agentContextMemoryContract, - agentFileHistoryContract, agentFullCompactionContract, agentLoopContract, agentMcpContract, @@ -89,7 +88,6 @@ export const globalContract: KlientContract = { agentProfileService: agentProfileContract, agentUsageService: agentUsageContract, agentPlanService: agentPlanContract, - agentFileHistoryService: agentFileHistoryContract, agentTaskService: agentTaskContract, agentMcpService: agentMcpContract, agentFullCompactionService: agentFullCompactionContract, diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index cacacb78ad3..f3a25b64110 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -14,7 +14,6 @@ import type { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp' import type { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/runtimeBinding/runtimeBinding'; import type { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; import type { ISessionTokenCountingService } from '@moonshot-ai/agent-core-v2/session/tokenCounting/sessionTokenCounting'; -import type { IAgentFileHistoryService } from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistory'; import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; @@ -43,10 +42,6 @@ export type AgentContextData = { export type AgentCommandInfo = Awaited>[number]; export type RuntimeBinding = ReturnType; export type PlanData = Awaited>; -export type FileHistoryChange = Awaited>[number]; -export type FileHistoryContent = NonNullable< - Awaited> ->; export type AgentTaskInfo = Awaited>[number]; export type McpServerEntry = ReturnType[number]; @@ -107,26 +102,6 @@ export interface AgentFacade { * Throws when there is nothing to compact or a turn is active. */ compact(input?: { instruction?: string }): Promise; - /** - * Per-turn file changes computed from the daemon's turn-level file - * snapshots (the `file_history` experimental flag). Empty when the flag is - * off, the turn is unknown, or the turn touched no tracked files. - */ - getFileChanges(input: { turnId: number }): Promise; - /** - * A file's content as captured at a turn's checkpoint. `undefined` when the - * file was not tracked at that turn; `content` is absent (with `binary` - * set, or for a file that did not exist yet) when there is no UTF-8 text to - * return. - */ - getFileContentAt(input: { - turnId: number; - path: string; - /** Which of the turn's two checkpoints to read (default 'start'): 'start' - is the file before the turn's edits, 'end' the file as the turn left - it. */ - phase?: 'start' | 'end'; - }): Promise; } export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { @@ -204,20 +179,5 @@ export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFac call(scope, 'agentFullCompactionService', 'begin', [ { source: 'manual', instruction: input?.instruction }, ]) as Promise, - getFileChanges: (input) => - call(scope, 'agentFileHistoryService', 'changes', [input.turnId]) as Promise< - readonly FileHistoryChange[] - >, - getFileContentAt: async (input) => { - const args: unknown[] = - input.phase === undefined - ? [input.turnId, input.path] - : [input.turnId, input.path, input.phase]; - const result = (await call(scope, 'agentFileHistoryService', 'contentAt', args)) as - | FileHistoryContent - | null - | undefined; - return result ?? undefined; - }, }; } diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index f49bad0efcb..38863dc02bf 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -39,7 +39,6 @@ import { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/ru import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; import { ISessionTokenCountingService } from '@moonshot-ai/agent-core-v2/session/tokenCounting/sessionTokenCounting'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; -import { IAgentFileHistoryService } from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistory'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; @@ -85,7 +84,6 @@ export const serviceTokens: Readonly>> agentProfileService: IAgentProfileService, agentUsageService: ISessionUsageService, agentPlanService: IAgentPlanService, - agentFileHistoryService: IAgentFileHistoryService, agentTaskService: IAgentTaskService, agentMcpService: IAgentMcpService, agentFullCompactionService: IAgentFullCompactionService, From 861652024d262e8af45357318ad50c3281558997 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 16:58:40 +0800 Subject: [PATCH 16/19] chore: drop the unconsumed transcript marker projection for file history The app client reads the REST endpoints; nothing consumes the markers. The events stay durable for state replay but are no longer observable, so neither the projector nor the WS broadcaster sees them. --- .../src/features/fileHistory/fileHistoryOps.ts | 2 -- .../src/services/transcript/coreEventMap.ts | 14 -------------- .../src/transport/ws/v1/sessionEventBroadcaster.ts | 2 -- 3 files changed, 18 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts index ebea771dbdb..83fa4b63ae2 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -30,7 +30,6 @@ const fileHistoryTrackedSchema = z.object({ export class FileHistoryTracked extends AgentEvent2> { static override readonly type = 'file_history.tracked'; static override readonly durable = true; - static override readonly observable = true; static override readonly schema = fileHistoryTrackedSchema; } export interface FileHistoryTracked { @@ -52,7 +51,6 @@ export class FileHistoryCheckpointed extends AgentEvent2< > { static override readonly type = 'file_history.checkpoint'; static override readonly durable = true; - static override readonly observable = true; static override readonly schema = fileHistoryCheckpointedSchema; } export interface FileHistoryCheckpointed { diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 5b5c56ff17a..a46e602d170 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -48,10 +48,6 @@ import type { ToolResultEvent, } from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; import type { AgentStatusUpdated } from '@moonshot-ai/agent-core-v2/agent/usage/usageEvents'; -import type { - FileHistoryCheckpointed, - FileHistoryTracked, -} from '@moonshot-ai/agent-core-v2/features/fileHistory/fileHistoryOps'; import type { PlanRevision } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; import type { SubagentSuspended } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; import type { @@ -97,11 +93,6 @@ export interface ProjectorInteraction { type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; -type FileHistoryTrackedEvent = { readonly type: 'file_history.tracked' } & FileHistoryTracked; -type FileHistoryCheckpointedEvent = { - readonly type: 'file_history.checkpoint'; -} & FileHistoryCheckpointed; - type AgentActivityUpdatedEvent = { readonly type: 'agent.activity.updated' } & AgentActivityUpdated; type PromptAcceptedEvent = { readonly type: 'prompt.accepted' } & PromptAccepted; type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued; @@ -114,8 +105,6 @@ type TurnSteerEvent = { readonly type: 'turn.steer' } & TurnSteer; export type ProjectorBusEvent = | PlanRevisionEvent - | FileHistoryTrackedEvent - | FileHistoryCheckpointedEvent | ({ readonly type: 'turn.started' } & TurnStarted) | ({ readonly type: 'turn.ended' } & TurnEnded) | ({ readonly type: 'turn.step.started' } & TurnStepStarted) @@ -251,9 +240,6 @@ export class AgentTranscriptProjector { switch (event.type) { case 'plan.revision': return this.onPlanRevision(event); - case 'file_history.tracked': - case 'file_history.checkpoint': - return [this.markerOp(event.type, restOf(event))]; case 'turn.started': return this.onTurnStarted(event); case 'turn.ended': diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 2ba3c0189eb..d24712e70ac 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -1145,8 +1145,6 @@ const TRANSCRIPT_PROJECTED_EVENT_TYPES: ReadonlySet = new Set([ 'warning', 'goal.updated', 'plan.revision', - 'file_history.tracked', - 'file_history.checkpoint', 'context.spliced', 'agent.status.updated', 'hook.result', From f02f923ee4c9f1cc87b34d2bbdabdda2558fb8da Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 17:06:02 +0800 Subject: [PATCH 17/19] fix(agent-core-v2): track first-touch oversize files and stop repeating their sentinel A first edit that overwrites an already-oversize file now records an oversize sentinel (carrying the stat size) instead of leaving the path untracked, so the turn reports the change. Checkpoints re-emit the sentinel only when the unavailable state actually changes (size fingerprint), and identical inherited sentinels on both boundaries no longer report a modification every turn. --- .../fileHistory/fileHistoryService.ts | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index f583564a62e..5aa15340181 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -116,19 +116,32 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor for (const path of [...paths].toSorted()) { const before = entryAt(state.checkpoints, index, path); const after = end !== undefined ? entryAt(state.checkpoints, endIndex, path) : undefined; - if (before?.oversize === true || after?.oversize === true) { - changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + if (before?.oversize === true && after?.oversize === true) { + if (before.version !== after.version) { + changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + } continue; } - const beforeBytes = await this.entryBytes(before); + let liveOversize: number | undefined; let afterBytes: Uint8Array | undefined; if (end !== undefined) { - afterBytes = await this.entryBytes(after); + if (before?.oversize !== true && after?.oversize !== true) { + afterBytes = await this.entryBytes(after); + } } else { const current = await this.readCurrent(path); if (current === 'unreadable') continue; - afterBytes = current === 'missing' ? undefined : current; + if (current instanceof Uint8Array) afterBytes = current; + else if (current !== 'missing') liveOversize = current.oversizeBytes; + } + if (before?.oversize === true || after?.oversize === true || liveOversize !== undefined) { + if (before?.oversize === true && liveOversize !== undefined && before.size === liveOversize) { + continue; + } + changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + continue; } + const beforeBytes = await this.entryBytes(before); const change = diffChange(path, beforeBytes, afterBytes); if (change !== undefined) changes.push(change); } @@ -193,8 +206,10 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const current = await this.readCurrent(pathKey); if (current === 'unreadable') return; - const entry = - current === 'missing' ? { key: null, version: 1 } : await this.backup(pathKey, 1, current); + let entry: FileBackupEntry; + if (current === 'missing') entry = { key: null, version: 1 }; + else if (current instanceof Uint8Array) entry = await this.backup(pathKey, 1, current); + else entry = { key: null, version: 1, oversize: true, size: current.oversizeBytes }; await this.dispatcher.dispatch( new FileHistoryTracked({ agentId: this.agentCtx.agentId, turnId, path: pathKey, entry }), ); @@ -214,18 +229,24 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const latest = latestEntry(state.checkpoints, pathKey); const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; const current = await this.readCurrent(pathKey); - if (current === 'unreadable') { - if (latest?.oversize !== true) { - entries[pathKey] = { key: null, version: nextVersion, oversize: true }; - } - continue; - } + if (current === 'unreadable') continue; if (current === 'missing') { if (latest === undefined || latest.key !== null || latest.oversize === true) { entries[pathKey] = { key: null, version: nextVersion }; } continue; } + if (!(current instanceof Uint8Array)) { + if (latest?.oversize !== true || latest.size !== current.oversizeBytes) { + entries[pathKey] = { + key: null, + version: nextVersion, + oversize: true, + size: current.oversizeBytes, + }; + } + continue; + } const contentHash = sha256(current); if (latest !== undefined && latest.contentHash === contentHash) continue; entries[pathKey] = await this.backup(pathKey, nextVersion, current, contentHash); @@ -278,7 +299,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return this.blobs.get(this.agentCtx.scope(), entry.key); } - private async readCurrent(pathKey: string): Promise { + private async readCurrent( + pathKey: string, + ): Promise { const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); const lease = this.runtime.acquire(['fs']); try { @@ -291,7 +314,8 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; return code === 'ENOENT' ? 'missing' : 'unreadable'; } - if (!info.isFile || info.size > FILE_HISTORY_MAX_FILE_BYTES) return 'unreadable'; + if (!info.isFile) return 'unreadable'; + if (info.size > FILE_HISTORY_MAX_FILE_BYTES) return { oversizeBytes: info.size }; try { return await fs.readBytes(absolute); } catch { From 92f3c944f3176f80a2177ea14404930004da1f46 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 17:23:28 +0800 Subject: [PATCH 18/19] fix(agent-core-v2): close the remaining oversize-file review findings Fingerprint oversize sentinels with mtime alongside size so a same-size rewrite still records a new boundary, derive added/deleted statuses for oversize transitions from the missing sentinels instead of always reporting modified, and bound the snapshot read to the byte cap plus one (with a short-read guard) so a file growing between stat and read cannot bypass the memory and storage limit. --- .../agent-core-v2/docs/state-manifest.d.ts | 1 + .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../src/features/fileHistory/fileHistory.ts | 1 + .../features/fileHistory/fileHistoryOps.ts | 1 + .../fileHistory/fileHistoryService.ts | 57 ++++++++++++++++--- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index caaf49138a7..8f8bdb0002d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1506,6 +1506,7 @@ export interface AgentStateSnapshot { readonly contentHash?: string; readonly size?: number; readonly oversize?: boolean; + readonly mtimeMs?: number; }>>; }[]; readonly tracked: readonly string[]; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 7690b68c4eb..145d0d58c70 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -243,6 +243,7 @@ interface FileHistoryTrackedPayload { contentHash?: string; size?: number; oversize?: boolean; + mtimeMs?: number; }; } diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts index eed1a0b7c43..959f92e6c3b 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -6,6 +6,7 @@ export interface FileBackupEntry { readonly contentHash?: string; readonly size?: number; readonly oversize?: boolean; + readonly mtimeMs?: number; } export type FileHistoryCheckpointPhase = 'start' | 'end'; diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts index 83fa4b63ae2..8032f618642 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -18,6 +18,7 @@ const backupEntrySchema = z.object({ contentHash: z.string().optional(), size: z.number().optional(), oversize: z.boolean().optional(), + mtimeMs: z.number().optional(), }); const fileHistoryTrackedSchema = z.object({ diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts index 5aa15340181..2cccb00898e 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -122,7 +122,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } continue; } - let liveOversize: number | undefined; + const beforeMissing = before === undefined || (before.key === null && before.oversize !== true); + let liveOversize: { size: number; mtimeMs?: number } | undefined; + let liveMissing = false; let afterBytes: Uint8Array | undefined; if (end !== undefined) { if (before?.oversize !== true && after?.oversize !== true) { @@ -132,13 +134,24 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor const current = await this.readCurrent(path); if (current === 'unreadable') continue; if (current instanceof Uint8Array) afterBytes = current; - else if (current !== 'missing') liveOversize = current.oversizeBytes; + else if (current === 'missing') liveMissing = true; + else liveOversize = { size: current.oversizeBytes, mtimeMs: current.mtimeMs }; } + const afterMissing = + end !== undefined + ? after === undefined || (after.key === null && after.oversize !== true) + : liveMissing; if (before?.oversize === true || after?.oversize === true || liveOversize !== undefined) { - if (before?.oversize === true && liveOversize !== undefined && before.size === liveOversize) { + if ( + before?.oversize === true && + liveOversize !== undefined && + before.size === liveOversize.size && + before.mtimeMs === liveOversize.mtimeMs + ) { continue; } - changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + const status = beforeMissing ? 'added' : afterMissing ? 'deleted' : 'modified'; + changes.push({ path, status, additions: 0, deletions: 0, oversize: true }); continue; } const beforeBytes = await this.entryBytes(before); @@ -209,7 +222,15 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor let entry: FileBackupEntry; if (current === 'missing') entry = { key: null, version: 1 }; else if (current instanceof Uint8Array) entry = await this.backup(pathKey, 1, current); - else entry = { key: null, version: 1, oversize: true, size: current.oversizeBytes }; + else { + entry = { + key: null, + version: 1, + oversize: true, + size: current.oversizeBytes, + mtimeMs: current.mtimeMs, + }; + } await this.dispatcher.dispatch( new FileHistoryTracked({ agentId: this.agentCtx.agentId, turnId, path: pathKey, entry }), ); @@ -237,12 +258,17 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor continue; } if (!(current instanceof Uint8Array)) { - if (latest?.oversize !== true || latest.size !== current.oversizeBytes) { + if ( + latest?.oversize !== true || + latest.size !== current.oversizeBytes || + latest.mtimeMs !== current.mtimeMs + ) { entries[pathKey] = { key: null, version: nextVersion, oversize: true, size: current.oversizeBytes, + mtimeMs: current.mtimeMs, }; } continue; @@ -301,7 +327,9 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor private async readCurrent( pathKey: string, - ): Promise { + ): Promise< + Uint8Array | 'missing' | 'unreadable' | { oversizeBytes: number; mtimeMs?: number } + > { const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); const lease = this.runtime.acquire(['fs']); try { @@ -315,9 +343,20 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor return code === 'ENOENT' ? 'missing' : 'unreadable'; } if (!info.isFile) return 'unreadable'; - if (info.size > FILE_HISTORY_MAX_FILE_BYTES) return { oversizeBytes: info.size }; + if (info.size > FILE_HISTORY_MAX_FILE_BYTES) { + return { oversizeBytes: info.size, mtimeMs: info.mtimeMs }; + } try { - return await fs.readBytes(absolute); + const bytes = await fs.readBytes(absolute, FILE_HISTORY_MAX_FILE_BYTES + 1); + if (bytes.byteLength > FILE_HISTORY_MAX_FILE_BYTES) { + const grown = await fs.stat(absolute).catch(() => undefined); + return { + oversizeBytes: grown?.size ?? bytes.byteLength, + mtimeMs: grown?.mtimeMs, + }; + } + if (bytes.byteLength !== info.size) return 'unreadable'; + return bytes; } catch { return 'unreadable'; } From e23287d0bab38632b00cd036a99fbb991af7784d Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 17:46:50 +0800 Subject: [PATCH 19/19] fix(agent-core-v2): fold windows case variants of one path into a single history Path-key lookups compare case-insensitively when the workspace path is Windows-style, canonicalizing every spelling to the first-seen tracked key, so Src/a.ts and src/A.TS no longer split one file's history on a case-insensitive filesystem. POSIX workspaces keep exact comparison. --- .../fileHistory/fileHistoryService.ts | 24 +++++++++++++++---- 1 file changed, 20 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 2cccb00898e..bc5ad978dd4 100644 --- a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -366,11 +366,27 @@ export class AgentFileHistoryService extends Service implements IAgentFileHistor } private pathKey(path: string): string { - if (!isAbsolute(path)) return path; - const relativePath = relative(this.workspaceCtx.workDir, path); - if (relativePath === '' || relativePath === '..' || relativePath.startsWith('../')) return path; - return relativePath; + let raw = path; + if (isAbsolute(path)) { + const relativePath = relative(this.workspaceCtx.workDir, path); + if (relativePath !== '' && relativePath !== '..' && !relativePath.startsWith('../')) { + raw = relativePath; + } + } + const key = this.comparisonKey(raw); + const existing = this.history().tracked.find( + (tracked) => this.comparisonKey(tracked) === key, + ); + return existing ?? raw; } + + private comparisonKey(pathKey: string): string { + return isWindowsPath(this.workspaceCtx.workDir) ? pathKey.toLowerCase() : pathKey; + } +} + +function isWindowsPath(value: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); } function editTargetPath(display: ToolInputDisplay | undefined): string | undefined {