Merge remote-tracking branch 'origin/farm/50987903/preserve-hashline-bom'

This commit is contained in:
can1357
2026-06-30 11:40:23 +02:00
7 changed files with 118 additions and 1 deletions
+3
View File
@@ -18,6 +18,9 @@
- Fixed auto-compaction dead-ending with "Compaction freed too little context to make progress" when a single recent turn (large tool output, heavy fenced/XML block) is itself bigger than the recovery band — `findCutPoint` can't cut inside one message, so the summarizer had no lever left. The guard now runs an artifact-backed `shake` elide pass over the oversized tail and re-tests headroom before pausing, and the remaining warning points at `/shake images` for image-only tails it can't elide. ([#3786](https://github.com/can1357/oh-my-pi/issues/3786))
- Fixed reviewer/`task` subagents whose incremental `yield` (`type: ["overall_correctness"]`, `type: ["findings"]`, …) carried a value that mismatched the matching property's sub-schema being silently accepted and then post-mortem rejected with `schema_violation` — opaquely swapping the agent's accepted output for an error blob. The yield tool now validates each incremental section's `data` against its top-level property's sub-schema (items schema for array-typed labels) and surfaces the same retry feedback as terminal yields, so models like `deepseek-v4-pro` that emit `"Correct"`/`"correct."`/`"approved"` for an enum field get up to three corrective retries; the existing `MAX_SCHEMA_RETRIES` override then accepts the value with `SUBAGENT_WARNING_SCHEMA_OVERRIDDEN` instead of losing the entire result. Unknown labels stay unconstrained ([#3870](https://github.com/can1357/oh-my-pi/issues/3870)).
- Fixed streaming tool-call previews (notably `write`) showing an empty body for the entire streaming phase by surfacing the partial JSON already in hand on the first reveal, then pacing only subsequent growth ([#3881](https://github.com/can1357/oh-my-pi/issues/3881)).
### Fixed
- Fixed hashline edit mode preserving UTF-8 BOM bytes on edited files. ([#3867](https://github.com/can1357/oh-my-pi/issues/3867))
## [16.2.7] - 2026-06-30
@@ -28,6 +28,7 @@ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
import { isInternalUrlPath } from "../../tools/path-utils";
import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard";
import { canonicalSnapshotKey } from "../file-snapshot-store";
import { isNotebookPath } from "../notebook";
import { readEditFileText, serializeEditFileText } from "../read-file";
import type { LspBatchRequest } from "../renderer";
@@ -123,6 +124,17 @@ export class HashlineFilesystem extends Filesystem {
return content;
}
async readBinary(relativePath: string): Promise<Uint8Array | undefined> {
const absolutePath = this.resolveAbsolute(relativePath);
if (isNotebookPath(absolutePath)) return undefined;
try {
return await fs.readFile(absolutePath);
} catch (error) {
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
throw error;
}
}
async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise<void> {
const fileOp = options?.fileOp;
if (fileOp?.kind === "rem") {
@@ -111,6 +111,56 @@ describe("hashline executor", () => {
});
});
it("preserves UTF-8 BOM bytes when hashline edits decoded text", async () => {
await withTempDir(async tempDir => {
const filePath = path.join(tempDir, "Program.cs");
const source = "using A;\n";
await Bun.write(filePath, new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(source)]));
const session = makeHashlineSession(tempDir);
const sourceTag = recordFullSnapshot(getFileReadCache(session), filePath, source);
const input = `${header("Program.cs", sourceTag)}\n${sameLineRange(tag(1, source))}\n${repl("using B;")}\n`;
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session));
const bytes = await fs.readFile(filePath);
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
expect(new TextDecoder().decode(bytes.subarray(3))).toBe("using B;\n");
});
});
it("edits BOM-prefixed notebooks through the virtual cell text", async () => {
await withTempDir(async tempDir => {
const filePath = path.join(tempDir, "notebook.ipynb");
const notebook = {
cells: [
{
cell_type: "markdown",
metadata: { keep: true },
source: ["# Title\n"],
},
],
metadata: {},
nbformat: 4,
nbformat_minor: 5,
};
await Bun.write(
filePath,
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(JSON.stringify(notebook))]),
);
const session = makeHashlineSession(tempDir);
const editableText = "# %% [markdown] cell:0\n# Title\n";
const sourceTag = recordFullSnapshot(getFileReadCache(session), filePath, editableText);
const input = `${header("notebook.ipynb", sourceTag)}\n${sameLineRange(tag(2, "# Title"))}\n${repl("# Updated")}\n`;
await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session));
const updated = await Bun.file(filePath).json();
expect(updated.cells).toHaveLength(1);
expect(updated.cells[0].source).toEqual(["# Updated\n"]);
expect(updated.cells[0].metadata).toEqual({ keep: true });
});
});
it("emits an actionable no-op diagnostic when the payload matches the file byte-for-byte", async () => {
await withTempDir(async tempDir => {
const filePath = path.join(tempDir, "a.ts");
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed hashline writes preserving UTF-8 BOM bytes when the host text decoder hides the leading `U+FEFF`. ([#3867](https://github.com/can1357/oh-my-pi/issues/3867))
## [16.2.6] - 2026-06-29
### Fixed
+12
View File
@@ -65,6 +65,9 @@ export abstract class Filesystem {
/** Read the file's full text content. Throw on missing file. */
abstract readText(path: string): Promise<string>;
/** Read raw bytes for backends whose text is a direct decode of persisted bytes. */
readBinary?(path: string): Promise<Uint8Array | undefined>;
/** Validate that `path` is writable before a prepared batch starts committing. */
async preflightWrite(_path: string, _options?: PreflightWriteOptions): Promise<void> {}
@@ -196,6 +199,15 @@ export class NodeFilesystem extends Filesystem {
return file.text();
}
async readBinary(path: string): Promise<Uint8Array> {
try {
return await fs.readFile(path);
} catch (error) {
if (isNotFound(error)) throw new NotFoundError(path, error);
throw error;
}
}
async writeText(path: string, content: string): Promise<WriteResult> {
await Bun.write(path, content);
return { text: content };
+12 -1
View File
@@ -148,6 +148,10 @@ function mergeWarnings(...sources: ReadonlyArray<readonly string[] | undefined>)
return out;
}
function hasUtf8Bom(bytes: Uint8Array | undefined): boolean {
return bytes !== undefined && bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
}
function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
const seen = new Map<string, string>();
for (const entry of prepared) {
@@ -295,7 +299,8 @@ export class Patcher {
throw new Error(`MV destination is the same as ${target.path}.`);
}
const { bom, text } = stripBom(read.rawContent);
const { bom: bomFromText, text } = stripBom(read.rawContent);
const bom = bomFromText || (await this.#readBinaryBom(target.path));
const lineEnding = detectLineEnding(text);
const normalized = normalizeToLF(text);
@@ -453,6 +458,12 @@ export class Patcher {
};
}
async #readBinaryBom(path: string): Promise<string> {
if (!this.fs.readBinary) return "";
const bytes = await this.fs.readBinary(path);
return hasUtf8Bom(bytes) ? "\uFEFF" : "";
}
async #tryRead(path: string): Promise<{ exists: boolean; rawContent: string }> {
try {
const content = await this.fs.readText(path);
+25
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
computeFileHash,
formatHashlineHeader,
HEADTAIL_DRIFT_WARNING,
InMemoryFilesystem,
InMemorySnapshotStore,
MismatchError,
NodeFilesystem,
Patch,
Patcher,
} from "@oh-my-pi/hashline";
@@ -33,6 +38,26 @@ describe("Patcher snapshot tag integrity", () => {
expect(fs.get(PATH)).toBe("after\n");
});
it("restores a UTF-8 BOM hidden by Bun text decoding", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-bom-"));
try {
const filePath = path.join(tempDir, "Program.cs");
const source = "using A;\n";
await Bun.write(filePath, new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(source)]));
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(filePath, source);
const patch = Patch.parse([formatHashlineHeader(filePath, tag), "SWAP 1.=1:", "+using B;"].join("\n"));
await new Patcher({ fs: new NodeFilesystem(), snapshots }).apply(patch);
const bytes = await fs.readFile(filePath);
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
expect(new TextDecoder().decode(bytes.subarray(3))).toBe("using B;\n");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("validates any anchor purely from the content hash, even with no recorded snapshot", async () => {
// The core fix: the tag fingerprints the WHOLE file. An edit anchored at
// a line the model never saw recorded applies whenever the live file