Merge remote-tracking branch 'origin/farm/50987903/preserve-hashline-bom'
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user