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:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user