diff --git a/.changeset/config-toml-writeback-preservation.md b/.changeset/config-toml-writeback-preservation.md new file mode 100644 index 00000000000..ea323c8a429 --- /dev/null +++ b/.changeset/config-toml-writeback-preservation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve comments, key order, and formatting in config.toml when configuration values are updated. diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 057aeb52ced..71a2577e4bf 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -6,10 +6,7 @@ import { Emitter, type Event } from '#/_base/event'; import { BugIndicatingError, Error2, ErrorCodes, onUnexpectedError } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ILogService } from '#/_base/log/log'; -import { - IAtomicTomlDocumentStore, - type IAtomicDocumentStore, -} from '#/persistence/interface/atomicDocumentStore'; +import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { type AnyEnvBindings, @@ -48,6 +45,7 @@ import { TomlError, transformTomlData, } from './toml'; +import { planConfigWriteback } from './tomlWriteback'; const CONFIG_SCOPE = ''; @@ -315,7 +313,7 @@ export class ConfigService extends Disposable implements IConfigService { @IConfigRegistry private readonly registry: IConfigRegistry, @IBootstrapService private readonly bootstrap: IBootstrapService, @ILogService private readonly log: ILogService, - @IAtomicTomlDocumentStore private readonly documentStore: IAtomicDocumentStore, + @IAtomicTomlDocumentStore private readonly documentStore: IAtomicTomlDocumentStore, ) { super(); this.configKey = this.bootstrap.configKey; @@ -789,13 +787,43 @@ export class ConfigService extends Disposable implements IConfigService { { cause: error }, ); } + let onDiskText: string | undefined; + try { + onDiskText = await this.documentStore.getText(CONFIG_SCOPE, this.configKey); + } catch { + onDiskText = undefined; + } const stagedRawSnake = cloneRecord(onDisk); const stagedRaw = transformTomlData(onDisk, this.registry); + const previousSnake: ResolvedConfig = {}; + for (const domain of domains) { + const snakeKey = camelToSnake(domain); + previousSnake[snakeKey] = stagedRawSnake[snakeKey]; + } rebase(stagedRaw, stagedRawSnake); for (const domain of domains) { applySectionToToml(stagedRawSnake, domain, stagedRaw[domain], this.registry); } - await this.documentStore.set(CONFIG_SCOPE, this.configKey, stagedRawSnake); + const plannedText = + onDiskText === undefined + ? undefined + : planConfigWriteback( + onDiskText, + domains.map((domain) => { + const snakeKey = camelToSnake(domain); + return { + snakeKey, + previousValue: previousSnake[snakeKey], + nextValue: stagedRawSnake[snakeKey], + }; + }), + stagedRawSnake, + ); + if (plannedText === undefined) { + await this.documentStore.set(CONFIG_SCOPE, this.configKey, stagedRawSnake); + } else if (plannedText !== onDiskText) { + await this.documentStore.setText(CONFIG_SCOPE, this.configKey, plannedText); + } this.rawSnake = stagedRawSnake; this.raw = stagedRaw; } diff --git a/packages/agent-core-v2/src/app/config/migrations.ts b/packages/agent-core-v2/src/app/config/migrations.ts index 6b69d30b232..dde5c66555a 100644 --- a/packages/agent-core-v2/src/app/config/migrations.ts +++ b/packages/agent-core-v2/src/app/config/migrations.ts @@ -1,9 +1,10 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'pathe'; -import { type IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { type IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { isPlainObject } from './configPure'; +import { replaceThinkingEffortMax } from './tomlWriteback'; const MIGRATIONS_FILE = 'migrations-effort.json'; const THINKING_EFFORT_MAX_TO_HIGH = 'thinking-effort-max-to-high'; @@ -31,14 +32,16 @@ function writeMigrationMarker(homeDir: string, key: string): void { } export async function migrateThinkingEffortMaxToHigh( - documentStore: IAtomicDocumentStore, + documentStore: IAtomicTomlDocumentStore, configKey: string, homeDir: string, ): Promise { try { if (readMigrationMarkers(homeDir)[THINKING_EFFORT_MAX_TO_HIGH] !== undefined) return; let doc: Record | undefined; + let text: string | undefined; try { + text = await documentStore.getText(CONFIG_SCOPE, configKey); const data = await documentStore.get>(CONFIG_SCOPE, configKey); doc = data !== undefined && isPlainObject(data) ? data : {}; } catch { @@ -46,8 +49,13 @@ export async function migrateThinkingEffortMaxToHigh( } const thinking = doc['thinking']; if (isPlainObject(thinking) && thinking['effort'] === 'max') { - doc['thinking'] = { ...thinking, effort: 'high' }; - await documentStore.set(CONFIG_SCOPE, configKey, doc); + const migrated = text === undefined ? undefined : replaceThinkingEffortMax(text); + if (migrated === undefined) { + doc['thinking'] = { ...thinking, effort: 'high' }; + await documentStore.set(CONFIG_SCOPE, configKey, doc); + } else if (migrated !== text) { + await documentStore.setText(CONFIG_SCOPE, configKey, migrated); + } } writeMigrationMarker(homeDir, THINKING_EFFORT_MAX_TO_HIGH); } catch { diff --git a/packages/agent-core-v2/src/app/config/tomlWriteback.ts b/packages/agent-core-v2/src/app/config/tomlWriteback.ts new file mode 100644 index 00000000000..f5258ccffce --- /dev/null +++ b/packages/agent-core-v2/src/app/config/tomlWriteback.ts @@ -0,0 +1,781 @@ +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; + +import { deepEqual, isPlainObject } from './configPure'; + +export interface DomainUpdate { + readonly snakeKey: string; + readonly previousValue: unknown; + readonly nextValue: unknown; +} + +type LineEdit = + | { + readonly type: 'replace'; + readonly startLine: number; + readonly endLine: number; + readonly text: string; + } + | { readonly type: 'insert'; readonly afterLine: number; readonly text: string }; + +interface RootRegion { + readonly rootKey: string; + start: number; + end: number; + dotted: boolean; +} + +type RootSegment = + | { readonly kind: 'trivia'; readonly start: number; readonly end: number } + | { readonly kind: 'region'; readonly region: RootRegion }; + +interface DomainStatement { + readonly key: string; + readonly startLine: number; + readonly endLine: number; + readonly indent: string; + readonly separator: string; + readonly valueStart: number; + readonly valueEnd: number; +} + +interface DomainBlock { + readonly path: readonly string[]; + readonly hasHeader: boolean; + readonly isArray: boolean; + readonly startLine: number; + endLine: number; + readonly statements: DomainStatement[]; +} + +interface DomainScan { + readonly blocks: readonly DomainBlock[]; + readonly ambiguous: boolean; +} + +interface KeyValueMatch { + readonly indent: string; + readonly keySegments: readonly string[]; + readonly dotted: boolean; + readonly separator: string; + readonly valueStart: number; +} + +interface HeaderMatch { + readonly rootKey: string; + readonly path: readonly string[]; + readonly isArray: boolean; +} + +const KEY_VALUE_LINE_PATTERN = /^(\s*)([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)(\s*=\s*)([\s\S]*)$/; +const BARE_KEY_CHAR_PATTERN = /[A-Za-z0-9_-]/; + +function splitLinesKeepEnds(text: string): string[] { + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') { + lines.push(text.slice(start, i + 1)); + start = i + 1; + } + } + if (start < text.length) lines.push(text.slice(start)); + return lines; +} + +function stripLineEnding(line: string): string { + if (!line.endsWith('\n')) return line; + return line.endsWith('\r\n') ? line.slice(0, -2) : line.slice(0, -1); +} + +function detectEol(text: string): string { + const index = text.indexOf('\n'); + return index > 0 && text.charAt(index - 1) === '\r' ? '\r\n' : '\n'; +} + +function isTriviaBody(body: string): boolean { + const trimmed = body.trim(); + return trimmed.length === 0 || trimmed.startsWith('#'); +} + +function lineIndexAt(offsets: readonly number[], position: number): number { + let low = 0; + let high = offsets.length - 1; + let result = 0; + while (low <= high) { + const mid = (low + high) >> 1; + if (offsets[mid]! <= position) { + result = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return result; +} + +function lineStartOffsets(lines: readonly string[]): number[] { + const offsets: number[] = []; + let offset = 0; + for (const line of lines) { + offsets.push(offset); + offset += line.length; + } + return offsets; +} + +function scanStringEnd(text: string, offset: number): number | undefined { + const quote = text.charAt(offset); + if (text.startsWith(quote + quote + quote, offset)) { + let i = offset + 3; + while (i < text.length) { + if (quote === '"' && text.charAt(i) === '\\') { + i += 2; + continue; + } + if (text.charAt(i) === quote) { + let run = 0; + while (i + run < text.length && text.charAt(i + run) === quote) run++; + if (run >= 3) return i + run; + i += run; + continue; + } + i++; + } + return undefined; + } + let i = offset + 1; + while (i < text.length) { + if (text.charAt(i) === '\n') return undefined; + if (quote === '"' && text.charAt(i) === '\\') { + i += 2; + continue; + } + if (text.charAt(i) === quote) return i + 1; + i++; + } + return undefined; +} + +function scanBalanced(text: string, offset: number, open: string, close: string): number | undefined { + let depth = 0; + let i = offset; + while (i < text.length) { + const ch = text.charAt(i); + if (ch === '"' || ch === "'") { + const end = scanStringEnd(text, i); + if (end === undefined) return undefined; + i = end; + continue; + } + if (ch === open) { + depth++; + } else if (ch === close) { + depth--; + if (depth === 0) return i + 1; + } else if (ch === '\n' && open === '{') { + return undefined; + } + i++; + } + return undefined; +} + +function scanValueEnd(text: string, offset: number): number | undefined { + const first = text.charAt(offset); + if (first === '"' || first === "'") return scanStringEnd(text, offset); + if (first === '[') return scanBalanced(text, offset, '[', ']'); + if (first === '{') return scanBalanced(text, offset, '{', '}'); + let i = offset; + while (i < text.length) { + const ch = text.charAt(i); + if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || ch === '#') break; + i++; + } + return i === offset ? undefined : i; +} + +function decodeBasicEscape(body: string, offset: number): { char: string; end: number } | undefined { + const code = body.charAt(offset + 1); + switch (code) { + case 'b': + return { char: '\b', end: offset + 2 }; + case 't': + return { char: '\t', end: offset + 2 }; + case 'n': + return { char: '\n', end: offset + 2 }; + case 'f': + return { char: '\f', end: offset + 2 }; + case 'r': + return { char: '\r', end: offset + 2 }; + case '"': + return { char: '"', end: offset + 2 }; + case '\\': + return { char: '\\', end: offset + 2 }; + case 'u': + return decodeUnicodeEscape(body, offset, 4); + case 'U': + return decodeUnicodeEscape(body, offset, 8); + default: + return undefined; + } +} + +function decodeUnicodeEscape( + body: string, + offset: number, + digits: number, +): { char: string; end: number } | undefined { + const hex = body.slice(offset + 2, offset + 2 + digits); + if (hex.length !== digits || !/^[0-9a-fA-F]+$/.test(hex)) return undefined; + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return undefined; + return { char: String.fromCodePoint(codePoint), end: offset + 2 + digits }; +} + +function skipInlineWhitespace(body: string, offset: number): number { + let i = offset; + while (i < body.length) { + const ch = body.charAt(i); + if (ch !== ' ' && ch !== '\t') break; + i++; + } + return i; +} + +interface HeaderSegment { + readonly value: string; + readonly end: number; +} + +function scanBasicHeaderSegment(body: string, offset: number): HeaderSegment | undefined { + let i = offset + 1; + let value = ''; + while (i < body.length) { + const ch = body.charAt(i); + if (ch === '"') { + if (value.length === 0) return undefined; + return { value, end: i + 1 }; + } + if (ch === '\\') { + const escape = decodeBasicEscape(body, i); + if (escape === undefined) return undefined; + value += escape.char; + i = escape.end; + continue; + } + value += ch; + i++; + } + return undefined; +} + +function scanLiteralHeaderSegment(body: string, offset: number): HeaderSegment | undefined { + const close = body.indexOf("'", offset + 1); + if (close === -1) return undefined; + const value = body.slice(offset + 1, close); + if (value.length === 0) return undefined; + return { value, end: close + 1 }; +} + +function scanHeaderSegment(body: string, offset: number): HeaderSegment | undefined { + const start = skipInlineWhitespace(body, offset); + const ch = body.charAt(start); + if (ch === '"') return scanBasicHeaderSegment(body, start); + if (ch === "'") return scanLiteralHeaderSegment(body, start); + let i = start; + while (i < body.length && BARE_KEY_CHAR_PATTERN.test(body.charAt(i))) i++; + if (i === start) return undefined; + return { value: body.slice(start, i), end: i }; +} + +function matchHeader(body: string): HeaderMatch | undefined { + const start = skipInlineWhitespace(body, 0); + let isArray = false; + let i: number; + if (body.startsWith('[[', start)) { + isArray = true; + i = start + 2; + } else if (body.charAt(start) === '[') { + i = start + 1; + } else { + return undefined; + } + const path: string[] = []; + for (;;) { + const segment = scanHeaderSegment(body, i); + if (segment === undefined) return undefined; + path.push(segment.value); + i = skipInlineWhitespace(body, segment.end); + const ch = body.charAt(i); + if (ch === ']') { + i++; + break; + } + if (ch !== '.') return undefined; + i = skipInlineWhitespace(body, i + 1); + } + if (isArray) { + if (body.charAt(i) !== ']') return undefined; + i++; + } + const rest = skipInlineWhitespace(body, i); + if (rest < body.length && body.charAt(rest) !== '#') return undefined; + return { rootKey: path[0]!, path, isArray }; +} + +function matchKeyValue(body: string): KeyValueMatch | undefined { + const match = KEY_VALUE_LINE_PATTERN.exec(body); + if (match === null) return undefined; + const keySegments = match[2]!.split('.'); + return { + indent: match[1]!, + keySegments, + dotted: keySegments.length > 1, + separator: match[3]!, + valueStart: body.length - match[4]!.length, + }; +} + +interface ScannedDocument { + lines: string[]; + offsets: number[]; + eol: string; + segments: RootSegment[]; +} + +function scanRootRegions(text: string): ScannedDocument | undefined { + const lines = splitLinesKeepEnds(text); + const offsets = lineStartOffsets(lines); + const eol = detectEol(text); + const segments: RootSegment[] = []; + let region: RootRegion | undefined; + let triviaStart = -1; + let i = 0; + while (i < lines.length) { + const body = stripLineEnding(lines[i]!); + if (isTriviaBody(body)) { + if (triviaStart < 0) triviaStart = i; + i++; + continue; + } + if (triviaStart >= 0) { + segments.push({ kind: 'trivia', start: triviaStart, end: i - 1 }); + triviaStart = -1; + } + const header = matchHeader(body); + if (header !== undefined) { + if (region === undefined || region.rootKey !== header.rootKey) { + if (region !== undefined) segments.push({ kind: 'region', region }); + region = { rootKey: header.rootKey, start: i, end: i, dotted: false }; + } else { + region.end = i; + } + i++; + continue; + } + const kv = matchKeyValue(body); + if (kv === undefined) return undefined; + const valueStart = offsets[i]! + kv.valueStart; + const valueEnd = scanValueEnd(text, valueStart); + if (valueEnd === undefined) return undefined; + const endLine = lineIndexAt(offsets, valueEnd - 1); + const rootKey = region === undefined ? kv.keySegments[0]! : region.rootKey; + if (region === undefined || region.rootKey !== rootKey) { + if (region !== undefined) segments.push({ kind: 'region', region }); + region = { rootKey, start: i, end: endLine, dotted: kv.dotted }; + } else { + region.end = endLine; + region.dotted = region.dotted || kv.dotted; + } + i = endLine + 1; + } + if (triviaStart >= 0) segments.push({ kind: 'trivia', start: triviaStart, end: lines.length - 1 }); + if (region !== undefined) segments.push({ kind: 'region', region }); + return { lines, offsets, eol, segments }; +} + +function scanDomainRegion( + text: string, + lines: readonly string[], + offsets: readonly number[], + region: RootRegion, + snakeKey: string, +): DomainScan | undefined { + const blocks: DomainBlock[] = []; + let ambiguous = false; + let current: DomainBlock | undefined; + for (let i = region.start; i <= region.end; i++) { + const body = stripLineEnding(lines[i]!); + if (isTriviaBody(body)) continue; + const header = matchHeader(body); + if (header !== undefined) { + if (header.rootKey !== snakeKey) return undefined; + current = { + path: header.path.slice(1), + hasHeader: true, + isArray: header.isArray, + startLine: i, + endLine: i, + statements: [], + }; + if (header.isArray) ambiguous = true; + blocks.push(current); + continue; + } + const kv = matchKeyValue(body); + if (kv === undefined) return undefined; + if (kv.dotted) ambiguous = true; + const valueStart = offsets[i]! + kv.valueStart; + const valueEnd = scanValueEnd(text, valueStart); + if (valueEnd === undefined) return undefined; + const endLine = lineIndexAt(offsets, valueEnd - 1); + const statement: DomainStatement = { + key: kv.keySegments.at(-1)!, + startLine: i, + endLine, + indent: kv.indent, + separator: kv.separator, + valueStart, + valueEnd, + }; + if (current === undefined) { + current = { + path: [], + hasHeader: false, + isArray: false, + startLine: i, + endLine, + statements: [statement], + }; + blocks.push(current); + } else { + current.statements.push(statement); + current.endLine = endLine; + } + i = endLine; + } + return { blocks, ambiguous }; +} + +function pathsEqual(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function blocksNestedUnder(blocks: readonly DomainBlock[], path: readonly string[]): readonly DomainBlock[] { + return blocks.filter( + (block) => block.path.length >= path.length && pathsEqual(block.path.slice(0, path.length), path), + ); +} + +function removeLines(startLine: number, endLine: number): LineEdit { + return { type: 'replace', startLine, endLine, text: '' }; +} + +function insertMerged(edits: LineEdit[], afterLine: number, text: string): void { + const existing = edits.find((edit) => edit.type === 'insert' && edit.afterLine === afterLine); + if (existing !== undefined && existing.type === 'insert') { + edits.splice(edits.indexOf(existing), 1, { ...existing, text: existing.text + text }); + return; + } + edits.push({ type: 'insert', afterLine, text }); +} + +function statementSuffix(text: string, statement: DomainStatement): string { + const lineEnd = text.indexOf('\n', statement.valueEnd); + const end = lineEnd === -1 ? text.length : lineEnd; + return text.slice(statement.valueEnd, end).replace(/\r$/, ''); +} + +function renderStatement(text: string, statement: DomainStatement, valueText: string, eol: string): string { + const suffix = statementSuffix(text, statement); + const rendered = `${statement.indent}${statement.key}${statement.separator}${valueText}${suffix}`; + return rendered.endsWith('\n') ? rendered : `${rendered}${eol}`; +} + +function serializeValueText(key: string, value: unknown): string | undefined { + const prefix = `${key} = `; + const serialized = stringifyToml({ [key]: value }); + if (!serialized.startsWith(prefix)) return undefined; + const text = serialized.slice(prefix.length); + return text.endsWith('\n') ? text.slice(0, -1) : text; +} + +function serializeTableBlock(path: readonly string[], value: unknown, eol: string): string { + let nested: unknown = value; + for (let i = path.length - 1; i >= 0; i--) { + nested = { [path[i]!]: nested }; + } + return stringifyToml(nested as Record).replaceAll('\n', eol); +} + +function blockAnchorLine(block: DomainBlock): number { + const last = block.statements.at(-1); + return last === undefined ? block.startLine : last.endLine; +} + +function planScalarDomainEdit( + text: string, + scan: DomainScan, + update: DomainUpdate, + eol: string, +): LineEdit[] | undefined { + const block = scan.blocks[0]; + if ( + block === undefined || + scan.blocks.length !== 1 || + block.hasHeader || + block.path.length > 0 || + block.statements.length !== 1 + ) { + return undefined; + } + const statement = block.statements[0]!; + if (statement.key !== update.snakeKey) return undefined; + const valueText = serializeValueText(statement.key, update.nextValue); + if (valueText === undefined) return undefined; + return [ + { + type: 'replace', + startLine: statement.startLine, + endLine: statement.endLine, + text: renderStatement(text, statement, valueText, eol), + }, + ]; +} + +function planObjectLevel( + text: string, + rootKey: string, + blocks: readonly DomainBlock[], + prefix: readonly string[], + previousValue: Record, + nextValue: Record, + edits: LineEdit[], + appends: string[], + eol: string, +): boolean { + const block = blocks.find((candidate) => pathsEqual(candidate.path, prefix)); + const keys = [...new Set([...Object.keys(previousValue), ...Object.keys(nextValue)])]; + for (const key of keys) { + const previous = previousValue[key]; + const next = nextValue[key]; + if (deepEqual(previous, next)) continue; + const childPath = [...prefix, key]; + const statement = block?.statements.find((candidate) => candidate.key === key); + const childBlock = blocks.find((candidate) => pathsEqual(candidate.path, childPath)); + if (statement !== undefined && childBlock !== undefined) return false; + if (next === undefined) { + if (childBlock !== undefined) { + if (childBlock.isArray) return false; + for (const nested of blocksNestedUnder(blocks, childPath)) { + edits.push(removeLines(nested.startLine, nested.endLine)); + } + continue; + } + if (statement === undefined) return false; + edits.push(removeLines(statement.startLine, statement.endLine)); + continue; + } + if (previous === undefined) { + if (isPlainObject(next)) { + appends.push(serializeTableBlock([rootKey, ...childPath], next, eol)); + } else { + const valueText = serializeValueText(key, next); + if (valueText === undefined) return false; + if (block !== undefined) { + insertMerged(edits, blockAnchorLine(block), `${key} = ${valueText}${eol}`); + } else { + appends.push(serializeTableBlock([rootKey, ...prefix], { [key]: next }, eol)); + } + } + continue; + } + if (isPlainObject(previous) && isPlainObject(next)) { + if (statement !== undefined || childBlock === undefined || childBlock.isArray) return false; + if (!planObjectLevel(text, rootKey, blocks, childPath, previous, next, edits, appends, eol)) { + return false; + } + continue; + } + if (isPlainObject(next)) { + if (statement === undefined) return false; + edits.push(removeLines(statement.startLine, statement.endLine)); + appends.push(serializeTableBlock([rootKey, ...childPath], next, eol)); + continue; + } + if (isPlainObject(previous)) { + if (childBlock === undefined || childBlock.isArray) return false; + for (const nested of blocksNestedUnder(blocks, childPath)) { + edits.push(removeLines(nested.startLine, nested.endLine)); + } + const valueText = serializeValueText(key, next); + if (valueText === undefined) return false; + if (block !== undefined) { + insertMerged(edits, blockAnchorLine(block), `${key} = ${valueText}${eol}`); + } else { + appends.push(serializeTableBlock([rootKey, ...prefix], { [key]: next }, eol)); + } + continue; + } + if (statement === undefined) return false; + const valueText = serializeValueText(key, next); + if (valueText === undefined) return false; + edits.push({ + type: 'replace', + startLine: statement.startLine, + endLine: statement.endLine, + text: renderStatement(text, statement, valueText, eol), + }); + } + return true; +} + +function planDomainKeyEdit( + text: string, + scan: DomainScan, + region: RootRegion, + update: DomainUpdate, + eol: string, +): LineEdit[] | undefined { + if (scan.ambiguous) return undefined; + const previousValue = update.previousValue; + const nextValue = update.nextValue; + if (!isPlainObject(previousValue) || !isPlainObject(nextValue)) { + if (isPlainObject(previousValue) || isPlainObject(nextValue)) return undefined; + return planScalarDomainEdit(text, scan, update, eol); + } + const edits: LineEdit[] = []; + const appends: string[] = []; + if (!planObjectLevel(text, update.snakeKey, scan.blocks, [], previousValue, nextValue, edits, appends, eol)) { + return undefined; + } + if (appends.length > 0) { + edits.push({ type: 'insert', afterLine: region.end, text: appends.join('') }); + } + return edits; +} + +function editPosition(edit: LineEdit): number { + return edit.type === 'replace' ? edit.startLine : edit.afterLine + 0.5; +} + +function applyLineEdits(lines: readonly string[], edits: readonly LineEdit[], eol: string): string { + const ordered = edits.toSorted((a, b) => editPosition(b) - editPosition(a)); + const out = [...lines]; + for (const edit of ordered) { + if (edit.type === 'replace') { + out.splice(edit.startLine, edit.endLine - edit.startLine + 1, ...splitLinesKeepEnds(edit.text)); + } else { + const prefix = edit.afterLine < out.length && !out[edit.afterLine]!.endsWith('\n') ? eol : ''; + out.splice(edit.afterLine + 1, 0, ...splitLinesKeepEnds(prefix + edit.text)); + } + } + return out.join(''); +} + +function verifyPlannedText(text: string, expected: Record): boolean { + if (text.trim().length === 0) return Object.keys(expected).length === 0; + try { + return deepEqual(parseToml(text), expected); + } catch { + return false; + } +} + +export function planConfigWriteback( + originalText: string, + updates: readonly DomainUpdate[], + expected: Record, +): string | undefined { + const scanned = scanRootRegions(originalText); + if (scanned === undefined) return undefined; + const regionsByKey = new Map(); + for (const segment of scanned.segments) { + if (segment.kind !== 'region') continue; + const list = regionsByKey.get(segment.region.rootKey); + if (list === undefined) { + regionsByKey.set(segment.region.rootKey, [segment.region]); + } else { + list.push(segment.region); + } + } + const edits: LineEdit[] = []; + const appends: string[] = []; + for (const update of updates) { + if (deepEqual(update.previousValue, update.nextValue)) continue; + const regions = regionsByKey.get(update.snakeKey) ?? []; + if (update.previousValue === undefined && regions.length > 0) return undefined; + if (update.nextValue === undefined) { + if (update.previousValue === undefined) continue; + if (regions.length === 0) return undefined; + for (const region of regions) edits.push(removeLines(region.start, region.end)); + continue; + } + if (regions.length === 0) { + if (update.previousValue !== undefined) return undefined; + appends.push(serializeTableBlock([update.snakeKey], update.nextValue, scanned.eol)); + continue; + } + const replacement = serializeTableBlock([update.snakeKey], update.nextValue, scanned.eol); + if (regions.length > 1 || regions[0]!.dotted) { + edits.push({ + type: 'replace', + startLine: regions[0]!.start, + endLine: regions[0]!.end, + text: replacement, + }); + for (const extra of regions.slice(1)) edits.push(removeLines(extra.start, extra.end)); + continue; + } + const region = regions[0]!; + const scan = scanDomainRegion(originalText, scanned.lines, scanned.offsets, region, update.snakeKey); + const planned = + scan === undefined + ? undefined + : planDomainKeyEdit(originalText, scan, region, update, scanned.eol); + if (planned === undefined) { + edits.push({ type: 'replace', startLine: region.start, endLine: region.end, text: replacement }); + continue; + } + edits.push(...planned); + } + let text = applyLineEdits(scanned.lines, edits, scanned.eol); + for (const block of appends) { + if (text.length > 0 && !text.endsWith('\n')) text += scanned.eol; + text += block; + } + if (!verifyPlannedText(text, expected)) return undefined; + return text; +} + +export function replaceThinkingEffortMax(originalText: string): string | undefined { + const scanned = scanRootRegions(originalText); + if (scanned === undefined) return undefined; + const regions = scanned.segments.flatMap((segment) => + segment.kind === 'region' && segment.region.rootKey === 'thinking' ? [segment.region] : [], + ); + const region = regions.length === 1 ? regions[0]! : undefined; + if (region === undefined || region.dotted) return undefined; + const scan = scanDomainRegion(originalText, scanned.lines, scanned.offsets, region, 'thinking'); + if (scan === undefined || scan.ambiguous) return undefined; + const block = scan.blocks.find((candidate) => candidate.path.length === 0 && !candidate.isArray); + const statement = block?.statements.find((candidate) => candidate.key === 'effort'); + if (block === undefined || statement === undefined) return undefined; + if (originalText.slice(statement.valueStart, statement.valueEnd) !== '"max"') return undefined; + return applyLineEdits( + scanned.lines, + [ + { + type: 'replace', + startLine: statement.startLine, + endLine: statement.endLine, + text: renderStatement(originalText, statement, '"high"', scanned.eol), + }, + ], + scanned.eol, + ); +} diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts index db1eaccd980..811deedfda4 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts @@ -41,7 +41,7 @@ class AtomicDocumentStoreBase implements IAtomicDocumentStore { declare readonly _serviceBrand: undefined; constructor( - private readonly storage: IFileSystemStorageService, + protected readonly storage: IFileSystemStorageService, private readonly codec: DocumentCodec, ) {} @@ -89,10 +89,22 @@ export class JsonAtomicDocumentStore extends AtomicDocumentStoreBase { } } -export class TomlAtomicDocumentStore extends AtomicDocumentStoreBase { +export class TomlAtomicDocumentStore + extends AtomicDocumentStoreBase + implements IAtomicTomlDocumentStore +{ constructor(@IFileSystemStorageService storage: IFileSystemStorageService) { super(storage, tomlDocumentCodec); } + + async getText(scope: string, key: string): Promise { + const bytes = await this.storage.read(scope, key); + return bytes === undefined ? undefined : textDecoder.decode(bytes); + } + + async setText(scope: string, key: string, text: string): Promise { + await this.storage.write(scope, key, textEncoder.encode(text), { atomic: true }); + } } registerScopedService( diff --git a/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts index c0e4f9f41d7..4a580a8c50c 100644 --- a/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts @@ -22,5 +22,10 @@ export interface IAtomicDocumentStore { export const IAtomicDocumentStore: ServiceIdentifier = createDecorator('atomicDocumentStore'); -export const IAtomicTomlDocumentStore: ServiceIdentifier = - createDecorator('atomicTomlDocumentStore'); +export interface IAtomicTomlDocumentStore extends IAtomicDocumentStore { + getText(scope: string, key: string): Promise; + setText(scope: string, key: string, text: string): Promise; +} + +export const IAtomicTomlDocumentStore: ServiceIdentifier = + createDecorator('atomicTomlDocumentStore'); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index db8a66578f1..12bd02446dc 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -2664,6 +2664,7 @@ describe('ConfigService replaceSections', () => { it('applies every domain in one transition with a single disk write, clearing undefined domains', async () => { const { config, disposables, store } = await createSectionsConfig(); const setSpy = vi.spyOn(store, 'set'); + const setTextSpy = vi.spyOn(store, 'setText'); await config.replaceSections({ [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, @@ -2672,7 +2673,7 @@ describe('ConfigService replaceSections', () => { [THINKING_SECTION]: undefined, }); - expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy.mock.calls.length + setTextSpy.mock.calls.length).toBe(1); expect(config.get>(PROVIDERS_SECTION)).toEqual({ acme: { type: 'openai', apiKey: 'sk-acme-2' }, }); @@ -2690,13 +2691,14 @@ describe('ConfigService replaceSections', () => { it('treats null as clear — the wire encoding JSON transports use for undefined', async () => { const { config, disposables, store } = await createSectionsConfig(); const setSpy = vi.spyOn(store, 'set'); + const setTextSpy = vi.spyOn(store, 'setText'); await config.replaceSections({ [DEFAULT_MODEL_SECTION]: null, [PROVIDERS_SECTION]: { acme: { type: 'openai', apiKey: 'sk-acme-2' } }, }); - expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy.mock.calls.length + setTextSpy.mock.calls.length).toBe(1); expect(config.get(DEFAULT_MODEL_SECTION)).toBeUndefined(); expect(config.inspect(DEFAULT_MODEL_SECTION).userValue).toBeUndefined(); expect(config.get>(PROVIDERS_SECTION)).toEqual({ diff --git a/packages/agent-core-v2/test/app/config/tomlWriteback.test.ts b/packages/agent-core-v2/test/app/config/tomlWriteback.test.ts new file mode 100644 index 00000000000..2143583b573 --- /dev/null +++ b/packages/agent-core-v2/test/app/config/tomlWriteback.test.ts @@ -0,0 +1,379 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { parse as parseToml } from 'smol-toml'; +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 { ILogService } from '#/_base/log/log'; +import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigRegistry, IConfigService } from '#/app/config/config'; +import { ConfigRegistry, ConfigService } from '#/app/config/configService'; +import { planConfigWriteback, replaceThinkingEffortMax, type DomainUpdate } from '#/app/config/tomlWriteback'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { stubLog } from '../../_base/log/stubs'; +import { stubBootstrap } from '../bootstrap/stubs'; + +describe('planConfigWriteback', () => { + function edit( + text: string, + snakeKey: string, + previousValue: unknown, + nextValue: unknown, + expected: Record, + ): string | undefined { + const update: DomainUpdate = { snakeKey, previousValue, nextValue }; + return planConfigWriteback(text, [update], expected); + } + + it('rewrites only the changed statement and keeps adjacent comments and key formatting', () => { + const text = ['[image]', '# keep me', 'max_edge_px = 1500', 'quality = "high"', ''].join('\n'); + const result = edit(text, 'image', { max_edge_px: 1500, quality: 'high' }, { max_edge_px: 2000, quality: 'high' }, { + image: { max_edge_px: 2000, quality: 'high' }, + }); + expect(result).toBe('[image]\n# keep me\nmax_edge_px = 2000\nquality = "high"\n'); + }); + + it('keeps the trailing comment of a changed statement', () => { + const text = 'default_model = "kimi-k2" # pick one\n'; + const result = edit(text, 'default_model', 'kimi-k2', 'kimi-k3', { default_model: 'kimi-k3' }); + expect(result).toBe('default_model = "kimi-k3" # pick one\n'); + }); + + it('appends a new key after the last statement of its block, before trailing trivia', () => { + const text = ['[image]', 'max_edge_px = 1500', '# tail note', ''].join('\n'); + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 1500, read_byte_budget: 5000 }, { + image: { max_edge_px: 1500, read_byte_budget: 5000 }, + }); + expect(result).toBe('[image]\nmax_edge_px = 1500\nread_byte_budget = 5000\n# tail note\n'); + }); + + it('inserts into an empty table right after its header', () => { + const text = '[image]\n'; + const result = edit(text, 'image', {}, { max_edge_px: 1500 }, { image: { max_edge_px: 1500 } }); + expect(result).toBe('[image]\nmax_edge_px = 1500\n'); + }); + + it('deletes only the removed key line and keeps surrounding comments', () => { + const text = ['[image]', '# about edge', 'max_edge_px = 1500', 'quality = "high"', ''].join('\n'); + const result = edit(text, 'image', { max_edge_px: 1500, quality: 'high' }, { quality: 'high' }, { + image: { quality: 'high' }, + }); + expect(result).toBe('[image]\n# about edge\nquality = "high"\n'); + }); + + it('edits one provider in place, drops a removed provider block and appends a new one', () => { + const text = [ + '[providers]', + '', + '# acme provider', + '[providers.acme]', + 'base_url = "https://acme.example.com"', + 'api_key = "acme-key"', + '', + '[providers.beta]', + 'base_url = "https://beta.example.com"', + 'api_key = "beta-key"', + '', + ].join('\n'); + const gamma = { base_url: 'https://gamma.example.com', api_key: 'gamma-key' }; + const result = edit( + text, + 'providers', + { + acme: { base_url: 'https://acme.example.com', api_key: 'acme-key' }, + beta: { base_url: 'https://beta.example.com', api_key: 'beta-key' }, + }, + { + acme: { base_url: 'https://acme.example.com', api_key: 'acme-key-2' }, + gamma, + }, + { + providers: { + acme: { base_url: 'https://acme.example.com', api_key: 'acme-key-2' }, + gamma, + }, + }, + ); + expect(result).toBe( + [ + '[providers]', + '', + '# acme provider', + '[providers.acme]', + 'base_url = "https://acme.example.com"', + 'api_key = "acme-key-2"', + '', + '[providers.gamma]', + 'base_url = "https://gamma.example.com"', + 'api_key = "gamma-key"', + '', + ].join('\n'), + ); + }); + + it('edits nested sub-tables without touching sibling lines', () => { + const text = [ + '[providers.acme.limits]', + 'rpm = 100', + '', + '[providers.acme]', + 'base_url = "https://acme.example.com"', + '', + ].join('\n'); + const result = edit( + text, + 'providers', + { acme: { limits: { rpm: 100 }, base_url: 'https://acme.example.com' } }, + { acme: { limits: { rpm: 200 }, base_url: 'https://acme.example.com' } }, + { providers: { acme: { limits: { rpm: 200 }, base_url: 'https://acme.example.com' } } }, + ); + expect(result).toBe( + ['[providers.acme.limits]', 'rpm = 200', '', '[providers.acme]', 'base_url = "https://acme.example.com"', ''].join( + '\n', + ), + ); + }); + + it('falls back to re-serializing a dotted-key domain while preserving other domains', () => { + const text = 'a.b = 1\n\n[cool]\nx = 1\n'; + const result = edit(text, 'a', { b: 1 }, { b: 2 }, { a: { b: 2 }, cool: { x: 1 } }); + expect(result).toBe('[a]\nb = 2\n\n[cool]\nx = 1\n'); + }); + + it('re-serializes a changed array-of-tables domain and keeps untouched ones byte-for-byte', () => { + const text = ['[[models]]', 'name = "m1"', '', '[[pinned]]', 'x = 1', ''].join('\n'); + const result = edit(text, 'models', [{ name: 'm1' }], [{ name: 'm1' }, { name: 'm2' }], { + models: [{ name: 'm1' }, { name: 'm2' }], + pinned: [{ x: 1 }], + }); + expect(result).toBe(['[[models]]', 'name = "m1"', '', '[[models]]', 'name = "m2"', '', '[[pinned]]', 'x = 1', ''].join('\n')); + }); + + it('does not treat multiline string bodies or bracketed array items as table headers', () => { + const text = [ + '[custom]', + 'notes = """', + '[fake]', + '"""', + 'list = [ "]", "[" ]', + '', + '[image]', + 'max_edge_px = 1500', + '', + ].join('\n'); + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, { + custom: { notes: '[fake]\n', list: [']', '['] }, + image: { max_edge_px: 2000 }, + }); + expect(result).toBe( + [ + '[custom]', + 'notes = """', + '[fake]', + '"""', + 'list = [ "]", "[" ]', + '', + '[image]', + 'max_edge_px = 2000', + '', + ].join('\n'), + ); + }); + + it('preserves CRLF everywhere and writes the edited statement with CRLF', () => { + const text = '# note\r\n[image]\r\nmax_edge_px = 1500\r\n'; + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, { + image: { max_edge_px: 2000 }, + }); + expect(result).toBe('# note\r\n[image]\r\nmax_edge_px = 2000\r\n'); + }); + + it('returns the original text unchanged when nothing differs', () => { + const text = '[image]\nmax_edge_px = 1500\n'; + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 1500 }, { + image: { max_edge_px: 1500 }, + }); + expect(result).toBe(text); + }); + + it('appends a new domain to a file without a trailing newline', () => { + const text = '[image]\nmax_edge_px = 1500'; + const result = edit(text, 'thinking', undefined, { effort: 'high' }, { + image: { max_edge_px: 1500 }, + thinking: { effort: 'high' }, + }); + expect(result).toBe('[image]\nmax_edge_px = 1500\n[thinking]\neffort = "high"\n'); + }); + + it('removes a deleted domain region and keeps neighboring trivia', () => { + const text = ['# head', '[image]', 'max_edge_px = 1500', '', '# tail', '[tail]', 'x = 1', ''].join('\n'); + const result = edit(text, 'image', { max_edge_px: 1500 }, undefined, { tail: { x: 1 } }); + expect(result).toBe('# head\n\n# tail\n[tail]\nx = 1\n'); + }); + + it('replaces a multiline array statement wholesale', () => { + const text = 'override_models = [\n "a",\n "b",\n]\n'; + const result = edit(text, 'override_models', ['a', 'b'], ['a', 'c'], { override_models: ['a', 'c'] }); + expect(result).toBe('override_models = [ "a", "c" ]\n'); + }); + + it('declines to plan when the file contains constructs it cannot map', () => { + const text = '"weird key" = 1\n'; + expect(edit(text, 'weird_key', 1, 2, { weird_key: 2 })).toBeUndefined(); + }); + + it('declines to plan when the data and the text disagree about a domain', () => { + const text = '[image]\nmax_edge_px = 1500\n'; + expect(edit(text, 'image', undefined, { max_edge_px: 2000 }, { image: { max_edge_px: 2000 } })).toBeUndefined(); + }); + + it('preserves a quoted sub-table header and its comments on an unrelated write', () => { + const text = '[models."acme/m1"]\n# model note\nname = "m1"\n\n[image]\nmax_edge_px = 1500\n'; + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, { + models: { 'acme/m1': { name: 'm1' } }, + image: { max_edge_px: 2000 }, + }); + expect(result).toBe('[models."acme/m1"]\n# model note\nname = "m1"\n\n[image]\nmax_edge_px = 2000\n'); + }); + + it('preserves a literal-quoted sub-table header on an unrelated write', () => { + const text = "[models.'acme/m1']\nname = \"m1\"\n\n[image]\nmax_edge_px = 1500\n"; + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, { + models: { 'acme/m1': { name: 'm1' } }, + image: { max_edge_px: 2000 }, + }); + expect(result).toBe("[models.'acme/m1']\nname = \"m1\"\n\n[image]\nmax_edge_px = 2000\n"); + }); + + it('preserves a whitespace-padded quoted header and a quoted root key holding a dot', () => { + const text = '[ models . "acme/m1" ]\nname = "m1"\n\n["x.y"]\nv = 1\n\n[image]\nmax_edge_px = 1500\n'; + const result = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, { + models: { 'acme/m1': { name: 'm1' } }, + 'x.y': { v: 1 }, + image: { max_edge_px: 2000 }, + }); + expect(result).toBe('[ models . "acme/m1" ]\nname = "m1"\n\n["x.y"]\nv = 1\n\n[image]\nmax_edge_px = 2000\n'); + }); + + it('edits inside a quoted sub-table region at key level', () => { + const text = '[models."acme/m1"]\n# keep\nname = "m1"\nmax_context_size = 1000\n'; + const result = edit( + text, + 'models', + { 'acme/m1': { name: 'm1', max_context_size: 1000 } }, + { 'acme/m1': { name: 'm1x', max_context_size: 1000 } }, + { models: { 'acme/m1': { name: 'm1x', max_context_size: 1000 } } }, + ); + expect(result).toBe('[models."acme/m1"]\n# keep\nname = "m1x"\nmax_context_size = 1000\n'); + }); + + it('removes one quoted model entry and appends another with a quoted header', () => { + const text = '[models."acme/m1"]\nname = "m1"\n'; + const result = edit(text, 'models', { 'acme/m1': { name: 'm1' } }, { 'beta/m2': { name: 'm2' } }, { + models: { 'beta/m2': { name: 'm2' } }, + }); + expect(result).toBe('[models."beta/m2"]\nname = "m2"\n'); + }); + + it('handles escaped quotes inside quoted header segments', () => { + const text = '[models."a\\"b"]\nname = "m1"\n\n[image]\nmax_edge_px = 1500\n'; + const preserved = edit(text, 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, { + models: { 'a"b': { name: 'm1' } }, + image: { max_edge_px: 2000 }, + }); + expect(preserved).toBe('[models."a\\"b"]\nname = "m1"\n\n[image]\nmax_edge_px = 2000\n'); + const result = edit( + '[models."a\\"b"]\nname = "m1"\n', + 'models', + { 'a"b': { name: 'm1' } }, + { 'a"b': { name: 'm2' } }, + { models: { 'a"b': { name: 'm2' } } }, + ); + expect(result).toBe('[models."a\\"b"]\nname = "m2"\n'); + }); + + it('declines to plan on malformed quoted headers', () => { + const expected = { image: { max_edge_px: 2000 } }; + expect(edit('[""]\nv = 1\n', 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, expected)).toBeUndefined(); + expect( + edit('[models."unterminated]\nname = "m1"\n', 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, expected), + ).toBeUndefined(); + expect( + edit('[models."a\\qb"]\nname = "m1"\n', 'image', { max_edge_px: 1500 }, { max_edge_px: 2000 }, expected), + ).toBeUndefined(); + }); +}); + +describe('replaceThinkingEffortMax', () => { + it('replaces effort = "max" with "high" inside the thinking region', () => { + const text = '# thinking config\n[thinking]\n# do not touch\neffort = "max"\n'; + expect(replaceThinkingEffortMax(text)).toBe('# thinking config\n[thinking]\n# do not touch\neffort = "high"\n'); + }); + + it('keeps the trailing comment on the effort line', () => { + const text = '[thinking]\neffort = "max" # legacy\n'; + expect(replaceThinkingEffortMax(text)).toBe('[thinking]\neffort = "high" # legacy\n'); + }); + + it('handles CRLF files', () => { + const text = '[thinking]\r\neffort = "max"\r\n'; + expect(replaceThinkingEffortMax(text)).toBe('[thinking]\r\neffort = "high"\r\n'); + }); + + it('returns undefined when there is no single thinking region', () => { + expect(replaceThinkingEffortMax('[other]\nx = 1\n')).toBeUndefined(); + expect(replaceThinkingEffortMax('[thinking]\neffort = "high"\n[thinking]\neffort = "max"\n')).toBeUndefined(); + }); + + it('returns undefined when effort is not a plain "max" literal', () => { + expect(replaceThinkingEffortMax('[thinking]\neffort = "medium"\n')).toBeUndefined(); + expect(replaceThinkingEffortMax('[thinking]\neffort = """\nmax\n"""\n')).toBeUndefined(); + }); +}); + +describe('ConfigService key-level writeback', () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-v2-keyedit-')); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it('keeps an adjacent comment inside [image] when setting maxEdgePx', async () => { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + const seed = ['[image]', '# do not touch', 'max_edge_px = 1500', 'extra = "keep"', ''].join('\n'); + await storage.write('', 'config.toml', new TextEncoder().encode(seed)); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap(homeDir)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + + await config.set(IMAGE_SECTION, { maxEdgePx: 2000 }); + + const bytes = await storage.read('', 'config.toml'); + const text = new TextDecoder().decode(bytes!); + expect(text).toBe('[image]\n# do not touch\nmax_edge_px = 2000\nextra = "keep"\n'); + const parsed = parseToml(text) as Record; + expect(parsed['image']).toEqual({ max_edge_px: 2000, extra: 'keep' }); + expect(config.get(IMAGE_SECTION)).toEqual({ maxEdgePx: 2000 }); + + disposables.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/app/config/writeback.test.ts b/packages/agent-core-v2/test/app/config/writeback.test.ts new file mode 100644 index 00000000000..e4ab40dea4a --- /dev/null +++ b/packages/agent-core-v2/test/app/config/writeback.test.ts @@ -0,0 +1,193 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { parse as parseToml } from 'smol-toml'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; +import { IMAGE_SECTION, type ImageConfig } from '#/agent/media/configSection'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigRegistry, IConfigService } from '#/app/config/config'; +import { ConfigRegistry, ConfigService } from '#/app/config/configService'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; +import { type ThinkingConfig } from '#/kosong/model/thinking'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { stubLog } from '../../_base/log/stubs'; +import { stubBootstrap } from '../bootstrap/stubs'; + +describe('config.toml writeback preservation', () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-v2-writeback-')); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + async function setup(toml: string, env: Record = {}) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap(homeDir, env)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + const readText = async (): Promise => { + const bytes = await storage.read('', 'config.toml'); + if (bytes === undefined) throw new Error('config.toml missing'); + return new TextDecoder().decode(bytes); + }; + return { config, disposables, storage, readText }; + } + + function section(parsed: Record, key: string): Record { + return parsed[key] as Record; + } + + it('preserves comments, blank lines and untouched domains byte-for-byte on set()', async () => { + const seed = [ + '# 顶部注释:全局设置', + 'default_model = "kimi-k2" # 行尾注释', + '', + '# 图片配置区块', + '[image]', + 'max_edge_px = 1500', + '', + '# 自定义区域', + '[custom]', + 'notes = """', + '第一行', + '[not_a_header] 这一行以左括号开头', + '"""', + 'keep_me = "yes"', + '', + ].join('\n'); + const { config, disposables, readText } = await setup(seed); + + await config.set(IMAGE_SECTION, { maxEdgePx: 2000 }); + + const text = await readText(); + expect( + text.startsWith( + '# 顶部注释:全局设置\ndefault_model = "kimi-k2" # 行尾注释\n\n# 图片配置区块\n', + ), + ).toBe(true); + expect( + text.endsWith( + '\n# 自定义区域\n[custom]\nnotes = """\n第一行\n[not_a_header] 这一行以左括号开头\n"""\nkeep_me = "yes"\n', + ), + ).toBe(true); + const parsed = parseToml(text) as Record; + expect(section(parsed, 'image')['max_edge_px']).toBe(2000); + expect(parsed['default_model']).toBe('kimi-k2'); + expect(section(parsed, 'custom')['keep_me']).toBe('yes'); + expect(config.get(IMAGE_SECTION)).toEqual({ maxEdgePx: 2000 }); + + disposables.dispose(); + }); + + it('skips the write entirely when the staged result is byte-identical', async () => { + const { config, disposables, storage, readText } = await setup('[image]\nmax_edge_px = 1500\n'); + const writeSpy = vi.spyOn(storage, 'write'); + + await config.set(IMAGE_SECTION, { maxEdgePx: 2000 }); + const afterFirst = await readText(); + expect(writeSpy).toHaveBeenCalledTimes(1); + + await config.set(IMAGE_SECTION, { maxEdgePx: 2000 }); + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(await readText()).toBe(afterFirst); + + disposables.dispose(); + }); + + it('appends a new domain at the end with a single trailing newline', async () => { + const seed = '# 只有图片\n[image]\nmax_edge_px = 1500\n'; + const { config, disposables, readText } = await setup(seed); + + await config.set(THINKING_SECTION, { effort: 'high' }); + + const text = await readText(); + expect(text.startsWith(seed)).toBe(true); + expect(text.endsWith('\n')).toBe(true); + expect(text.endsWith('\n\n')).toBe(false); + const parsed = parseToml(text) as Record; + expect(section(parsed, 'thinking')['effort']).toBe('high'); + expect(config.get(THINKING_SECTION)).toEqual({ effort: 'high' }); + + disposables.dispose(); + }); + + it('removes a deleted domain region while keeping neighboring trivia', async () => { + const seed = [ + '# 头部注释', + '[thinking]', + 'effort = "high"', + '', + '# 图片注释', + '[image]', + 'max_edge_px = 1500', + '', + '# 尾部注释', + '[custom]', + 'keep_me = "yes"', + '', + ].join('\n'); + const { config, disposables, readText } = await setup(seed); + + await config.replace(IMAGE_SECTION, null); + + const text = await readText(); + expect(text.includes('[image]')).toBe(false); + expect(text.includes('max_edge_px')).toBe(false); + expect(text.includes('# 头部注释\n[thinking]\neffort = "high"\n')).toBe(true); + expect(text.includes('# 图片注释')).toBe(true); + expect(text.includes('# 尾部注释\n[custom]\nkeep_me = "yes"\n')).toBe(true); + const parsed = parseToml(text) as Record; + expect(parsed['image']).toBeUndefined(); + expect(section(parsed, 'thinking')['effort']).toBe('high'); + + disposables.dispose(); + }); + + it('preserves CRLF line endings in untouched regions', async () => { + const seed = '# 注释\r\ndefault_model = "kimi-k2"\r\n\r\n[image]\r\nmax_edge_px = 1500\r\n'; + const { config, disposables, readText } = await setup(seed); + + await config.set(IMAGE_SECTION, { maxEdgePx: 3000 }); + + const text = await readText(); + expect(text.startsWith('# 注释\r\ndefault_model = "kimi-k2"\r\n\r\n')).toBe(true); + const parsed = parseToml(text) as Record; + expect(section(parsed, 'image')['max_edge_px']).toBe(3000); + + disposables.dispose(); + }); + + it('migrates thinking effort max to high without dropping the surrounding comments', async () => { + const seed = '# 思考配置\n[thinking]\n# 不要动我\neffort = "max"\n'; + const { config, disposables, readText } = await setup(seed); + + const text = await readText(); + expect(text.includes('# 思考配置')).toBe(true); + expect(text.includes('effort = "high"')).toBe(true); + expect(text.includes('effort = "max"')).toBe(false); + expect(config.get(THINKING_SECTION)).toEqual({ effort: 'high' }); + + disposables.dispose(); + }); +});