feat(write): added snapshot header to write tool output in hashline mode

- Prepended `¶path#TAG` hashline header to plain file, ACP-bridge, and conflict resolution write results.
- Bulk conflict resolutions emit a trailing `Snapshots:` block with one header per written file.
- Suppressed when hashline display mode is disabled or for archive/SQLite/internal-URL targets.
- Added tests covering header presence, patcher usability, and disabled-mode suppression.
This commit is contained in:
can1357
2026-05-28 17:36:09 +02:00
parent cacf996f90
commit 31f3fbda61
4 changed files with 159 additions and 6 deletions
+1
View File
@@ -44,6 +44,7 @@ Single-shot result.
- Archive write: `Successfully wrote <bytes> bytes to <relative-archive-path>:<entry-path>`.
- SQLite write: one of `Inserted row into <table>`, `Updated row '<key>' in <table>`, `No row updated ...`, `Deleted row ...`, `No row deleted ...`.
- If hashline prefixes were copied from `read` output and stripped first, the first text block gets an extra note.
- In hashline display mode, plain file writes (including ACP bridge writes) and conflict resolutions prepend a fresh `¶<relative-path>#TAG` header so the next `edit` has a current snapshot tag without an extra `read`. Bulk conflict resolutions append a `Snapshots:` block listing one header per successfully written file.
- Plain file writes may also return `details.diagnostics` plus `details.meta.diagnostics` when LSP diagnostics-on-write is enabled.
- SQLite writes use `toolResult(...).sourcePath(...)`, so `details.meta.sourcePath` points at the database file.
- Archive writes return empty `details`.
+1
View File
@@ -7,6 +7,7 @@
- Added `SqlSessionStorage`, a `bun:sql`-backed implementation of `SessionStorage` that persists session JSONL into PostgreSQL, MySQL/MariaDB, or SQLite. Pass a connected `Bun.SQL` instance (the constructor accepts `postgres://`, `mysql://`, or `sqlite:` URLs) to `SqlSessionStorage.create({ client, table?, adapter?, createTable? })` and hand the returned storage to any `SessionManager` factory. The dialect is auto-detected from `client.options.adapter` and used to pick the correct DDL plus upsert-with-append syntax (`ON CONFLICT … DO UPDATE` for PG/SQLite, `ON DUPLICATE KEY UPDATE` for MySQL), so the agent's append-only persist pattern works in a single round-trip per line. Same in-memory mirror and `drain()` semantics as the Redis backend; blobs and tool artifacts still live on disk via `ArtifactManager`/`BlobStore`.
- Added `RedisSessionStorage`, a `bun:redis`-backed implementation of the `SessionStorage` interface that lets API consumers route session JSONL through Redis instead of local disk. Pass a connected `Bun.RedisClient` (or any compatible adapter) to `RedisSessionStorage.create({ client, prefix? })` and hand the returned storage to `SessionManager.create(cwd, sessionDir, storage)` (or any other static factory that accepts a storage argument). An in-memory mirror is loaded on creation so the interface's synchronous methods (`existsSync`, `statSync`, `listFilesSync`, …) keep their contracts; `drain()` waits for queued background writes. Tool artifacts and image blobs still live on disk via `ArtifactManager`/`BlobStore` — Redis only owns the session JSONL keyspace under the configured prefix.
- Exported the `SessionStorage` / `SessionStorageWriter` / `FileSessionStorage` / `MemorySessionStorage` symbols (already reachable via the `./session/session-storage` subpath) from the package root so SDK consumers can construct alternative storage backends without deep-importing.
- Added a fresh `¶<relative-path>#TAG` snapshot header to the `write` tool's success text in hashline display mode, covering plain disk writes, ACP-bridge writes, and conflict resolutions (bulk resolutions emit a trailing `Snapshots:` block with one header per successfully written file). The header records a current snapshot in the file-snapshot store so the next `edit` can land without an extra `read` round-trip. Suppressed when the session is not in hashline mode and skipped for archive/SQLite writes and host-managed internal URL targets where hashline anchors do not apply.
## [15.5.10] - 2026-05-28
+40 -6
View File
@@ -2,13 +2,15 @@ import { Database } from "bun:sqlite";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { stripHashlinePrefixes } from "@oh-my-pi/hashline";
import { formatHashlineHeader, stripHashlinePrefixes } from "@oh-my-pi/hashline";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
import { normalizeToLF } from "../edit/normalize";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import { parseInternalUrl } from "../internal-urls/parse";
@@ -116,6 +118,24 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
return stripWriteContentWithPotentialLooseHeader(content.split("\n"));
}
/**
* Record a snapshot of the freshly-written `content` for `absolutePath`
* so subsequent hashline edits address the new file with a current tag,
* and return the matching `¶displayPath#TAG` header. Returns `undefined`
* when the session is not in hashline mode so callers can no-op cheaply.
*
* Mirrors the post-commit snapshot recording the hashline patcher performs
* after a successful edit: the model gets a tag without an extra `read`.
*/
function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined {
if (!resolveFileDisplayMode(session).hashLines) return undefined;
const normalized = normalizeToLF(content);
const tag = getFileSnapshotStore(session).recordContiguous(absolutePath, 1, normalized.split("\n"), {
fullText: normalized,
});
return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
}
/**
* Append a trailing note line to the first text block of a tool result.
* Mutates `result` in place (the result object is owned by this call).
@@ -540,11 +560,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
this.session.fileSnapshotStore?.invalidate(absolutePath);
this.session.conflictHistory?.invalidate(entry.id);
const header = maybeWriteSnapshotHeader(this.session, absolutePath, newContent);
const range =
entry.startLine === entry.endLine
? `line ${entry.startLine}`
: `lines ${entry.startLine}\u2013${entry.endLine}`;
let resultText = `Resolved conflict #${entry.id} at ${range} in ${entry.displayPath}.`;
const summary = `Resolved conflict #${entry.id} at ${range} in ${entry.displayPath}.`;
let resultText = header ? `${header}\n${summary}` : summary;
if (stripped) {
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
}
@@ -624,7 +646,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
const batchRequest = getLspBatchRequest(context?.toolCall);
const allDiagnostics: FileDiagnosticsResult[] = [];
const succeededFiles: { displayPath: string; count: number }[] = [];
const succeededFiles: { displayPath: string; count: number; header?: string }[] = [];
const failedFiles: { displayPath: string; count: number; error: string }[] = [];
let totalResolvedIds = 0;
@@ -661,7 +683,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
invalidateFsScanAfterWrite(absolutePath);
this.session.fileSnapshotStore?.invalidate(absolutePath);
for (const entry of fileEntries) history.invalidate(entry.id);
succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length });
const header = maybeWriteSnapshotHeader(this.session, absolutePath, text);
succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length, header });
totalResolvedIds += fileEntries.length;
if (diagnostics) allDiagnostics.push(diagnostics);
}
@@ -685,6 +708,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
summaryLines.push(` ${file.displayPath}: ${file.count} ${conflictWord(file.count)} (${file.error})`);
}
}
const headerLines = succeededFiles
.map(file => file.header)
.filter((header): header is string => header !== undefined);
if (headerLines.length > 0) {
summaryLines.push("Snapshots:");
for (const header of headerLines) summaryLines.push(` ${header}`);
}
if (stripped) {
summaryLines.push("Note: auto-stripped hashline display prefixes from content before writing.");
}
@@ -813,7 +843,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
}
invalidateFsScanAfterWrite(absolutePath);
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
let resultText = header ? `${header}\n${writeLine}` : writeLine;
if (stripped) {
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
}
@@ -825,7 +857,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
let resultText = header ? `${header}\n${writeLine}` : writeLine;
if (stripped) {
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
}
@@ -0,0 +1,117 @@
import { afterEach, beforeAll, beforeEach, 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 { Patch, Patcher } from "@oh-my-pi/hashline";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getFileSnapshotStore } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store";
import { HashlineFilesystem } from "@oh-my-pi/pi-coding-agent/edit/hashline/filesystem";
import { writethroughNoop } from "@oh-my-pi/pi-coding-agent/lsp";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
function createSession(cwd: string): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => path.join(cwd, "session.jsonl"),
getSessionSpawns: () => "*",
getArtifactsDir: () => path.join(cwd, "artifacts"),
allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }),
settings: Settings.isolated(),
enableLsp: false,
};
}
function resultText(result: { content: { type: string; text?: string }[] }): string {
return result.content
.filter((b): b is { type: "text"; text: string } => b.type === "text" && typeof b.text === "string")
.map(b => b.text)
.join("\n");
}
const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{3})$/;
describe("write tool hashline header", () => {
let tmpDir: string;
beforeAll(async () => {
await Settings.init({ inMemory: true });
});
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-hashline-test-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("prepends a fresh ¶path#TAG header that maps to the written content", async () => {
const filePath = path.join(tmpDir, "module.ts");
const session = createSession(tmpDir);
const tool = new WriteTool(session);
const content = "export const value = 42;\nexport const flag = true;\n";
const result = await tool.execute("call-1", { path: filePath, content });
const lines = resultText(result).split("\n");
// First line is the hashline header; subsequent text is the byte count.
const match = HASHLINE_HEADER_LINE.exec(lines[0] ?? "");
expect(match).not.toBeNull();
const [, headerPath, tag] = match!;
expect(headerPath).toBe(path.relative(tmpDir, filePath));
expect(lines[1]).toBe(`Successfully wrote ${content.length} bytes to ${headerPath}`);
// The tag must address a snapshot whose content matches what we wrote so a
// follow-up edit can land without an extra `read` round-trip.
const snapshot = getFileSnapshotStore(session).byHash(filePath, tag!);
expect(snapshot).not.toBeNull();
expect(snapshot?.fullText).toBe(content);
});
it("makes the post-write tag usable by the hashline patcher", async () => {
const filePath = path.join(tmpDir, "config.ts");
const session = createSession(tmpDir);
const tool = new WriteTool(session);
const content = "export const enabled = false;\n";
const writeResult = await tool.execute("call-1", { path: filePath, content });
const headerLine = resultText(writeResult).split("\n")[0] ?? "";
expect(HASHLINE_HEADER_LINE.test(headerLine)).toBe(true);
// Apply a hashline patch immediately, using only the tag the write tool
// returned — no intervening `read`.
const patchInput = `${headerLine}\n1 1\n+export const enabled = true;\n`;
const patch = Patch.parse(patchInput, { cwd: tmpDir });
expect(patch.sections).toHaveLength(1);
const filesystem = new HashlineFilesystem({
session,
writethrough: writethroughNoop,
beginDeferredDiagnosticsForPath: () => {
throw new Error("deferred diagnostics unused with writethroughNoop");
},
});
const patcher = new Patcher({ fs: filesystem, snapshots: getFileSnapshotStore(session) });
const prepared = await patcher.prepare(patch.sections[0]!);
const sectionResult = await patcher.commit(prepared);
expect(sectionResult.op).toBe("update");
const final = await fs.readFile(filePath, "utf8");
expect(final).toBe("export const enabled = true;\n");
});
it("omits the hashline header when hashLines display mode is disabled", async () => {
const filePath = path.join(tmpDir, "plain.txt");
const session = createSession(tmpDir);
session.settings.set("readHashLines", false);
const tool = new WriteTool(session);
const content = "no anchors here\n";
const result = await tool.execute("call-1", { path: filePath, content });
const text = resultText(result);
expect(text.startsWith("¶")).toBe(false);
expect(text).toBe(`Successfully wrote ${content.length} bytes to ${path.relative(tmpDir, filePath)}`);
});
});