diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cc930f4d9f..46e468eddb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ - Added the `commandcode-goat` model profile for the Command Code GOAT provider, assigning GLM-5.3 to the default role, DeepSeek V4 Flash to execution, Kimi K3 to planning, GLM-5.2 to criticism, and DeepSeek V4 Pro to architecture. - Session endpoints hosted on the notification-adapter transport now deliver every ring-retained session event live to attached SDK subscribers as the same positioned `event` envelope (`generation`/`seq`) that `event_replay` returns, sent per connection over the validated directed leg with the same capability gating replay applies. Previously the live leg only pushed raw side-channel frames — the native broadcast enum reduced non-native kinds (including terminal `agent_end` lifecycle) to empty `unknown` frames, and correlated lifecycle reached only the submitting connection — so an already-attached direct SDK subscriber could observe a later positioned event, including a turn's terminal lifecycle, only by issuing another replay. Each connection's directed writer now bounds queued host frames to the replay-ring capacity; a lagged subscriber rejects additional best-effort live sends and recovers through replay (including the existing sequence-gap contract) instead of growing an unbounded backlog. Ring persistence, replay ordering, event positions, correlated requester delivery, and native notification frames are unchanged. +- Persisted edit results no longer inline complete pre/post-edit file snapshots. `EditToolDetails.oldText`/`newText` (and each `perFileResults[]` copy) larger than 16 KiB are replaced at the session-persistence boundary by a fixed-size digest receipt (`oldTextDigest`/`newTextDigest`: UTF-8 byte length + SHA-256) plus an externalization marker, so a tiny `apply_patch` to a large file costs bytes proportional to its diff instead of ~2x the file size in the managed transcript (#4566). Live in-process results still carry full bodies for ACP `diff` ToolCallContent and editors; sub-16 KiB snapshots persist verbatim; diffs, paths, ops, first-changed-line, diagnostics, and meta are unchanged. A regression suite pins bounding, inline sub-cap behavior, multi-file copies, and the near-limit committed-edit durability contract. +- Near-limit managed appends are now a typed, deterministic outcome instead of a silent recovery or an unclassified abort. When a live append crosses the 128 MiB managed per-file cap, the existing full-rewrite recovery runs and is then verified: if the recovered transcript still cannot hold the entry (or the rewrite itself hits `content_too_large`), the append throws `SessionNearLimitAppendError` carrying structured fields (`code: "near_limit_append"`, entry/live/cap bytes, whether the entry is retained in memory) and a message stating whether the committed edit's receipt is preserved and how to continue (`/compact` or `gjc export`). `AgentSession` maps it to a structured tool-result outcome instead of a generic fatal `SessionAppendPersistenceError`, so a committed source mutation can no longer lose its receipt silently (#4566). - Telegram notification delivery now carries an explicit per-update inbound acknowledgement contract: user messages are acked `accepted` at session preflight acceptance (before the turn starts, so a fast turn can no longer out-race the pending-update registration), late admission failures ack `rejected`, and genuinely discarded frames ack `dropped`. Policy-suspended control commands are deferred to activation instead of being acked as dropped, per-update reaction transitions are serialized with terminal states monotonic (a slow queued 👀 can no longer overwrite a later ✅), and retraction sends the empty reaction list the Bot API requires. Daemon generation bumped 167→168. (#4528) - Managed fallback local snapshot failures now surface their one producer-boundary diagnostic immediately instead of re-issuing the identical request up to three times. The failure still never charges the provider fallback chain, advances models, or mutates credentials. diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 4b896b5793..3dbc0d9733 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -2,6 +2,7 @@ * Edit tool renderer and LSP batching helpers. */ +import { createHash } from "node:crypto"; import type { Component } from "@gajae-code/tui"; import { Text, visibleWidth, wrapTextWithAnsi } from "@gajae-code/tui"; import { sanitizeText } from "@gajae-code/utils"; @@ -85,6 +86,45 @@ export interface EditToolDetails { newText?: string; } +/** + * Bounded durable identity of one edit snapshot (#4566). + * + * Live edit results carry full `oldText`/`newText` file bodies so in-process + * consumers (ACP `diff` ToolCallContent, editors) keep working. Every persisted + * transcript entry replaces those bodies with this fixed-size receipt: byte + * length plus SHA-256 content digest, enough to detect source drift and to + * account for the edit durably without re-writing the whole file per edit. + */ +export interface EditSnapshotReceipt { + /** UTF-8 byte length of the snapshot (`0` for create/delete-absent sides). */ + bytes: number; + /** SHA-256 hex digest of the exact snapshot text (empty string for length 0). */ + sha256: string; +} + +/** Per-edit-mode cap on any single persisted edit-result string field (#4566). */ +export const EDIT_PERSIST_FIELD_MAX_CHARS = 16 * 1024; + +/** Fixed marker used when a snapshot receipt replaces a full body. */ +export const EDIT_SNAPSHOT_EXTERNALIZED_NOTICE = + "[edit snapshot externalized: see oldTextDigest/newTextDigest; full body omitted from transcript]"; + +function sha256Hex(text: string): string { + if (text.length === 0) return ""; + return createHash("sha256").update(Buffer.from(text, "utf-8")).digest("hex"); +} + +/** Build the bounded durable receipt for one snapshot body. */ +export function editSnapshotReceipt(text: string | undefined): EditSnapshotReceipt | undefined { + if (text === undefined) return undefined; + return { bytes: Buffer.byteLength(text, "utf-8"), sha256: sha256Hex(text) }; +} + +/** True when a snapshot body is small enough to persist inline without amplification. */ +export function editSnapshotPersistableInline(text: string | undefined): boolean { + return text !== undefined && text.length <= EDIT_PERSIST_FIELD_MAX_CHARS; +} + // ═══════════════════════════════════════════════════════════════════════════ // TUI Renderer // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 291c863f87..8b41250a58 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -465,6 +465,7 @@ import { SessionAppendPersistenceError, SessionContextTooLargeError, SessionManager, + SessionNearLimitAppendError, transferSessionMessageIdentity, } from "./session-manager"; import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal"; @@ -5015,6 +5016,47 @@ export class AgentSession { try { this.sessionManager.appendMessage(event.message); } catch (error) { + // Typed near-limit append (#4566): the transcript hit the managed + // per-file cap and even the live-entry rewrite could not hold this + // entry. The edit/effect itself may already be committed; surface a + // structured tool-result outcome stating that and the exact + // continuation path instead of a generic fatal abort. The typed + // error already performed deterministic recovery, so the session is + // not poisoned and the turn ends with actionable state. + if (error instanceof SessionNearLimitAppendError) { + this.agent.abort(); + if (event.message.role === "toolResult") { + event.message.isError = true; + const committed = error.entryRetained + ? "The edit committed and its receipt is retained in the live session; it will persist on the next successful write." + : "The edit committed on disk but its receipt could not be retained in the live session."; + event.message.content = [ + { + type: "text", + text: [ + "Session transcript reached the managed per-file limit; this result could not be recorded durably.", + committed, + "Continue by compacting the session (`/compact`) or exporting to a fresh session (`gjc export `); re-verify the edited file before relying on it.", + ].join("\n"), + }, + ]; + event.message.details = { + ...(event.message.details && typeof event.message.details === "object" + ? event.message.details + : {}), + failureKind: "persistence", + nearLimitAppend: { + code: error.code, + entryBytes: error.entryBytes, + liveBytes: error.liveBytes, + capBytes: error.capBytes, + entryRetained: error.entryRetained, + }, + }; + this.agent.touchContext(); + } + return; + } if ( event.message.role !== "toolResult" || event.message.toolName !== "todo_write" || diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 6ac43fb4d0..60d7e7ead3 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -40,6 +40,7 @@ import { Snowflake, toError, } from "@gajae-code/utils"; +import { EDIT_SNAPSHOT_EXTERNALIZED_NOTICE, editSnapshotReceipt } from "../edit/renderer"; import type { TtsrInjectionRecord } from "../export/ttsr"; import { assertSafePathComponent } from "../gjc-runtime/session-layout"; import { writeTextAtomic } from "../gjc-runtime/state-writer"; @@ -2045,6 +2046,49 @@ export class SessionAppendPersistenceError extends Error { } } +/** + * Typed near-limit append outcome (#4566). + * + * A live managed append that would cross the per-file transcript cap is now + * preflighted: when the append alone cannot fit even after a full rewrite of + * the live entries, this deterministic error replaces the generic + * `SessionAppendPersistenceError: content_too_large` abort. It states whether + * the in-memory entry was kept (so the just-committed source mutation keeps + * its receipt on the next successful persist) and how to continue. + */ +export class SessionNearLimitAppendError extends Error { + readonly code = "near_limit_append" as const; + /** Serialized size (bytes) of the entry that could not fit. */ + readonly entryBytes: number; + /** Live-entry rewrite size (bytes) the recovery already attempted. */ + readonly liveBytes: number; + /** Managed per-file cap in force when the append was rejected. */ + readonly capBytes: number; + /** True when the entry remains in the resident list awaiting the next persist. */ + readonly entryRetained: boolean; + + constructor(details: { + entryBytes: number; + liveBytes: number; + capBytes: number; + entryRetained: boolean; + }) { + super( + [ + `near_limit_append: entry (${details.entryBytes} B) plus live transcript (${details.liveBytes} B) exceeds the managed per-file limit (${details.capBytes} B).`, + details.entryRetained + ? "The appended entry is retained in memory; its effect (including any committed source edit) is recorded and will persist on the next successful write. Compact the session (`/compact`) or export to a fresh session (`gjc export `) before continuing." + : "The appended entry was rolled back from memory; re-issue it after compacting the session (`/compact`) or exporting to a fresh session (`gjc export `).", + ].join(" "), + ); + this.name = "SessionNearLimitAppendError"; + this.entryBytes = details.entryBytes; + this.liveBytes = details.liveBytes; + this.capBytes = details.capBytes; + this.entryRetained = details.entryRetained; + } +} + export class SessionManagedStorageError extends Error { readonly code = "managed_storage_unsupported"; @@ -4634,6 +4678,12 @@ async function movePathAcrossDevicesSafe(source: string, destination: string): P const MAX_PERSIST_CHARS = 500_000; const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]"; +/** + * Inline cap for edit-result snapshot bodies (`EditToolDetails.oldText` / + * `.newText` and per-file copies). Bodies up to this size persist verbatim; + * larger ones persist as a digest receipt (#4566). + */ +const EDIT_SNAPSHOT_INLINE_MAX_CHARS = 16 * 1024; /** Minimum base64 length to externalize to blob store (skip tiny inline images) */ const BLOB_EXTERNALIZE_THRESHOLD = 1024; const TEXT_CONTENT_KEY = "content"; @@ -4761,6 +4811,77 @@ function truncateString(value: string, maxLength: number): string { return truncated; } +/** + * Bound durable edit-result snapshot bodies (#4566). + * + * A tiny edit to a large file used to persist the complete pre- and post-edit + * file bodies in `EditToolDetails.oldText`/`newText` (and each + * `perFileResults[]` copy), so every patch added ~2x file size to the managed + * transcript and long sessions hit the 64 MiB per-file append limit mid-turn. + * + * Persisted edit results keep full bodies only under the inline cap; larger + * bodies are replaced by a fixed-size receipt (`oldTextDigest`/`newTextDigest` + * = byte length + SHA-256) plus a marker, so rendering, diagnostics, diffs, + * paths, and source-change accounting stay intact while the durable cost per + * edit is bounded independently of file size. Live in-process results are + * untouched — ACP `diff` ToolCallContent still receives full bodies. + */ +function boundEditSnapshotFields(value: unknown, visited: WeakSet): unknown { + if (typeof value !== "object" || value === null) return value; + if (visited.has(value)) return value; + visited.add(value); + + const boundOne = (entry: Record): Record => { + let changed = false; + const next: Record = { ...entry }; + const pairs: Array<["oldText", "oldTextDigest"] | ["newText", "newTextDigest"]> = [ + ["oldText", "oldTextDigest"], + ["newText", "newTextDigest"], + ]; + for (const [bodyKey, digestKey] of pairs) { + const body = entry[bodyKey]; + if (typeof body !== "string" || body.length <= EDIT_SNAPSHOT_INLINE_MAX_CHARS) continue; + const receipt = editSnapshotReceipt(body); + if (receipt === undefined) continue; + next[digestKey] = receipt; + next[bodyKey] = EDIT_SNAPSHOT_EXTERNALIZED_NOTICE; + changed = true; + } + return changed ? next : entry; + }; + + if (Array.isArray(value)) { + let changed = false; + const result: unknown[] = new Array(value.length); + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const bounded = + typeof item === "object" && item !== null && !Array.isArray(item) + ? boundOne(item as Record) + : item; + result[i] = bounded === item ? boundEditSnapshotFields(item, visited) : bounded; + if (result[i] !== item) changed = true; + } + return changed ? result : value; + } + + for (const [key, child] of Object.entries(value)) { + if (child !== null && typeof child === "object" && !Array.isArray(child)) { + const boundedChild = boundOne(child as Record); + if (boundedChild !== child) { + return { ...value, [key]: boundedChild }; + } + } + } + for (const [key, child] of Object.entries(value)) { + const bounded = boundEditSnapshotFields(child, visited); + if (bounded !== child) { + return { ...(value as Record), [key]: bounded }; + } + } + return boundOne(value as Record); +} + function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } { return ( typeof value === "object" && @@ -5594,7 +5715,10 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: } async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise { - return truncateForPersistence(entry, blobStore); + // Bound edit snapshots before the generic 500k string truncation so the + // receipt hashes/lengths identify the exact source bodies, not truncated + // prefixes of files larger than MAX_PERSIST_CHARS (#4566). + return (await truncateForPersistence(boundEditSnapshotFieldsForEntry(entry), blobStore)) as FileEntry; } /** @@ -5699,7 +5823,27 @@ function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: st } function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry { - return truncateForPersistenceSync(entry, blobStore) as FileEntry; + // Keep this ordering identical to the async path: snapshot receipts must be + // computed from the complete body before generic persistence truncation. + return truncateForPersistenceSync(boundEditSnapshotFieldsForEntry(entry), blobStore) as FileEntry; +} + +/** + * Apply {@link boundEditSnapshotFields} to a persisted entry's edit-result + * details. Only `message` entries with `role === "toolResult"` and a `details` + * object can carry edit snapshots; everything else returns unchanged, so the + * walk never touches unrelated entries (#4566). + */ +function boundEditSnapshotFieldsForEntry(entry: FileEntry): FileEntry { + if (entry.type !== "message") return entry; + const message = entry.message; + if (message === null || typeof message !== "object" || (message as { role?: unknown }).role !== "toolResult") + return entry; + const details = (message as { details?: unknown }).details; + if (details === null || typeof details !== "object" || Array.isArray(details)) return entry; + const bounded = boundEditSnapshotFields(details, new WeakSet()); + if (bounded === details) return entry; + return { ...entry, message: { ...(message as object), details: bounded } } as FileEntry; } class NdjsonFileWriter { @@ -16076,9 +16220,62 @@ export class SessionManager { // in-memory entries, shrinking the file below the limit. The entry has // already been added to #fileEntries by #appendEntryWithinPersistenceFence. if (err instanceof Error && err.message === "content_too_large") { - this.#rewriteFileSync(); + // Typed near-limit contract (#4566): recover by rewriting only the + // live in-memory entries, then verify the recovered file actually + // holds the just-appended entry. When even the rewrite cannot fit + // the entry (live content alone is at the cap), surface the typed + // near-limit outcome instead of silently succeeding without the + // receipt for an effect that already committed (e.g. a source edit). + const entryBytes = (() => { + try { + const materialized = materializeResidentEntryForPersistenceSync( + entry, + this.#residentBlobStores(), + new Map(), + ); + return Buffer.byteLength( + `${JSON.stringify(prepareEntryForPersistenceSync(materialized, this.#blobStore))}\n`, + "utf8", + ); + } catch { + return 0; + } + })(); + const liveBytesBefore = this.getTranscriptFileBytes(); + try { + this.#rewriteFileSync(); + } catch (rewriteError) { + // The recovery rewrite itself failed. The appended entry stays in + // the resident list (its effect, including any committed source + // edit, is not lost), but the receipt is not durable yet: report + // the typed near-limit outcome instead of an unclassified abort. + if (rewriteError instanceof Error && rewriteError.message === "content_too_large") { + this.#needsFullRewriteOnNextPersist = true; + throw new SessionNearLimitAppendError({ + entryBytes, + liveBytes: liveBytesBefore, + capBytes: MANAGED_ARTIFACT_MAX_FILE_BYTES, + entryRetained: this.#byId.has(entry.id), + }); + } + throw rewriteError; + } if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile); this.#readOnlyResume = false; + // Post-rewrite verification: a rewrite that still cannot fit the + // entry leaves an effect/receipt gap and must be reported, never + // silently swallowed as a successful append. + const liveBytesAfter = this.getTranscriptFileBytes(); + const entryRetained = liveBytesAfter <= MANAGED_ARTIFACT_MAX_FILE_BYTES && this.#byId.has(entry.id); + if (!entryRetained) { + this.#needsFullRewriteOnNextPersist = true; + throw new SessionNearLimitAppendError({ + entryBytes, + liveBytes: liveBytesAfter || liveBytesBefore, + capBytes: MANAGED_ARTIFACT_MAX_FILE_BYTES, + entryRetained: this.#byId.has(entry.id), + }); + } return; } this.#recordPersistError(err); @@ -16181,6 +16378,16 @@ export class SessionManager { this.#needsFullRewriteOnNextPersist = true; throw new SessionAppendPersistenceError("current_append", residentEntry.id, this.#persistError ?? error); } + // Typed near-limit recovery (#4566) already ran its deterministic + // rewrite inside _persist and deliberately kept the entry resident so + // the committed effect keeps its receipt. Do not roll it back or wrap + // it into a generic SessionAppendPersistenceError: propagate the typed + // outcome with its structured fields intact. + if (error instanceof SessionNearLimitAppendError) { + if (sidecarAppendCharge > 0) activeRuntime?.accountant.release(sidecarAppendCharge); + if (sidecarTailCharge > 0) activeRuntime?.tailCache.release(sidecarTailCharge); + throw error; + } const removed = this.#fileEntries.pop(); if (removed !== residentEntry) throw new Error("Session append rollback lost resident ordering.", { cause: error }); diff --git a/packages/coding-agent/test/edit-result-persistence-bounding.test.ts b/packages/coding-agent/test/edit-result-persistence-bounding.test.ts new file mode 100644 index 0000000000..e546a7eefb --- /dev/null +++ b/packages/coding-agent/test/edit-result-persistence-bounding.test.ts @@ -0,0 +1,604 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetSettingsForTest, Settings } from "../src/config/settings"; +import { ManagedSessionDescendantStore } from "../src/session/internal/managed-session-storage"; +import { SessionManager } from "../src/session/session-manager"; +import { makeAssistantMessage } from "./session-manager/helpers"; + +// ─── #4566: apply_patch full-file result metadata must not amplify transcripts ── + +const tempDirs: string[] = []; + +beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, cwd: process.cwd() }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + resetSettingsForTest(); + for (const dir of tempDirs.splice(0)) await fs.promises.rm(dir, { recursive: true, force: true }); +}); + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function makeEditToolSession(cwd: string): unknown { + return { + cwd, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + enableLsp: false, + settings: Settings.isolated({ "edit.mode": "apply_patch" }), + getArtifactsDir: () => null, + getSessionId: () => null, + getPlanModeState: () => undefined, + }; +} + +/** ~400 KiB synthetic source file like the reporter's, patchable line by line. */ +function writeBigFile(cwd: string, lines: number): { file: string; lineAt: (i: number) => string } { + const lineAt = (i: number) => `export const v${i} = ${i}; // ${"x".repeat(42)}`; + const file = path.join(cwd, "big.ts"); + fs.writeFileSync(file, Array.from({ length: lines }, (_, i) => lineAt(i)).join("\n"), "utf8"); + return { file, lineAt }; +} + +function patchEnvelope(_index: number, oldLine: string, newLine: string): { input: string } { + return { + input: `*** Begin Patch\n*** Update File: big.ts\n@@\n-${oldLine}\n+${newLine}\n*** End Patch\n`, + }; +} + +function makeEditToolSessionWithMode(cwd: string, mode: string): Record { + return { + ...(makeEditToolSession(cwd) as Record), + settings: Settings.isolated({ "edit.mode": mode }), + }; +} + +describe("edit-result persistence bounding (#4566)", () => { + it("keeps transcript growth bounded independently of file size across repeated apply_patch results", async () => { + const root = makeTempDir("gjc-4566-bounding-"); + const cwd = path.join(root, "proj"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + const LINES = 6400; // ~465 KB, well over the 16 KiB inline snapshot cap + const { lineAt } = writeBigFile(cwd, LINES); + const fileSize = fs.statSync(path.join(cwd, "big.ts")).size; + expect(fileSize).toBeGreaterThan(64 * 1024); + + process.env.GJC_EDIT_VARIANT = "apply_patch"; + const { EditTool } = await import("../src/edit"); + const editTool = new EditTool(makeEditToolSession(cwd) as never); + + const destination = SessionManager.managedDestination(cwd, agentDir); + const manager = SessionManager.create(cwd, destination); + try { + manager.appendMessage({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }); + manager.appendMessage(makeAssistantMessage() as never); + await manager.ensureOnDisk(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected managed session file"); + const sizeAfterOpen = fs.statSync(sessionFile).size; + + const PATCHES = 25; + for (let i = 0; i < PATCHES; i++) { + const newLine = `export const v${i} = ${i}; // patched-${i} ${"y".repeat(30)}`; + const result = await editTool.execute( + `call-${i}`, + patchEnvelope(i, lineAt(i), newLine) as never, + undefined, + undefined, + undefined, + ); + expect(result.isError).toBeFalsy(); + // Live in-process results keep full bodies for ACP diff consumers. + const live = result.details as { oldText?: string; newText?: string }; + expect(live.oldText?.length ?? 0).toBeGreaterThan(64 * 1024); + manager.appendMessage({ + role: "toolResult", + toolCallId: `call-${i}`, + toolName: "edit", + content: result.content, + details: result.details, + isError: false, + timestamp: Date.now(), + }); + } + await manager.flush(); + + const transcript = fs.readFileSync(sessionFile, "utf8"); + const transcriptSize = Buffer.byteLength(transcript, "utf8"); + const perPatch = (transcriptSize - sizeAfterOpen) / PATCHES; + + // Pre-fix behavior was ~2x file size per patch (#4566: 0.76 MiB results + // for a ~395 KiB file). Bounded evidence must stay a small fraction. + expect(perPatch).toBeLessThan(fileSize * 0.02); + + // Every persisted edit result carries digest receipts, not full bodies. + const entries = transcript + .trim() + .split("\n") + .map(line => JSON.parse(line) as { type?: string; message?: Record }) + .filter( + e => + e.type === "message" && + (e.message as { role?: string } | undefined)?.role === "toolResult" && + (e.message as { toolName?: string } | undefined)?.toolName === "edit", + ); + expect(entries.length).toBe(PATCHES); + for (const entry of entries) { + const details = entry.message?.details as Record; + expect(details).toBeDefined(); + const oldDigest = details.oldTextDigest as { bytes: number; sha256: string } | undefined; + const newDigest = details.newTextDigest as { bytes: number; sha256: string } | undefined; + expect(oldDigest).toBeDefined(); + expect(newDigest).toBeDefined(); + // First patch rewrites one line; the receipt records the exact + // pre-edit snapshot byte length (within a line of the fixture size). + expect(oldDigest?.bytes).toBeGreaterThan(fileSize - 1024); + expect(oldDigest?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(newDigest?.sha256).toMatch(/^[0-9a-f]{64}$/); + // The persisted body slots hold the bounded externalization marker. + expect(details.oldText).toBe( + "[edit snapshot externalized: see oldTextDigest/newTextDigest; full body omitted from transcript]", + ); + // Bounded evidence for rendering/replay/accounting is retained. + expect(typeof details.diff).toBe("string"); + expect((details.diff as string).length).toBeGreaterThan(0); + expect(typeof details.path).toBe("string"); + expect(details.op).toBe("update"); + expect(typeof details.firstChangedLine).toBe("number"); + } + } finally { + delete process.env.GJC_EDIT_VARIANT; + await manager.close(); + } + }); + + it("computes snapshot receipts before generic 500k persistence truncation", async () => { + const root = makeTempDir("gjc-4566-exact-digest-"); + const cwd = path.join(root, "proj"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + const destination = SessionManager.managedDestination(cwd, agentDir); + const manager = SessionManager.create(cwd, destination); + try { + manager.appendMessage({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }); + manager.appendMessage(makeAssistantMessage() as never); + await manager.ensureOnDisk(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected managed session file"); + + // Deliberately exceed MAX_PERSIST_CHARS (500k). If generic truncation + // runs first, the durable receipt would identify only the prefix. + const oldText = `${"old-snapshot-line\n".repeat(40_000)}tail-old`; + const newText = `${"new-snapshot-line\n".repeat(40_000)}tail-new`; + expect(oldText.length).toBeGreaterThan(500_000); + expect(newText.length).toBeGreaterThan(500_000); + + manager.appendMessage({ + role: "toolResult", + toolCallId: "call-exact-digest", + toolName: "edit", + content: [{ type: "text", text: "Updated large.txt" }], + details: { diff: "@@\n-old\n+new", path: path.join(cwd, "large.txt"), oldText, newText }, + isError: false, + timestamp: Date.now(), + }); + await manager.flush(); + + const persisted = fs + .readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map(line => JSON.parse(line) as { type?: string; message?: Record }) + .find( + entry => + entry.type === "message" && + (entry.message as { toolCallId?: string } | undefined)?.toolCallId === "call-exact-digest", + ); + const details = persisted?.message?.details as Record | undefined; + const oldDigest = details?.oldTextDigest as { bytes: number; sha256: string } | undefined; + const newDigest = details?.newTextDigest as { bytes: number; sha256: string } | undefined; + + expect(oldDigest).toEqual({ + bytes: Buffer.byteLength(oldText, "utf8"), + sha256: createHash("sha256").update(Buffer.from(oldText, "utf8")).digest("hex"), + }); + expect(newDigest).toEqual({ + bytes: Buffer.byteLength(newText, "utf8"), + sha256: createHash("sha256").update(Buffer.from(newText, "utf8")).digest("hex"), + }); + expect(details?.oldText).toBe( + "[edit snapshot externalized: see oldTextDigest/newTextDigest; full body omitted from transcript]", + ); + expect(details?.newText).toBe( + "[edit snapshot externalized: see oldTextDigest/newTextDigest; full body omitted from transcript]", + ); + } finally { + await manager.close(); + } + }); + + it("persists small edit snapshots inline without receipts", async () => { + const root = makeTempDir("gjc-4566-inline-"); + const cwd = path.join(root, "proj"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(cwd, "small.txt"), "a\n", "utf8"); + + const { EditTool } = await import("../src/edit"); + const editTool = new EditTool(makeEditToolSessionWithMode(cwd, "patch") as never); + + const destination = SessionManager.managedDestination(cwd, agentDir); + const manager = SessionManager.create(cwd, destination); + try { + manager.appendMessage({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }); + manager.appendMessage(makeAssistantMessage() as never); + await manager.ensureOnDisk(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected managed session file"); + + const result = await editTool.execute( + "call-small", + { path: "small.txt", edits: [{ op: "update", diff: "@@\n-a\n+b" }] } as never, + undefined, + undefined, + undefined, + ); + expect(result.isError).toBeFalsy(); + manager.appendMessage({ + role: "toolResult", + toolCallId: "call-small", + toolName: "edit", + content: result.content, + details: result.details, + isError: false, + timestamp: Date.now(), + }); + await manager.flush(); + + const entryLine = fs + .readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map(line => JSON.parse(line) as { type?: string; message?: Record }) + .find( + e => + e.type === "message" && + (e.message as { role?: string } | undefined)?.role === "toolResult" && + (e.message as { toolName?: string } | undefined)?.toolName === "edit", + ); + const details = entryLine?.message?.details as Record | undefined; + expect(details).toBeDefined(); + // Sub-cap bodies persist verbatim — no receipts, no markers. + expect(details?.oldText).toBe("a\n"); + expect(details?.newText).toBe("b\n"); + expect("oldTextDigest" in (details ?? {})).toBe(false); + expect("newTextDigest" in (details ?? {})).toBe(false); + } finally { + await manager.close(); + } + }); + + it("bounds per-file copies in multi-file apply_patch results", async () => { + const root = makeTempDir("gjc-4566-multifile-"); + const cwd = path.join(root, "proj"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + const body = `${"line\n".repeat(5000)}`; // ~25 KB > 16 KiB cap + fs.writeFileSync(path.join(cwd, "one.txt"), `${body}one-old\n`, "utf8"); + fs.writeFileSync(path.join(cwd, "two.txt"), `${body}two-old\n`, "utf8"); + + const { EditTool } = await import("../src/edit"); + const editTool = new EditTool(makeEditToolSession(cwd) as never); + + const destination = SessionManager.managedDestination(cwd, agentDir); + const manager = SessionManager.create(cwd, destination); + try { + manager.appendMessage({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }); + manager.appendMessage(makeAssistantMessage() as never); + await manager.ensureOnDisk(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected managed session file"); + + const input = [ + "*** Begin Patch", + "*** Update File: one.txt", + "@@", + "-one-old", + "+one-new", + "*** Update File: two.txt", + "@@", + "-two-old", + "+two-new", + "*** End Patch", + ].join("\n"); + const result = await editTool.execute("call-multi", { input } as never, undefined, undefined, undefined); + expect(result.isError).toBeFalsy(); + // Live per-file results carry full bodies for ACP diff content. + const livePerFile = (result.details as { perFileResults?: Array<{ oldText?: string }> }).perFileResults; + expect(livePerFile?.length).toBe(2); + for (const perFile of livePerFile ?? []) expect(perFile.oldText?.length ?? 0).toBeGreaterThan(16 * 1024); + + manager.appendMessage({ + role: "toolResult", + toolCallId: "call-multi", + toolName: "edit", + content: result.content, + details: result.details, + isError: false, + timestamp: Date.now(), + }); + await manager.flush(); + + const entryLine = fs + .readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map(line => JSON.parse(line) as { type?: string; message?: Record }) + .find( + e => + e.type === "message" && + (e.message as { role?: string } | undefined)?.role === "toolResult" && + (e.message as { toolName?: string } | undefined)?.toolName === "edit", + ); + const details = entryLine?.message?.details as { perFileResults?: Array> } | undefined; + const perFile = details?.perFileResults; + expect(perFile?.length).toBe(2); + for (const file of perFile ?? []) { + const digest = file.oldTextDigest as { bytes: number; sha256: string } | undefined; + expect(digest).toBeDefined(); + expect(digest?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(file.oldText).toBe( + "[edit snapshot externalized: see oldTextDigest/newTextDigest; full body omitted from transcript]", + ); + expect(typeof file.diff).toBe("string"); + expect(typeof file.path).toBe("string"); + } + } finally { + delete process.env.GJC_EDIT_VARIANT; + await manager.close(); + } + }); +}); + +describe("near-limit edit append after committed mutation (#4566)", () => { + it("recovers the append via full rewrite, keeps the committed edit durable, and states the recovery path", async () => { + const root = makeTempDir("gjc-4566-nearlimit-"); + const cwd = path.join(root, "workspace"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + const { lineAt } = writeBigFile(cwd, 6400); + + process.env.GJC_EDIT_VARIANT = "apply_patch"; + const { EditTool } = await import("../src/edit"); + const editTool = new EditTool(makeEditToolSession(cwd) as never); + + const destination = SessionManager.managedDestination(cwd, agentDir); + const manager = SessionManager.create(cwd, destination); + try { + manager.appendMessage({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }); + manager.appendMessage(makeAssistantMessage() as never); + await manager.ensureOnDisk(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected managed session file"); + + // The first edit applies on disk, then its result append hits the + // emulated per-file cap exactly as appendManagedFileStreamingSync does + // when predecessor.size > MANAGED_ARTIFACT_MAX_FILE_BYTES - appended. + const TEST_CAP = 6000; // far below the post-open transcript size + const overCap = (bytes: Uint8Array): boolean => fs.statSync(sessionFile).size > TEST_CAP - bytes.byteLength; + const proto = ManagedSessionDescendantStore.prototype as unknown as Record; + const realAppendExpectedIdentity = proto.appendExpectedIdentitySync as ( + this: unknown, + p: string, + b: Uint8Array, + ...r: unknown[] + ) => unknown; + const realAppendSync = proto.appendSync as (this: unknown, p: string, b: Uint8Array) => unknown; + proto.appendExpectedIdentitySync = function (this: unknown, p: string, b: Uint8Array, ...r: unknown[]) { + if (overCap(b)) throw new Error("content_too_large"); + return realAppendExpectedIdentity.call(this, p, b, ...r); + }; + proto.appendSync = function (this: unknown, p: string, b: Uint8Array) { + if (overCap(b)) throw new Error("content_too_large"); + return realAppendSync.call(this, p, b); + }; + + // Committed source mutation: the patch writes disk BEFORE append. + const newLine = `export const v0 = 0; // patched-0 ${"y".repeat(30)}`; + const result = await editTool.execute( + "call-0", + patchEnvelope(0, lineAt(0), newLine) as never, + undefined, + undefined, + undefined, + ); + expect(result.isError).toBeFalsy(); + const committed = fs.readFileSync(path.join(cwd, "big.ts"), "utf8").includes("patched-0"); + expect(committed).toBe(true); + + // The near-limit append must not strand the committed edit: the + // content_too_large fallback rewrites live entries, entry included. + expect(() => + manager.appendMessage({ + role: "toolResult", + toolCallId: "call-0", + toolName: "edit", + content: result.content, + details: result.details, + isError: false, + timestamp: Date.now(), + }), + ).not.toThrow(); + + const transcript = fs.readFileSync(sessionFile, "utf8"); + // Effect + receipt are both durable: the edit result entry exists with + // its bounded evidence, and no unclassified SessionAppendPersistenceError + // surfaced to abort the turn. + expect(transcript).toContain("Updated big.ts"); + const persistedEdit = transcript + .trim() + .split("\n") + .map(line => JSON.parse(line) as { type?: string; message?: Record }) + .find( + e => + e.type === "message" && + (e.message as { role?: string } | undefined)?.role === "toolResult" && + (e.message as { toolCallId?: string } | undefined)?.toolCallId === "call-0", + ); + expect(persistedEdit).toBeDefined(); + const details = persistedEdit?.message?.details as Record | undefined; + expect(details?.oldTextDigest).toBeDefined(); + expect(details?.diff).toBeDefined(); + + // The session is not poisoned: the next append after recovery succeeds. + proto.appendExpectedIdentitySync = realAppendExpectedIdentity; + proto.appendSync = realAppendSync; + manager.appendMessage({ role: "user", content: [{ type: "text", text: "continue" }], timestamp: 3 }); + await manager.flush(); + expect(fs.readFileSync(sessionFile, "utf8")).toContain("continue"); + } finally { + delete process.env.GJC_EDIT_VARIANT; + await manager.close(); + } + }); + it("surfaces the typed near-limit outcome when even the rewrite cannot hold the entry", async () => { + const root = makeTempDir("gjc-4566-typed-"); + const cwd = path.join(root, "workspace"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + const { lineAt } = writeBigFile(cwd, 6400); + + process.env.GJC_EDIT_VARIANT = "apply_patch"; + const { EditTool } = await import("../src/edit"); + const { SessionNearLimitAppendError: SessionNearLimitAppendErrorValue } = await import( + "../src/session/session-manager" + ); + type SessionNearLimitAppendError = InstanceType; + const editTool = new EditTool(makeEditToolSession(cwd) as never); + + const destination = SessionManager.managedDestination(cwd, agentDir); + const manager = SessionManager.create(cwd, destination); + try { + manager.appendMessage({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }); + manager.appendMessage(makeAssistantMessage() as never); + await manager.ensureOnDisk(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected managed session file"); + + // Committed source mutation happens BEFORE the append that fails. + const newLine = `export const v0 = 0; // typed-0 ${"y".repeat(30)}`; + const result = await editTool.execute( + "call-0", + patchEnvelope(0, lineAt(0), newLine) as never, + undefined, + undefined, + undefined, + ); + expect(result.isError).toBeFalsy(); + expect(fs.readFileSync(path.join(cwd, "big.ts"), "utf8")).toContain("typed-0"); + + // Reject EVERY append/replace path deterministically: neither the + // streaming append nor the recovery full rewrite can fit, which is + // exactly the state where the old code either aborted with an + // unclassified error or silently succeeded without the receipt. + const proto = ManagedSessionDescendantStore.prototype as unknown as Record; + const realAppendExpectedIdentity = proto.appendExpectedIdentitySync as ( + this: unknown, + p: string, + b: Uint8Array, + ...r: unknown[] + ) => unknown; + const realAppendSync = proto.appendSync as (this: unknown, p: string, b: Uint8Array) => unknown; + const realReplaceSync = proto.replaceSync as (this: unknown, p: string, b: Uint8Array) => unknown; + const realReplaceExpectedIdentity = proto.replaceExpectedIdentitySync as ( + this: unknown, + p: string, + b: Uint8Array, + ...r: unknown[] + ) => unknown; + let appendCalls = 0; + proto.appendExpectedIdentitySync = function (this: unknown, p: string, b: Uint8Array, ...r: unknown[]) { + appendCalls++; + throw new Error("content_too_large"); + }; + proto.appendSync = function (this: unknown, p: string, b: Uint8Array) { + appendCalls++; + throw new Error("content_too_large"); + }; + proto.replaceSync = function (this: unknown, p: string, b: Uint8Array) { + appendCalls++; + throw new Error("content_too_large"); + }; + proto.replaceExpectedIdentitySync = function (this: unknown, p: string, b: Uint8Array, ...r: unknown[]) { + appendCalls++; + throw new Error("content_too_large"); + }; + let thrown: unknown; + try { + manager.appendMessage({ + role: "toolResult", + toolCallId: "call-0", + toolName: "edit", + content: result.content, + details: result.details, + isError: false, + timestamp: Date.now(), + }); + } catch (error) { + thrown = error; + } + + proto.appendExpectedIdentitySync = realAppendExpectedIdentity; + proto.appendSync = realAppendSync; + proto.replaceSync = realReplaceSync; + proto.replaceExpectedIdentitySync = realReplaceExpectedIdentity; + + expect(thrown).toBeInstanceOf(SessionNearLimitAppendErrorValue); + const typed = thrown as InstanceType; + // Deterministic, structured fields — not an unclassified + // SessionAppendPersistenceError abort. + expect(typed.code).toBe("near_limit_append"); + expect(typed.capBytes).toBe(128 * 1024 * 1024); + expect(typed.entryBytes).toBeGreaterThan(0); + expect(typed.entryRetained).toBe(true); + expect(typed.message).toContain("compact"); + expect(typed.message).toContain("gjc export"); + // The committed edit is still in memory; the next successful persist + // (after compaction) records it — the effect/receipt gap is stated, + // not silent. + const entries = manager.getBranch(); + expect( + entries.some( + entry => entry.type === "message" && (entry.message as { toolCallId?: string }).toolCallId === "call-0", + ), + ).toBe(true); + expect(appendCalls).toBeGreaterThanOrEqual(1); + } finally { + delete process.env.GJC_EDIT_VARIANT; + await manager.close(); + } + }); +});