Waiting for a full unified diff before showing anything is how IDE agents feel slow even when the model is streaming. The unfair advantage is hunk-oriented streaming: parse patch frames as tokens arrive, validate each hunk against the live tree, and apply only ranges that still match — so time-to-first-diff collapses without accepting stale or overlapping edits.
⚡ TL;DR: Stream NDJSON hunk frames (
path,oldStart,oldLines,newLines,hash). Validate context lines + file SHA before apply; skip or re-request failed hunks. Never buffer the whole answer thengit apply. Pair with LLM Output Validators and AST-Guided Edits when mechanical transforms beat freeform hunks.
Frame the stream as typed hunks
// patch/stream-frames.ts
export type HunkFrame = {
path: string;
oldStart: number;
oldLines: string[]; // exact context expected on disk
newLines: string[];
baseSha: string; // blob hash at plan time
seq: number;
};
export function parseFrame(line: string): HunkFrame | null {
try {
const f = JSON.parse(line) as HunkFrame;
if (!f.path || !Array.isArray(f.oldLines) || !Array.isArray(f.newLines)) return null;
return f;
} catch {
return null; // ✅ ignore incomplete SSE chunks
}
}
❌ Treating raw @@ markdown fences as trusted once the stream ends — by then the file may have changed under the cursor.
Validate before every apply
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
export function validateHunk(f: HunkFrame): "ok" | "stale" | "mismatch" {
const disk = readFileSync(f.path, "utf8");
const sha = createHash("sha1").update(disk).digest("hex");
if (sha !== f.baseSha) return "stale";
const lines = disk.split("\n");
const slice = lines.slice(f.oldStart - 1, f.oldStart - 1 + f.oldLines.length);
// ✅ Exact context match — fuzzy apply is how you corrupt JSX
if (slice.join("\n") !== f.oldLines.join("\n")) return "mismatch";
return "ok";
}
export function applyHunk(f: HunkFrame) {
if (validateHunk(f) !== "ok") throw new Error(`hunk_rejected:${f.path}:${f.seq}`);
const lines = readFileSync(f.path, "utf8").split("\n");
lines.splice(f.oldStart - 1, f.oldLines.length, ...f.newLines);
// write atomically via tmp+rename in production
}
Pipeline: parse → validate → apply → UI paint
// agent/stream-apply.ts — illustrative Node consumer
async function consumePatchStream(readable: AsyncIterable<string>) {
let buf = "";
for await (const chunk of readable) {
buf += chunk;
const parts = buf.split("\n");
buf = parts.pop() ?? "";
for (const line of parts) {
const frame = parseFrame(line);
if (!frame) continue;
const v = validateHunk(frame);
if (v === "ok") {
applyHunk(frame);
ui.paintDiff(frame); // ✅ first paint in <1s typical
} else {
// ❌ Don't invent offsets — re-request this path only
await agent.rerequestHunk(frame.path, frame.seq, reason: v);
}
}
}
}
Conflict and abort rules
| Event | Action |
|---|---|
| User typed in same file | Mark baseSha stale; pause apply |
| Hunk overlap | Serialize by seq; reject later overlapping |
| Client abort | Keep applied hunks; write undo journal |
| Parse fail mid-frame | Drop incomplete; wait for next newline |
Same abort discipline as Lambda Plus Bedrock token streaming — never buffer the whole completion in memory.
Closing checklist
✅ Dos
– ✅ Stream NDJSON hunk frames with base blob SHA
– ✅ Exact context match before splice
– ✅ Paint UI on first valid hunk
– ✅ Re-request only failed paths
– ✅ Keep an undo journal per session
❌ Don’ts
– ❌ Don’t git apply a mega-diff after the stream ends
– ❌ Don’t fuzzy-match context “close enough”
– ❌ Don’t apply overlapping hunks out of order
– ❌ Don’t ignore user edits mid-stream
– ❌ Don’t skip parse/typecheck after the last hunk
Related reading
- LLM Output Validators: Parse Trees Before Accepting Any Patches
- AST-Guided Edits: LLMs Propose Intents and Codemods Apply Them
- Agentic Git Workflows: Atomic Commits From Noisy LLM Diffs
- Lambda Plus Bedrock: Stream Tokens Without API Gateway Caps
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
