diff --git a/docs/tools/write.md b/docs/tools/write.md index 5b4047ba0..af6bdf441 100644 --- a/docs/tools/write.md +++ b/docs/tools/write.md @@ -44,6 +44,7 @@ Single-shot result. - Archive write: `Successfully wrote bytes to :`. - SQLite write: one of `Inserted row into `, `Updated row '' in
`, `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 `¶#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`. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0d12c6026..08ece2def 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `¶#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 diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 3f18bfec3..0ef9e588e 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -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 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 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)}`); + }); +});