07 → Edit with preview and diff gating
Overview
What you'll build
You’re teaching the assistant to stage edits safely. First it previews a diff (previewEdit), then it decides whether the change is safe to apply (editFile) based on validation rules. Learners should walk away knowing: preview → review warnings/errors → apply (or reject) the edit.
Why it matters
- Prevents accidental binary writes or massive file bloats.
- Forces the model to show a diff before writing risky changes.
- Produces stats and warnings that future steps (like diff presenters) surface to the user.
Big picture
This step is the guardrail between the AI and your filesystem. Search (Step 06) finds candidates, preview (this step) checks safety, and streaming (Step 05) renders the results. Later, Step 10 will add custom tools that rely on the same gating pattern.
Core concepts (Effect-TS)
Preview
previewEdit never writes to disk. It returns the candidate diff, validation messages, and a recommendation (proceed, review, abort). Treat it like a dry run.
Gating
editFile refuses to write if there are warnings or errors and the caller hasn’t opted in (force: true). Gating nudges the assistant to request a preview first and only proceed when the diff looks safe.
Validation
Use validateEdit to detect hazards: huge files, suspicious characters, extreme line lengths. Anything in errors blocks the write; anything in warnings demands a preview before proceeding.
Implementation
Implementation
Step 1: Validate the proposed edit
const validateEdit = (original: string, updated: string) => {
const stats = computeStats(original, updated)
const warnings: string[] = []
const errors: string[] = []
if (updated.length > 500_000) {
warnings.push('File becomes very large (>500KB)')
}
if (updated.includes('\u0000')) {
errors.push('Content contains null bytes')
}
if (updated.split('\n').some((line) => line.length > 2000)) {
warnings.push('Contains very long lines (>2000 chars)')
}
return {
isValid: errors.length === 0,
warnings,
errors,
changeStats: stats,
} as const
}
Step 2: Return a preview recommendation
import * as Schema from 'effect/Schema'
import * as Effect from 'effect/Effect'
import * as FileSystem from '@effect/platform/FileSystem'
import { PathValidation } from '../services/PathValidation'
import { computeDiff } from '../utils/diff'
// Define the input schema
const PreviewParameters = Schema.Struct({
filePath: Schema.String,
oldString: Schema.String,
newString: Schema.String,
replaceAll: Schema.optional(Schema.Boolean),
})
const previewEdit = (params: Schema.Schema.Type<typeof PreviewParameters>) =>
Effect.gen(function* () {
const { filePath, oldString, newString, replaceAll = false } = params
const fs = yield* FileSystem.FileSystem
const pathValidation = yield* PathValidation
const resolved = yield* pathValidation.ensureWithinCwd(filePath)
const content = yield* fs.readFileString(resolved)
const updated = yield* applyReplacement(content, oldString, newString, replaceAll, filePath)
const validation = validateEdit(content, updated)
const recommendation = !validation.isValid
? 'abort'
: validation.warnings.length > 0
? 'review'
: 'proceed'
return {
path: pathValidation.relativePath(resolved),
diff: computeDiff(content, updated, filePath),
validation,
recommendation,
} as const
})
Step 3: Gate writes based on preview + validation
import * as Schema from 'effect/Schema'
import * as Effect from 'effect/Effect'
import * as FileSystem from '@effect/platform/FileSystem'
import { PathValidation } from '../services/PathValidation'
import { computeDiff } from '../utils/diff'
// Define the input schema
const EditParameters = Schema.Struct({
filePath: Schema.String,
oldString: Schema.String,
newString: Schema.String,
replaceAll: Schema.optional(Schema.Boolean),
preview: Schema.optional(Schema.Boolean),
force: Schema.optional(Schema.Boolean),
})
const editFile = (params: Schema.Schema.Type<typeof EditParameters>) =>
Effect.gen(function* () {
const { filePath, oldString, newString, replaceAll = false, preview, force } = params
const fs = yield* FileSystem.FileSystem
const pathValidation = yield* PathValidation
const resolved = yield* pathValidation.ensureWithinCwd(filePath)
const content = yield* fs.readFileString(resolved)
const updated = yield* applyReplacement(content, oldString, newString, replaceAll, filePath)
const validation = validateEdit(content, updated)
const stats = validation.changeStats
const diff = computeDiff(content, updated, filePath)
const relPath = pathValidation.relativePath(resolved)
const shouldGate = (preview ?? true) && (!validation.isValid || validation.warnings.length > 0)
if (shouldGate && !force) {
return {
success: false,
path: relPath,
size: updated.length,
diff,
stats,
validation,
message: 'Large or risky change detected. Preview recommended before applying.',
preview: diff,
} as const
}
yield* fs.writeFileString(resolved, updated)
return {
success: true,
path: relPath,
size: updated.length,
diff,
stats,
validation,
} as const
})
Step 4: Wire adapters to expose the gating signals
When adapting the tool for Vercel AI, surface recommendation, warnings, and errors so the model can react:
import { tool } from 'ai'
import * as Effect from 'effect/Effect'
import * as Schema from 'effect/Schema'
import type * as ManagedRuntime from 'effect/ManagedRuntime'
import { EditTools } from '../tools/EditTools'
import { runToolEffect } from './runtime'
// Define schemas for preview and edit operations
const PreviewParameters = Schema.Struct({
filePath: Schema.String,
oldString: Schema.String,
newString: Schema.String,
replaceAll: Schema.optional(Schema.Boolean),
})
const EditParameters = Schema.Struct({
filePath: Schema.String,
oldString: Schema.String,
newString: Schema.String,
replaceAll: Schema.optional(Schema.Boolean),
preview: Schema.optional(Schema.Boolean),
force: Schema.optional(Schema.Boolean),
})
// Wrap with Standard Schema V1 for AI SDK v6 compatibility
const PreviewToolSchema = Schema.standardSchemaV1(PreviewParameters)
const EditToolSchema = Schema.standardSchemaV1(EditParameters)
export const makeEditToolsForVercel = (
runtime: ManagedRuntime.ManagedRuntime<EditTools, never>,
) => ({
previewEdit: tool({
description: 'Preview an edit and return diff + validation',
inputSchema: PreviewToolSchema,
execute: async (input: {
filePath: string
oldString: string
newString: string
replaceAll?: boolean
}) =>
runToolEffect(
runtime,
Effect.flatMap(EditTools, (tools) => tools.previewEdit(input)),
),
}),
editFile: tool({
description: 'Apply an edit with gating and diff stats',
inputSchema: EditToolSchema,
execute: async (input: {
filePath: string
oldString: string
newString: string
replaceAll?: boolean
preview?: boolean
force?: boolean
}) =>
runToolEffect(
runtime,
Effect.flatMap(EditTools, (tools) => tools.editFile(input)),
),
}),
})
Expose a clear contract: if success is false, the model should dig into validation and decide what to do next (usually call previewEdit).
Testing & Validation
- Run
previewEditon a small change—expectrecommendation: "proceed". - Introduce a warning (e.g., add a very long line); the recommendation should become
"review"andeditFileshould gate unless forced. - Inject a null byte in
newString;validation.isValidshould be false andeditFilemust refuse to write. - Set
force: trueand confirm the write succeeds even when warnings exist (errors still block it).
Common Issues
| Problem | Likely Cause | Fix |
|---|---|---|
previewEdit writes to disk | Using fs.writeFileString in preview | Ensure preview only calls computeDiff and returns data |
| Gating never triggers | (preview ?? true) set to false in adapter | Default preview to true unless user explicitly opts out |
| Assistant ignores warnings | Not surfacing validation in adapter | Return warnings/errors in the tool response so the model sees them |
| Diff shows absolute paths | Skipped pathValidation.relativePath | Always return relative paths to avoid leaking filesystem details |
Connections
Builds on:
- 03 — First Tool — Reuses
PathValidation - 06 — Search with Glob and Ripgrep — Provides candidates to edit
Sets up:
- 08 — Markdown Rendering — Renders preview diffs for display
- 10 — Add a Custom Tool — Reuses the preview/force pattern
Related code:
src/tools/EditTools.tssrc/utils/diff.tssrc/adapters/EditToolAdapters.tssrc/chat/ui/ToolResultPresenter.ts