feat(coding-agent): implemented shared single-path edit request handling

- Required top-level `path` in edit requests, removed per-entry `path` fields, and updated docs/tests to match.
- Updated patch/hashline/atom streaming preview generation to return a single request-level path diff preview.
- Changed edit execution to route patch/replace/atom/hashline through single-path handlers sharing `path`.
- Added `scripts/analyze-edit-formats` Go CLI with reports to audit edit-tool usage from session JSONL logs.
This commit is contained in:
can1357
2026-04-28 05:21:24 +02:00
parent e8c5c660b6
commit 5944fe34c0
25 changed files with 986 additions and 320 deletions
+3
View File
@@ -1,8 +1,10 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Required top-level `path` for `edit` tool `atom`, `hashline`, `patch`, and `replace` calls and removed per-entry `path` overrides, so edits to different files now require separate calls
- Replaced the atom edit `sed` verb with `replace`, requiring `{ find, with, all? }` instead of `{ pat, rep, g? }`
- Removed bracketed atom locators like `(anchor)` and `[anchor]`, so region block rewrites via `splice` are no longer supported and bare anchor `loc` values now target exactly one line
- Renamed MCP tool identifiers from the `mcp_<server>_<tool>` format to `mcp__<server>_<tool>` so custom tool names, active tool lists, and persisted MCP selections must be updated to the new prefix
@@ -15,6 +17,7 @@
### Changed
- Updated `edit` streaming diff previews for `patch`, `replace`, and `hashline` to produce a single request-level preview for the new single-file `path` mode
- Changed atom inline and file-wide replacements to perform literal substring substitution, with `all: true` replacing all matches on the line
- Changed `loc` parsing so path-qualified atom edits correctly split `path:loc` when the locator suffix contains colons
- Changed `replace` to reject multiline `find`/`with` values and require single-line operands
+107 -107
View File
@@ -19,13 +19,7 @@ import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit
import type { VimToolDetails } from "../vim/types";
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import {
type AtomParams,
type AtomToolEdit,
atomEditParamsSchema,
executeAtomSingle,
resolveAtomEntryPaths,
} from "./modes/atom";
import { type AtomParams, type AtomToolEdit, atomEditParamsSchema, executeAtomSingle } from "./modes/atom";
import {
executeHashlineSingle,
HashlineMismatchError,
@@ -122,45 +116,11 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
}
/**
* Resolve per-entry `path` against an optional top-level `path` default.
* If both are absent on an entry, throws a descriptive error.
*/
function resolveEntryPaths<T extends { path?: string }>(
edits: readonly T[],
topLevelPath: string | undefined,
): (T & { path: string })[] {
return edits.map((edit, i) => {
const path = (edit && typeof edit.path === "string" && edit.path) || topLevelPath;
if (!path) {
throw new Error(
`Edit ${i}: missing \`path\`. Provide \`path\` on this edit or supply a top-level \`path\` for the request.`,
);
}
return { ...edit, path };
});
}
/** Group items by a key, preserving insertion order. */
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
const map = new Map<K, T[]>();
for (const item of items) {
const k = key(item);
let arr = map.get(k);
if (!arr) {
arr = [];
map.set(k, arr);
}
arr.push(item);
}
return map;
}
/** Run single-file executors for each file group and aggregate results. */
async function executePerFile(
/** Run apply_patch file operations and aggregate their multi-file result. */
async function executeApplyPatchPerFile(
fileEntries: {
path: string;
run: (batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails, any>>;
run: (batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>;
}[],
outerBatchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
@@ -230,6 +190,58 @@ async function executePerFile(
};
}
async function executeSinglePathEntries(
path: string,
runs: ((batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>)[],
outerBatchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
): Promise<AgentToolResult<EditToolDetails, TInput>> {
if (runs.length === 1) {
return runs[0](outerBatchRequest);
}
const contentTexts: string[] = [];
const diffTexts: string[] = [];
let firstChangedLine: number | undefined;
for (let i = 0; i < runs.length; i++) {
const isLast = i === runs.length - 1;
const batchRequest: LspBatchRequest | undefined = outerBatchRequest
? { id: outerBatchRequest.id, flush: isLast && outerBatchRequest.flush }
: undefined;
try {
const result = await runs[i](batchRequest);
const details = result.details;
if (details?.diff) diffTexts.push(details.diff);
firstChangedLine ??= details?.firstChangedLine;
const text = result.content?.find(c => c.type === "text")?.text ?? "";
if (text) contentTexts.push(text);
} catch (err) {
const errorText = err instanceof Error ? err.message : String(err);
contentTexts.push(`Error editing ${path}: ${errorText}`);
}
if (!isLast && onUpdate) {
onUpdate({
content: [{ type: "text", text: contentTexts.join("\n") }],
details: {
diff: diffTexts.join("\n"),
firstChangedLine,
},
});
}
}
return {
content: [{ type: "text", text: contentTexts.join("\n") }],
details: {
diff: diffTexts.join("\n"),
firstChangedLine,
},
};
}
export class EditTool implements AgentTool<TInput> {
readonly name = "edit";
readonly label = "Edit";
@@ -316,13 +328,12 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits, path: topPath } = params as PatchParams & { path?: string };
const resolved = resolveEntryPaths(edits as PatchEditEntry[], topPath);
const entries = resolved.map(entry => ({
path: entry.path,
run: (br: LspBatchRequest | undefined) =>
const { edits, path } = params as PatchParams;
const runs = (edits as PatchEditEntry[]).map(
entry => (br: LspBatchRequest | undefined) =>
executePatchSingle({
session: tool.session,
path,
params: entry,
signal,
batchRequest: br,
@@ -331,8 +342,8 @@ export class EditTool implements AgentTool<TInput> {
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(entries, batchRequest, onUpdate);
);
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
},
},
apply_patch: {
@@ -346,21 +357,25 @@ export class EditTool implements AgentTool<TInput> {
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
const perFile = entries.map(entry => ({
path: entry.path!,
run: (br: LspBatchRequest | undefined) =>
executePatchSingle({
session: tool.session,
params: entry,
signal,
batchRequest: br,
allowFuzzy: tool.#allowFuzzy,
fuzzyThreshold: tool.#fuzzyThreshold,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(perFile, batchRequest, onUpdate);
const perFile = entries.map(entry => {
const { path, ...patchParams } = entry;
return {
path,
run: (br: LspBatchRequest | undefined) =>
executePatchSingle({
session: tool.session,
path,
params: patchParams,
signal,
batchRequest: br,
allowFuzzy: tool.#allowFuzzy,
fuzzyThreshold: tool.#fuzzyThreshold,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
};
});
return executeApplyPatchPerFile(perFile, batchRequest, onUpdate);
},
},
hashline: {
@@ -371,25 +386,18 @@ export class EditTool implements AgentTool<TInput> {
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits, path: topPath } = params as HashlineParams & { path?: string };
const resolved = resolveEntryPaths(edits as HashlineToolEdit[], topPath);
const byFile = groupBy(resolved, e => e.path);
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
const { edits, path } = params as HashlineParams;
return executeHashlineSingle({
session: tool.session,
path,
run: (br: LspBatchRequest | undefined) =>
executeHashlineSingle({
session: tool.session,
path,
edits: fileEdits,
signal,
batchRequest: br,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(entries, batchRequest, onUpdate);
edits: edits as HashlineToolEdit[],
signal,
batchRequest,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
});
},
},
atom: {
@@ -400,25 +408,18 @@ export class EditTool implements AgentTool<TInput> {
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits, path: topPath } = params as AtomParams & { path?: string };
const resolved = resolveAtomEntryPaths(edits as AtomToolEdit[], topPath);
const byFile = groupBy(resolved, e => e.path);
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
const { edits, path } = params as AtomParams;
return executeAtomSingle({
session: tool.session,
path,
run: (br: LspBatchRequest | undefined) =>
executeAtomSingle({
session: tool.session,
path,
edits: fileEdits,
signal,
batchRequest: br,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(entries, batchRequest, onUpdate);
edits: edits as AtomToolEdit[],
signal,
batchRequest,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
});
},
},
replace: {
@@ -431,13 +432,12 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits, path: topPath } = params as ReplaceParams & { path?: string };
const resolved = resolveEntryPaths(edits as ReplaceEditEntry[], topPath);
const entries = resolved.map(entry => ({
path: entry.path,
run: (br: LspBatchRequest | undefined) =>
const { edits, path } = params as ReplaceParams;
const runs = (edits as ReplaceEditEntry[]).map(
entry => (br: LspBatchRequest | undefined) =>
executeReplaceSingle({
session: tool.session,
path,
params: entry,
signal,
batchRequest: br,
@@ -446,8 +446,8 @@ export class EditTool implements AgentTool<TInput> {
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(entries, batchRequest, onUpdate);
);
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
},
},
vim: {
+8 -4
View File
@@ -690,10 +690,14 @@ function structuralBigram(line: number): string {
const mod100 = line % 100;
if (mod100 >= 11 && mod100 <= 13) return "th";
switch (line % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
@@ -22,17 +22,19 @@ export const applyPatchSchema = Type.Object({
export type ApplyPatchParams = Static<typeof applyPatchSchema>;
export type ApplyPatchEntry = PatchEditEntry & { path: string };
/**
* Parse the envelope and lower each hunk to a `PatchEditEntry` so it can
* be routed through `executePatchSingle`.
*/
export function expandApplyPatchToEntries(params: ApplyPatchParams): PatchEditEntry[] {
export function expandApplyPatchToEntries(params: ApplyPatchParams): ApplyPatchEntry[] {
const hunks = parseApplyPatch(params.input);
if (hunks.length === 0) {
throw new ApplyPatchError("No files were modified.");
}
return hunks.map(
(h): PatchEditEntry => ({
(h): ApplyPatchEntry => ({
path: h.path,
op: h.op,
rename: h.rename,
@@ -41,10 +43,10 @@ export function expandApplyPatchToEntries(params: ApplyPatchParams): PatchEditEn
);
}
export function expandApplyPatchToPreviewEntries(params: ApplyPatchParams): PatchEditEntry[] {
export function expandApplyPatchToPreviewEntries(params: ApplyPatchParams): ApplyPatchEntry[] {
const hunks = parseApplyPatchStreaming(params.input);
return hunks.map(
(h): PatchEditEntry => ({
(h): ApplyPatchEntry => ({
path: h.path,
op: h.op,
rename: h.rename,
+8 -44
View File
@@ -55,7 +55,7 @@ export const atomEditSchema = Type.Object(
{
loc: Type.String({
description: "edit location",
examples: ["1ab", "$", "src/foo.ts:1ab"],
examples: ["1ab", "$"],
}),
splice: Type.Optional(textSchema),
pre: Type.Optional(textSchema),
@@ -65,7 +65,9 @@ export const atomEditSchema = Type.Object(
{
find: Type.String({ description: "literal substring to find" }),
with: Type.String({ description: "literal substring to substitute" }),
all: Type.Optional(Type.Boolean({ description: "replace every occurrence (default: first only)", default: false })),
all: Type.Optional(
Type.Boolean({ description: "replace every occurrence (default: first only)", default: false }),
),
},
{
additionalProperties: false,
@@ -78,7 +80,7 @@ export const atomEditSchema = Type.Object(
export const atomEditParamsSchema = Type.Object(
{
path: Type.Optional(Type.String({ description: "default file path for edits" })),
path: Type.String({ description: "file path for edits" }),
edits: Type.Array(atomEditSchema, { description: "edit ops" }),
},
{ additionalProperties: false },
@@ -120,15 +122,6 @@ const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonl
// captured as a content hint for anchor disambiguation.
const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
// Splits `path:loc` references where the right side starts with a valid anchor
// (single `\d+<bigram>`, optionally followed by a content suffix using `|` or
// `:`). The non-greedy `(.+?)` picks the leftmost colon whose RHS is a real
// anchor, so colons inside the loc's content suffix (TS type annotations, etc.)
// don't break the split. Drive-letter prefixes like `C:\path\a.ts:160sr` still
// resolve correctly because the first colon's RHS fails the anchor pattern.
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:[|:].*)?)$`);
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
let next: Record<string, unknown> | undefined;
const fields = edit as Record<string, unknown>;
@@ -179,37 +172,6 @@ function parseAnchor(raw: string, opName: string): Anchor {
}
}
function resolveAtomEntryPath(
edit: AtomToolEdit,
topLevelPath: string | undefined,
editIndex: number,
): AtomToolEdit & { path: string } {
const entry = stripNullAtomFields(edit);
let loc = entry.loc;
let pathOverride: string | undefined;
if (typeof loc === "string") {
const split = loc.match(PATH_LOC_SPLIT_RE);
if (split) {
pathOverride = split[1];
loc = split[2]!;
}
}
const path = pathOverride || topLevelPath;
if (!path) {
throw new Error(
`Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`,
);
}
return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
}
export function resolveAtomEntryPaths(
edits: readonly AtomToolEdit[],
topLevelPath: string | undefined,
): (AtomToolEdit & { path: string })[] {
return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
}
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
const trimmed = raw.trim();
if (trimmed === "$") return { kind: "file" };
@@ -681,7 +643,9 @@ export function applyAtomEdits(
}
}
if (!anyMatched) {
throw new Error(`Edit replace expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
throw new Error(
`Edit replace expression ${JSON.stringify(edit.expression)} did not match any line in the file.`,
);
}
}
@@ -156,7 +156,6 @@ const locSchema = Type.Union(
export const hashlineEditSchema = Type.Object(
{
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
loc: Type.Optional(locSchema),
content: Type.Optional(linesSchema),
},
@@ -165,7 +164,7 @@ export const hashlineEditSchema = Type.Object(
export const hashlineEditParamsSchema = Type.Object(
{
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
path: Type.String({ description: "file path for edits" }),
edits: Type.Array(hashlineEditSchema, { description: "edits" }),
},
{ additionalProperties: false },
+22 -18
View File
@@ -1576,27 +1576,33 @@ export async function computePatchDiff(
}
}
export const patchEditEntrySchema = Type.Object({
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
op: Type.Optional(
StringEnum(["create", "delete", "update"], {
description: "Operation (default: update)",
}),
),
rename: Type.Optional(Type.String({ description: "New path for move" })),
diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
});
export const patchEditEntrySchema = Type.Object(
{
op: Type.Optional(
StringEnum(["create", "delete", "update"], {
description: "Operation (default: update)",
}),
),
rename: Type.Optional(Type.String({ description: "New path for move" })),
diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
},
{ additionalProperties: false },
);
export const patchEditSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
});
export const patchEditSchema = Type.Object(
{
path: Type.String({ description: "file path for edits" }),
edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
},
{ additionalProperties: false },
);
export type PatchEditEntry = Static<typeof patchEditEntrySchema>;
export type PatchParams = Static<typeof patchEditSchema>;
export interface ExecutePatchSingleOptions {
session: ToolSession;
path: string;
params: PatchEditEntry;
signal?: AbortSignal;
batchRequest?: LspBatchRequest;
@@ -1694,6 +1700,7 @@ export async function executePatchSingle(
): Promise<AgentToolResult<EditToolDetails, typeof patchEditEntrySchema>> {
const {
session,
path,
params,
signal,
batchRequest,
@@ -1702,10 +1709,7 @@ export async function executePatchSingle(
writethrough,
beginDeferredDiagnosticsForPath,
} = options;
const { path, op: rawOp, rename, diff } = params;
if (typeof path !== "string" || path.length === 0) {
throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
}
const { op: rawOp, rename, diff } = params;
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
+18 -14
View File
@@ -976,23 +976,29 @@ export function findContextLine(
return { index: undefined, confidence: bestScore };
}
export const replaceEditEntrySchema = Type.Object({
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
new_text: Type.String({ description: "Replacement text" }),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
});
export const replaceEditEntrySchema = Type.Object(
{
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
new_text: Type.String({ description: "Replacement text" }),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
},
{ additionalProperties: false },
);
export const replaceEditSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
});
export const replaceEditSchema = Type.Object(
{
path: Type.String({ description: "file path for edits" }),
edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
},
{ additionalProperties: false },
);
export type ReplaceEditEntry = Static<typeof replaceEditEntrySchema>;
export type ReplaceParams = Static<typeof replaceEditSchema>;
export interface ExecuteReplaceSingleOptions {
session: ToolSession;
path: string;
params: ReplaceEditEntry;
signal?: AbortSignal;
batchRequest?: LspBatchRequest;
@@ -1007,6 +1013,7 @@ export async function executeReplaceSingle(
): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
const {
session,
path,
params,
signal,
batchRequest,
@@ -1015,10 +1022,7 @@ export async function executeReplaceSingle(
writethrough,
beginDeferredDiagnosticsForPath,
} = options;
const { path, old_text, new_text, all } = params;
if (typeof path !== "string" || path.length === 0) {
throw new Error("replace edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
}
const { old_text, new_text, all } = params;
enforcePlanModeWrite(session, path);
+3 -3
View File
@@ -28,8 +28,8 @@ import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../
import type { EditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import type { DiffError, DiffResult } from "./diff";
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import type { Operation, PatchEditEntry } from "./modes/patch";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import type { Operation } from "./modes/patch";
import type { PerFileDiffPreview } from "./streaming";
// ═══════════════════════════════════════════════════════════════════════════
@@ -107,7 +107,7 @@ type EditRenderEntry = {
};
interface ApplyPatchRenderSummary {
entries: PatchEditEntry[];
entries: ApplyPatchEntry[];
error?: string;
}
+41 -70
View File
@@ -15,7 +15,7 @@
import type { Theme } from "../modes/theme/theme";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
import type { ReplaceEditEntry } from "./modes/replace";
@@ -126,33 +126,19 @@ export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: stri
}
// -----------------------------------------------------------------------------
// Multi-file grouping
// Apply_patch remains multi-file because the Codex envelope carries paths per hunk.
// -----------------------------------------------------------------------------
/** Cap on how many distinct files a streaming preview will render diffs for. */
const MAX_PREVIEW_FILES = 5;
function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map<string, ApplyPatchEntry[]> {
const groups = new Map<string, ApplyPatchEntry[]>();
/**
* Group a list of edits by their effective `path` (per-edit `path` falls back
* to the top-level `args.path`). Insertion order is preserved and the number of
* distinct buckets is capped at {@link MAX_PREVIEW_FILES}.
*/
function groupEditsByPath<T extends { path?: string }>(
edits: readonly T[],
fallbackPath: string | undefined,
): Map<string, Array<T & { path: string }>> {
const groups = new Map<string, Array<T & { path: string }>>();
for (const edit of edits) {
if (!edit) continue;
const editPath = edit.path ?? fallbackPath;
if (!editPath) continue;
let bucket = groups.get(editPath);
for (const entry of entries) {
let bucket = groups.get(entry.path);
if (!bucket) {
if (groups.size >= MAX_PREVIEW_FILES) continue;
bucket = [];
groups.set(editPath, bucket);
groups.set(entry.path, bucket);
}
bucket.push({ ...edit, path: editPath });
bucket.push(entry);
}
return groups;
}
@@ -173,26 +159,21 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview(args, ctx) {
const groups = groupEditsByPath(args.edits ?? [], args.path);
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, fileEdits] of groups) {
const first = fileEdits[0];
if (!first || first.old_text === undefined || first.new_text === undefined) continue;
ctx.signal.throwIfAborted();
const result = await computeEditDiff(
path,
first.old_text,
first.new_text,
ctx.cwd,
ctx.allowFuzzy ?? true,
first.all,
ctx.fuzzyThreshold,
);
ctx.signal.throwIfAborted();
previews.push(toPerFilePreview(path, result));
}
return previews.length > 0 ? previews : null;
if (!args.path) return null;
const first = args.edits?.[0];
if (!first || first.old_text === undefined || first.new_text === undefined) return null;
ctx.signal.throwIfAborted();
const result = await computeEditDiff(
args.path,
first.old_text,
first.new_text,
ctx.cwd,
ctx.allowFuzzy ?? true,
first.all,
ctx.fuzzyThreshold,
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(args.path, result)];
},
renderStreamingFallback() {
return "";
@@ -211,22 +192,17 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview(args, ctx) {
const groups = groupEditsByPath(args.edits ?? [], args.path);
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, fileEdits] of groups) {
const first = fileEdits[0];
if (!first) continue;
ctx.signal.throwIfAborted();
const result = await computePatchDiff(
{ path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
ctx.cwd,
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
);
ctx.signal.throwIfAborted();
previews.push(toPerFilePreview(path, result));
}
return previews.length > 0 ? previews : null;
if (!args.path) return null;
const first = args.edits?.[0];
if (!first) return null;
ctx.signal.throwIfAborted();
const result = await computePatchDiff(
{ path: args.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
ctx.cwd,
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(args.path, result)];
},
renderStreamingFallback() {
return "";
@@ -245,16 +221,11 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview(args, ctx) {
const groups = groupEditsByPath(args.edits ?? [], args.path);
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, fileEdits] of groups) {
ctx.signal.throwIfAborted();
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
ctx.signal.throwIfAborted();
previews.push(toPerFilePreview(path, result));
}
return previews;
if (!args.path || !args.edits?.length) return null;
ctx.signal.throwIfAborted();
const result = await computeHashlineDiff({ path: args.path, edits: args.edits }, ctx.cwd);
ctx.signal.throwIfAborted();
return [toPerFilePreview(args.path, result)];
},
renderStreamingFallback() {
return "";
@@ -272,7 +243,7 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
},
async computeDiffPreview(args, ctx) {
if (typeof args.input !== "string" || args.input.length === 0) return null;
let entries: PatchEditEntry[];
let entries: ApplyPatchEntry[];
try {
entries = expandApplyPatchToEntries({ input: args.input });
} catch {
@@ -282,7 +253,7 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
}
}
const groups = groupEditsByPath(entries, undefined);
const groups = groupApplyPatchEntriesByPath(entries);
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, fileEntries] of groups) {
@@ -46,10 +46,10 @@ import type {
BashToolInput,
FindToolDetails,
FindToolInput,
SearchToolDetails,
SearchToolInput,
ReadToolDetails,
ReadToolInput,
SearchToolDetails,
SearchToolInput,
WriteToolInput,
} from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
@@ -301,7 +301,6 @@ These are inviolable.
{{#has tools "task"}}- Use tasks or subagents when independent investigations or edits can be split safely.{{/has}}
- If you cannot explain why one piece depends on another, they are probably independent.
{{#has tools "task"}}- When a plan feels too large for a single turn, parallelize aggressively — do **NOT** abandon phases, silently drop them, or narrate scope cuts. Scope pressure is a signal to delegate, not to shrink the work.{{/has}}
## 4. Task tracking
- Update todos as you progress.
- Skip task tracking only for trivial requests.
@@ -1,13 +1,12 @@
Applies precise file edits using anchors (line+hash).
<ops>
Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is the default file; you **MAY** override it per edit with `loc:"b.ts:160sr"`.
Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is required and applies to every edit in the call; `loc` is anchor-only and **MUST NOT** include a file prefix.
Each edit **MUST** have exactly one `loc` and **MUST** include one or more verbs.
# Locators
- `"A"` targets one anchored line (line number + 2-letter suffix, e.g. `160sr`).
- `"$"` targets the whole file: `pre` = BOF, `post` = EOF, `replace` = every line.
-
# Verbs
- `splice:[…]` replaces the anchored line. `[]` deletes; `[""]` makes a blank line. To replace N lines, anchor the first line and list all replacement lines.
- `pre:[…]` inserts before the anchor, or BOF with `loc:"$"`.
@@ -44,8 +43,6 @@ Use for tiny inline edits: names, operators, literals.
# File edges:
`{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
`{path:"a.ts",edits:[{loc:"$",post:["","export { FALLBACK };"]}]}`
# Cross-file override:
`{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";" "config.ts:" ""}},splice:["const FALLBACK = \"anonymous\";"]}]}`
# Replace several consecutive lines: anchor the first line and list all replacement lines in `splice`.
`{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;"}},splice:["\tconst clean = String(name ?? FALLBACK).trim();","\treturn clean.toLowerCase();","}"]}]}`
This anchors line 4 and replaces lines 4-6 of the original function body in one splice. The anchor's hash protects against the file having shifted under you.
@@ -43,9 +43,8 @@ You **MUST** use specialized tools instead of bash for any file, directory, or t
|`cat <<'EOF' > file`|`write(path="file", content="…")`|
|`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`|
{{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}}
- You **MUST NOT** create files with `cat <<EOF`, `echo > file`, or `printf > file`. Use `write` — heredoc content cannot be cached for permission reuse, every revision triggers a fresh review, and there is no diff. This is the most-violated rule.
- You **MUST NOT** read line ranges with `sed -n 'A,Bp'`, `awk 'NR>=A && NR<=B'`, or `head | tail` pipelines. Use `read` with `offset`/`limit` (or `sel` if available).
- You **MUST NOT** read line ranges with `sed -n 'A,Bp'`, `awk 'NR≥A && NR≤B'`, or `head | tail` pipelines. Use `read` with `offset`/`limit` (or `sel` if available).
{{#if hasAstGrep}}- You **MUST** use `ast_grep` for structural code search instead of bash `grep`/`awk`/`perl` pipelines{{/if}}
{{#if hasAstEdit}}- You **MUST** use `ast_edit` for structural rewrites instead of bash `sed`/`awk`/`perl` pipelines{{/if}}
- You **MUST NOT** use `2>&1` or `2>/dev/null` — stdout and stderr are already merged
@@ -5,10 +5,9 @@ Read the file first. Copy the full anchors exactly as shown by `read`.
<operations>
**Top level**
- `edits` — array of edit entries
- `path` (optional) — default file path used when an entry omits its own `path`. Lets you share the path across many edits in one request.
- `path` (required) — file path for all edits in this request
**Edit entry**: `{ path?, loc, content }`
- `path` — file path (omit to fall back to the request-level `path`)
**Edit entry**: `{ loc, content }`
- `loc` — where to apply the edit (see below)
- `content` — replacement/inserted lines (`string[]`, one element per line; `null` to delete)
@@ -44,19 +43,19 @@ All examples below reference the same file:
# Replace a block body
Replace only the catch body. Do not target the shared boundary line `} catch (err) {`.
`{edits:[{path:"a.ts",loc:{range:{pos:{{href 15 "\t\tconsole.error(err);"}},end:{{href 16 "\t\treturn null;"}}}},content:["\t\tif (isEnoent(err)) return null;","\t\tthrow err;"]}]}`
`{path:"a.ts",edits:[{loc:{range:{pos:{{href 15 "\t\tconsole.error(err);"}},end:{{href 16 "\t\treturn null;"}}}},content:["\t\tif (isEnoent(err)) return null;","\t\tthrow err;"]}]}`
# Replace whole block including closing brace
Replace `alpha`'s entire body including the closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`.
`{edits:[{path:"a.ts",loc:{range:{pos:{{href 6 "\tlog();"}},end:{{href 7 "}"}}}},content:["\tvalidate();","\tlog();","}"]}]}`
`{path:"a.ts",edits:[{loc:{range:{pos:{{href 6 "\tlog();"}},end:{{href 7 "}"}}}},content:["\tvalidate();","\tlog();","}"]}]}`
**Wrong**: `end: {{href 6 "\tlog();"}}` — line 7 (`}`) survives AND content emits `}`, producing two closing braces.
# Replace one line
Single-line replace uses `pos == end`.
`{edits:[{path:"a.ts",loc:{range:{pos:{{href 2 "const timeout = 5000;"}},end:{{href 2 "const timeout = 5000;"}}}},content:["const timeout = 30_000;"]}]}`
`{path:"a.ts",edits:[{loc:{range:{pos:{{href 2 "const timeout = 5000;"}},end:{{href 2 "const timeout = 5000;"}}}},content:["const timeout = 30_000;"]}]}`
# Delete a range
`{edits:[{path:"a.ts",loc:{range:{pos:{{href 10 "\t// TODO: remove after migration"}},end:{{href 11 "\tlegacy();"}}}},content:null}]}`
`{path:"a.ts",edits:[{loc:{range:{pos:{{href 10 "\t// TODO: remove after migration"}},end:{{href 11 "\tlegacy();"}}}},content:null}]}`
# Insert before a sibling
When adding a sibling declaration, prefer `prepend` on the next declaration.
`{edits:[{path:"a.ts",loc:{prepend:{{href 9 "function beta() {"}}},content:["function gamma() {","\tvalidate();","}",""]}]}`
`{path:"a.ts",edits:[{loc:{prepend:{{href 9 "function beta() {"}}},content:["function gamma() {","\tvalidate();","}",""]}]}`
</examples>
<critical>
@@ -18,19 +18,19 @@ When editing structured blocks (nested braces, tags, indented regions), include
<parameters>
```ts
// Input is { edits: Entry[] } where Entry is one of:
// Input is { path: string, edits: Entry[] }. `path` is required and applies to every entry.
type Entry =
// Diff is one or more hunks in the same file.
// Diff is one or more hunks for the top-level path.
// - Each hunk begins with "@@" (anchor optional).
// - Each hunk body only has lines starting with ' ' | '+' | '-'.
// - Each hunk includes at least one change (+ or -).
| { path: string, op: "update", diff: string }
| { op: "update", diff: string }
// Diff is full file content, no prefixes.
| { path: string, op: "create", diff: string }
| { op: "create", diff: string }
// No diff for delete.
| { path: string, op: "delete" }
// New path for update+move.
| { path: string, op: "update", rename: string, diff: string }
| { op: "delete" }
// New path for update+move from the top-level path.
| { op: "update", rename: string, diff: string }
```
</parameters>
@@ -52,15 +52,15 @@ Returns success/failure; on failure, error message indicates:
<examples>
# Create
`edit {"edits":[{"path":"hello.txt","op":"create","diff":"Hello\n"}]}`
`edit {"path":"hello.txt","edits":[{"op":"create","diff":"Hello\n"}]}`
# Update
`edit {"edits":[{"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}]}`
`edit {"path":"src/app.py","edits":[{"op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}]}`
# Rename
`edit {"edits":[{"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"}]}`
`edit {"path":"src/app.py","edits":[{"op":"update","rename":"src/main.py","diff":"@@\n …\n"}]}`
# Delete
`edit {"edits":[{"path":"obsolete.txt","op":"delete"}]}`
# Multi-file
`edit {"edits":[{"path":"src/types.ts","op":"update","diff":"@@\n-old\n+new\n"},{"path":"src/index.ts","op":"update","diff":"@@\n-old\n+new\n"}]}`
`edit {"path":"obsolete.txt","edits":[{"op":"delete"}]}`
# Multiple entries
All entries in one call apply to the top-level `path`; use separate calls for different files.
</examples>
<avoid>
@@ -1,9 +1,9 @@
Performs string replacements in files with fuzzy whitespace matching.
<instruction>
- You **MUST** use the smallest edit that uniquely identifies the change
- If `old_text` not unique, you **MUST** expand to include more context or use `all: true` to replace all occurrences
- Fuzzy matching handles minor whitespace/indentation differences automatically
- Params **MUST** be `{ path, edits }`; `path` is required at the top level and applies to every replacement
- You **MUST** use the smallest `old_text` that uniquely identifies the change
- If `old_text` is not unique, you **MUST** expand it with more context or use `all: true` to replace all occurrences
- You **SHOULD** prefer editing existing files over creating new ones
</instruction>
@@ -21,4 +21,4 @@ Searches files using powerful regex matching.
- Bash `grep`/`rg` loses `.gitignore` semantics, bypasses result limits, and wastes tokens. The `search` tool is faster, structured, and already wired into the workspace — there is no scenario where Bash search is preferable.
- If you catch yourself typing `grep`, `rg`, or `| grep` in a Bash command, stop and re-issue the lookup through the `search` tool instead.
- If the search is open-ended, requiring multiple rounds, you **MUST** use the Task tool with the explore subagent instead of chaining `search` calls yourself.
</critical>
</critical>
+1 -1
View File
@@ -18,13 +18,13 @@ import { calculatorToolRenderer } from "./calculator";
import { debugToolRenderer } from "./debug";
import { findToolRenderer } from "./find";
import { githubToolRenderer } from "./gh-renderer";
import { searchToolRenderer } from "./search";
import { inspectImageToolRenderer } from "./inspect-image-renderer";
import { jobToolRenderer } from "./job";
import { notebookToolRenderer } from "./notebook";
import { pythonToolRenderer } from "./python";
import { readToolRenderer } from "./read";
import { resolveToolRenderer } from "./resolve";
import { searchToolRenderer } from "./search";
import { searchToolBm25Renderer } from "./search-tool-bm25";
import { sshToolRenderer } from "./ssh";
import { todoWriteToolRenderer } from "./todo-write";
+1 -1
View File
@@ -246,7 +246,7 @@ describe("computeHashlineDiff", () => {
await Bun.write(sourcePath, "first\n");
const result = await computeHashlineDiff(
{ path: sourcePath, edits: [{ path: sourcePath, loc: "append", content: ["second"] }] },
{ path: sourcePath, edits: [{ loc: "append", content: ["second"] }] },
tempDir,
);
expect("diff" in result).toBe(true);
+29 -15
View File
@@ -12,10 +12,10 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash";
import { FindTool } from "@oh-my-pi/pi-coding-agent/tools/find";
import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
import { JobTool } from "@oh-my-pi/pi-coding-agent/tools/job";
import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
import * as markitUtils from "@oh-my-pi/pi-coding-agent/utils/markit";
import { $which, Snowflake } from "@oh-my-pi/pi-utils";
@@ -662,7 +662,8 @@ describe("Coding Agent Tools", () => {
fs.writeFileSync(testFile, originalContent);
const result = await editTool.execute("test-call-5", {
edits: [{ path: testFile, old_text: "world", new_text: "testing" }],
path: testFile,
edits: [{ old_text: "world", new_text: "testing" }],
});
const details = result.details as { diff?: string } | undefined;
@@ -680,7 +681,8 @@ describe("Coding Agent Tools", () => {
await expect(
editTool.execute("test-call-6", {
edits: [{ path: testFile, old_text: "nonexistent", new_text: "testing" }],
path: testFile,
edits: [{ old_text: "nonexistent", new_text: "testing" }],
}),
).rejects.toThrow(/Could not find/);
});
@@ -692,7 +694,8 @@ describe("Coding Agent Tools", () => {
await expect(
editTool.execute("test-call-7", {
edits: [{ path: testFile, old_text: "foo", new_text: "bar" }],
path: testFile,
edits: [{ old_text: "foo", new_text: "bar" }],
}),
).rejects.toThrow(/Found 3 occurrences/);
});
@@ -702,7 +705,8 @@ describe("Coding Agent Tools", () => {
fs.writeFileSync(testFile, "foo bar foo baz foo");
const result = await editTool.execute("test-all-1", {
edits: [{ path: testFile, old_text: "foo", new_text: "qux", all: true }],
path: testFile,
edits: [{ old_text: "foo", new_text: "qux", all: true }],
});
expect(getTextOutput(result)).toContain("Successfully replaced 3 occurrences");
@@ -731,9 +735,9 @@ function b() {
// With multiple fuzzy matches, the tool rejects for safety to avoid ambiguous replacements
await expect(
editTool.execute("test-all-fuzzy", {
path: testFile,
edits: [
{
path: testFile,
old_text: "if (x) {\n doThing();\n}",
new_text: "if (y) {\n doOther();\n}",
all: true,
@@ -749,7 +753,8 @@ function b() {
await expect(
editTool.execute("test-all-nomatch", {
edits: [{ path: testFile, old_text: "nonexistent", new_text: "bar", all: true }],
path: testFile,
edits: [{ old_text: "nonexistent", new_text: "bar", all: true }],
}),
).rejects.toThrow(/Could not find/);
});
@@ -759,7 +764,8 @@ function b() {
fs.writeFileSync(testFile, "start\nfoo\nbar\nend\nstart\nfoo\nbar\nend");
const result = await editTool.execute("test-all-multiline", {
edits: [{ path: testFile, old_text: "foo\nbar", new_text: "replaced", all: true }],
path: testFile,
edits: [{ old_text: "foo\nbar", new_text: "replaced", all: true }],
});
expect(getTextOutput(result)).toContain("Successfully replaced 2 occurrences");
@@ -772,7 +778,8 @@ function b() {
fs.writeFileSync(testFile, "hello world");
const result = await editTool.execute("test-all-single", {
edits: [{ path: testFile, old_text: "world", new_text: "universe", all: true }],
path: testFile,
edits: [{ old_text: "world", new_text: "universe", all: true }],
});
expect(getTextOutput(result)).toContain("Successfully replaced text");
@@ -1219,7 +1226,9 @@ function b() {
fs.writeFileSync(testFile, content);
const contextSettings = Settings.isolated({ "search.contextBefore": 1, "search.contextAfter": 1 });
const contextSearchTool = wrapToolWithMetaNotice(new SearchTool(createTestToolSession(testDir, contextSettings)));
const contextSearchTool = wrapToolWithMetaNotice(
new SearchTool(createTestToolSession(testDir, contextSettings)),
);
const result = await contextSearchTool.execute("test-call-12", {
pattern: "match",
path: testFile,
@@ -1560,7 +1569,8 @@ describe("edit tool CRLF handling", () => {
fs.writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n");
const result = await editTool.execute("test-crlf-1", {
edits: [{ path: testFile, old_text: "line two\n", new_text: "replaced line\n" }],
path: testFile,
edits: [{ old_text: "line two\n", new_text: "replaced line\n" }],
});
expect(getTextOutput(result)).toContain("Successfully replaced");
@@ -1571,7 +1581,8 @@ describe("edit tool CRLF handling", () => {
fs.writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n");
await editTool.execute("test-crlf-2", {
edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }],
path: testFile,
edits: [{ old_text: "second\n", new_text: "REPLACED\n" }],
});
const content = await Bun.file(testFile).text();
@@ -1583,7 +1594,8 @@ describe("edit tool CRLF handling", () => {
fs.writeFileSync(testFile, "first\nsecond\nthird\n");
await editTool.execute("test-lf-1", {
edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }],
path: testFile,
edits: [{ old_text: "second\n", new_text: "REPLACED\n" }],
});
const content = await Bun.file(testFile).text();
@@ -1597,7 +1609,8 @@ describe("edit tool CRLF handling", () => {
await expect(
editTool.execute("test-crlf-dup", {
edits: [{ path: testFile, old_text: "hello\nworld\n", new_text: "replaced\n" }],
path: testFile,
edits: [{ old_text: "hello\nworld\n", new_text: "replaced\n" }],
}),
).rejects.toThrow(/Found 2 occurrences/);
});
@@ -1608,7 +1621,8 @@ describe("edit tool CRLF handling", () => {
fs.writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n");
await editTool.execute("test-bom", {
edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }],
path: testFile,
edits: [{ old_text: "second\n", new_text: "REPLACED\n" }],
});
const content = await Bun.file(testFile).text();
@@ -27,9 +27,14 @@ describe("searchToolRenderer", () => {
},
};
const collapsed = searchToolRenderer.renderResult(result as never, { expanded: false, isPartial: false }, uiTheme, {
pattern: "needle",
});
const collapsed = searchToolRenderer.renderResult(
result as never,
{ expanded: false, isPartial: false },
uiTheme,
{
pattern: "needle",
},
);
const renderedLines = sanitizeText(collapsed.render(200).join("\n")).split("\n");
const bodyLines = renderedLines.slice(1);
+48
View File
@@ -0,0 +1,48 @@
# analyze-edit-formats
Audits how agents have used the `edit` / `ast_edit` / `write` tools across
historical session JSONLs in `~/.omp/agent/sessions/`.
For each tool call we:
- detect the **argument-schema family** in use (the edit tool has shipped many
shapes over time: `oldText/newText`, `op+pos+end+lines`, `loc+content`,
`loc+splice/pre/post/sed`, etc.);
- record the locator shape and verb combination (for the current
`loc+splice/pre/post/sed` schema);
- pair the call with its `toolResult` and classify the outcome
(`success` / `truncated` / `aborted` / `fail:anchor-stale` /
`fail:no-match` / `fail:parse` / `fail:no-enclosing-block` / …).
Output is a markdown-ish report on stdout plus per-call CSV at
`/tmp/edit-analysis/edits.csv` (or your CWD if you set that up differently).
## Usage
```sh
# Scan every session jsonl on disk (slow — ~25k files).
go run ./scripts/analyze-edit-formats
# Scan only files whose path contains the given date prefix(es).
go run ./scripts/analyze-edit-formats 2026-04-28
go run ./scripts/analyze-edit-formats 2026-04-27 2026-04-28
```
The walk root is `~/.omp/agent/sessions/`. Sub-session files (subagent
trajectories nested under `<session-id>/<n>-<name>.jsonl`) are picked up
automatically.
## Why Go
The session corpus is large (>25k files, >100k edit calls). Go iterates the
JSONL stream with negligible memory overhead and finishes in ~90s. The same
analysis in Bun/TS works but is noticeably slower for ad-hoc runs.
## What it's good for
- Comparing reliability across edit-tool argument schemas before changing the
current one.
- Spotting which verb / locator shapes have outsized failure rates so the
prompt can warn against them.
- Sanity-checking that a new edit-tool design isn't regressing the
failure-mode mix versus the previous design.
+3
View File
@@ -0,0 +1,3 @@
module github.com/oh-my-pi/scripts/analyze-edit-formats
go 1.26.2
+651
View File
@@ -0,0 +1,651 @@
// Analyzes how agents use the `edit` tool across today's session jsonl files
// in ~/.omp/agent/sessions/.
//
// For every edit-family toolCall (edit, ast_edit, write) we:
// - record what shape of arguments was supplied (loc, splice/pre/post/sed,
// bracket form of locator, line vs file targeted, etc.)
// - pair it with its toolResult and classify the result as success or as a
// specific failure category (anchor stale, anchor unknown, no enclosing
// block, parse error, ssr no match, etc.).
//
// Output is a markdown-ish report on stdout plus a CSV of every edit attempt
// to ./edit-analysis.csv (override with $EDIT_ANALYSIS_CSV).
package main
import (
"bufio"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
type rawMessage struct {
Type string `json:"type"`
Message json.RawMessage `json:"message"`
}
type message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolName string `json:"toolName"`
ToolCallID string `json:"toolCallId"`
}
type contentItem struct {
Type string `json:"type"`
Text string `json:"text"`
Name string `json:"name"`
ID string `json:"id"`
Arguments json.RawMessage `json:"arguments"`
}
type editEntry struct {
File string
CallID string
ToolName string
NumEdits int
Verbs []string // splice/pre/post/sed per sub-edit
LocShapes []string // bare/bracket-(/bracket-[/bracket-tail/bracket-head/$pre/$post/$sed
HasNewFile bool
HasGlob bool
HasOps bool
Format string // edit-tool argument schema family
ResultRaw string
Status string // "success" or failure category
}
// matchDate accepts files whose path contains any of the supplied date
// prefixes (e.g. "2026-04-28"). With no flags it accepts every .jsonl.
var dateFilters []string
func matchDate(p string) bool {
if len(dateFilters) == 0 {
return true
}
for _, d := range dateFilters {
if strings.Contains(p, d) {
return true
}
}
return false
}
func main() {
dateFilters = os.Args[1:]
root, err := os.UserHomeDir()
must(err)
base := filepath.Join(root, ".omp", "agent", "sessions")
var files []string
must(filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(p, ".jsonl") {
return nil
}
if !matchDate(p) {
return nil
}
files = append(files, p)
return nil
}))
sort.Strings(files)
fmt.Fprintf(os.Stderr, "loaded %d session files for today\n", len(files))
var entries []editEntry
for _, f := range files {
entries = append(entries, processFile(f)...)
}
report(entries)
writeCSV(entries)
}
func processFile(path string) []editEntry {
fh, err := os.Open(path)
if err != nil {
fmt.Fprintln(os.Stderr, "open:", err)
return nil
}
defer fh.Close()
calls := map[string]*editEntry{}
var order []string
sc := bufio.NewScanner(fh)
sc.Buffer(make([]byte, 0, 64*1024), 64*1024*1024)
for sc.Scan() {
var rm rawMessage
if err := json.Unmarshal(sc.Bytes(), &rm); err != nil {
continue
}
if rm.Type != "message" {
continue
}
var m message
if err := json.Unmarshal(rm.Message, &m); err != nil {
continue
}
var items []contentItem
if err := json.Unmarshal(m.Content, &items); err != nil {
continue
}
switch m.Role {
case "assistant":
for _, it := range items {
if it.Type != "toolCall" {
continue
}
if !isEditTool(it.Name) {
continue
}
e := classifyArgs(it.Name, it.Arguments)
e.File = path
e.CallID = it.ID
e.ToolName = it.Name
calls[it.ID] = &e
order = append(order, it.ID)
}
case "toolResult":
if !isEditTool(m.ToolName) {
continue
}
e, ok := calls[m.ToolCallID]
if !ok {
// orphan result, skip
continue
}
text := joinText(items)
e.ResultRaw = text
e.Status = classifyResult(m.ToolName, text)
}
}
out := make([]editEntry, 0, len(order))
for _, id := range order {
if e, ok := calls[id]; ok {
out = append(out, *e)
}
}
return out
}
func isEditTool(name string) bool {
switch strings.ToLower(name) {
case "edit", "ast_edit", "write":
return true
}
return false
}
func joinText(items []contentItem) string {
var b strings.Builder
for _, it := range items {
if it.Type == "text" {
b.WriteString(it.Text)
}
}
return b.String()
}
// ---- argument classification ----
type editOp struct {
Loc string `json:"loc"`
Splice json.RawMessage `json:"splice"`
Pre json.RawMessage `json:"pre"`
Post json.RawMessage `json:"post"`
Sed json.RawMessage `json:"sed"`
}
type editArgs struct {
Path string `json:"path"`
Edits []editOp `json:"edits"`
// ast_edit
Ops []json.RawMessage `json:"ops"`
// Write
Content *string `json:"content,omitempty"`
}
var anchorBare = regexp.MustCompile(`^[a-zA-Z]?\d+[a-z]{2}$`)
var anchorWithFile = regexp.MustCompile(`^[^:]+:\d+[a-z]{2}$`)
func classifyArgs(name string, raw json.RawMessage) editEntry {
e := editEntry{}
e.Format = detectFormat(name, raw)
switch strings.ToLower(name) {
case "edit":
var a editArgs
_ = json.Unmarshal(raw, &a)
e.NumEdits = len(a.Edits)
for _, op := range a.Edits {
e.LocShapes = append(e.LocShapes, locShape(op.Loc))
verbs := []string{}
if !isNullOrEmpty(op.Splice) {
verbs = append(verbs, "splice")
}
if !isNullOrEmpty(op.Pre) {
verbs = append(verbs, "pre")
}
if !isNullOrEmpty(op.Post) {
verbs = append(verbs, "post")
}
if !isNullOrEmpty(op.Sed) {
verbs = append(verbs, "sed")
}
if len(verbs) == 0 {
verbs = append(verbs, "none")
}
e.Verbs = append(e.Verbs, strings.Join(verbs, "+"))
}
case "ast_edit":
var a editArgs
_ = json.Unmarshal(raw, &a)
e.HasOps = len(a.Ops) > 0
e.NumEdits = len(a.Ops)
if strings.ContainsAny(a.Path, "*?,") {
e.HasGlob = true
}
case "write":
e.HasNewFile = true
e.NumEdits = 1
e.Verbs = []string{"write"}
}
return e
}
// detectFormat figures out which edit-tool argument schema is in use by
// looking at the top-level argument keys and (for `edit`) the keys of the
// first sub-edit. Older sessions used many incompatible schemas.
func detectFormat(name string, raw json.RawMessage) string {
switch strings.ToLower(name) {
case "write":
return "write"
case "ast_edit":
return "ast_edit"
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return "unknown"
}
has := func(k string) bool { _, ok := top[k]; return ok }
switch {
case has("oldText") && has("newText"):
return "oldText/newText"
case has("old_text") && has("new_text"):
return "old_text/new_text"
case has("diff") && has("op"):
return "diff+op"
case has("diff") && has("operation"):
return "diff+operation"
case has("diff"):
return "diff"
case has("replace") || has("insert"):
return "replace/insert"
}
if edits, ok := top["edits"]; ok {
var list []map[string]json.RawMessage
if err := json.Unmarshal(edits, &list); err == nil && len(list) > 0 {
first := list[0]
fh := func(k string) bool { _, ok := first[k]; return ok }
switch {
case fh("loc") && (fh("splice") || fh("pre") || fh("post") || fh("sed")):
return "loc+splice/pre/post/sed"
case fh("loc") && fh("content"):
return "loc+content"
case fh("set_line"):
return "set_line"
case fh("insert_after"):
return "insert_after"
case fh("op") && fh("pos") && fh("end") && fh("lines"):
return "op+pos+end+lines"
case fh("op") && fh("pos") && fh("lines"):
return "op+pos+lines"
case fh("op") && fh("sel") && fh("content"):
return "op+sel+content"
case fh("all") && (fh("new_text") || fh("old_text")):
return "per-edit:old_text/new_text"
}
keys := make([]string, 0, len(first))
for k := range first {
keys = append(keys, k)
}
sort.Strings(keys)
return "edits[" + strings.Join(keys, ",") + "]"
}
}
keys := make([]string, 0, len(top))
for k := range top {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ",")
}
func isNullOrEmpty(b json.RawMessage) bool {
s := strings.TrimSpace(string(b))
return s == "" || s == "null"
}
func locShape(loc string) string {
if loc == "" {
return "empty"
}
if loc == "$" {
return "$file"
}
// strip optional file: prefix
rest := loc
if i := strings.LastIndex(loc, ":"); i >= 0 && !strings.HasPrefix(loc, "$") {
rest = loc[i+1:]
}
switch {
case strings.HasPrefix(rest, "(") && strings.HasSuffix(rest, ")"):
return "bracket-(body)"
case strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]"):
return "bracket-[block]"
case strings.HasPrefix(rest, "(") || strings.HasPrefix(rest, "["):
return "bracket-tail"
case strings.HasSuffix(rest, ")") || strings.HasSuffix(rest, "]"):
return "bracket-head"
case anchorBare.MatchString(rest):
return "bare-anchor"
}
return "other"
}
// ---- result classification ----
var (
reAnchorStale = regexp.MustCompile(`(?i)(Edit rejected:.*line[s]? .* changed since the last read|line[s]? ha(s|ve) changed since last read)`)
reAnchorMissing = regexp.MustCompile(`(?i)anchor .* (not found|unknown|missing)|loc requires the full anchor`)
reNoEnclosing = regexp.MustCompile(`(?i)No enclosing .* block`)
reParseError = regexp.MustCompile(`(?i)parse|syntax error|unbalanced|unexpected token`)
reSSRNoMatch = regexp.MustCompile(`(?i)0 matches|no replacements|no match found|No replacements made|Failed to find expected lines`)
reFileNotRead = regexp.MustCompile(`(?i)must be read first|has not been read|not yet read`)
reFileChanged = regexp.MustCompile(`(?i)file has been (modified|changed) externally`)
rePermDenied = regexp.MustCompile(`(?i)permission denied|not allowed`)
reGenericRejected = regexp.MustCompile(`(?i)\b(rejected|failed|error|invalid)\b`)
reTruncated = regexp.MustCompile(`(?i)\[Output truncated`)
reAborted = regexp.MustCompile(`(?i)Tool execution was aborted|Request was aborted|cancelled|canceled by user`)
reSuccess = regexp.MustCompile(`(?i)^(Updated|Successfully (wrote|replaced|edited|deleted|inserted)|Replaced|Applied|Deleted|Created|Wrote|edit applied|Edited|Inserted|OK\b)`)
)
func classifyResult(tool, text string) string {
t := strings.TrimSpace(text)
if t == "" {
return "empty"
}
first := strings.SplitN(t, "\n", 2)[0]
switch {
case reTruncated.MatchString(first):
return "truncated"
case reAborted.MatchString(t):
return "aborted"
case reSuccess.MatchString(first):
return "success"
case reAnchorStale.MatchString(t):
return "fail:anchor-stale"
case reNoEnclosing.MatchString(t):
return "fail:no-enclosing-block"
case reAnchorMissing.MatchString(t):
return "fail:anchor-missing"
case reParseError.MatchString(t):
return "fail:parse"
case reSSRNoMatch.MatchString(t):
return "fail:no-match"
case reFileNotRead.MatchString(t):
return "fail:file-not-read"
case reFileChanged.MatchString(t):
return "fail:file-changed"
case rePermDenied.MatchString(t):
return "fail:perm"
case reGenericRejected.MatchString(first):
return "fail:other"
}
return "unknown"
}
// ---- reporting ----
func report(entries []editEntry) {
if len(entries) == 0 {
fmt.Println("no edit-family tool calls found in today's sessions")
return
}
byTool := map[string]int{}
byFormat := map[string]int{}
statusByFormat := map[string]map[string]int{}
statusByTool := map[string]map[string]int{}
verbCount := map[string]int{}
locCount := map[string]int{}
failsByVerb := map[string]map[string]int{}
failsByLoc := map[string]map[string]int{}
for _, e := range entries {
byTool[e.ToolName]++
if statusByTool[e.ToolName] == nil {
statusByTool[e.ToolName] = map[string]int{}
}
statusByTool[e.ToolName][e.Status]++
byFormat[e.Format]++
if statusByFormat[e.Format] == nil {
statusByFormat[e.Format] = map[string]int{}
}
statusByFormat[e.Format][e.Status]++
for _, v := range e.Verbs {
verbCount[v]++
if failsByVerb[v] == nil {
failsByVerb[v] = map[string]int{}
}
failsByVerb[v][e.Status]++
}
for _, l := range e.LocShapes {
locCount[l]++
if failsByLoc[l] == nil {
failsByLoc[l] = map[string]int{}
}
failsByLoc[l][e.Status]++
}
}
fmt.Println("# Edit-tool usage in today's sessions")
fmt.Printf("\nTotal tool calls: %d (across %d sessions)\n",
len(entries), countSessions(entries))
fmt.Println("\n## By tool")
printSorted(byTool)
fmt.Println("\n## Outcome by tool")
tools := keys(byTool)
sort.Strings(tools)
for _, t := range tools {
fmt.Printf("\n %s (%d calls):\n", t, byTool[t])
printSortedIndent(statusByTool[t], " ")
}
fmt.Println("\n## edit verb distribution (per sub-edit)")
printSorted(verbCount)
fmt.Println("\n## edit locator shape distribution")
printSorted(locCount)
fmt.Println("\n## Failure rate per verb shape")
for _, v := range sortedKeys(verbCount) {
total, failed := 0, 0
for status, n := range failsByVerb[v] {
total += n
if strings.HasPrefix(status, "fail") {
failed += n
}
}
fmt.Printf(" %-20s %d/%d failed (%.0f%%)\n", v, failed, total, pct(failed, total))
}
fmt.Println("\n## Failure rate per locator shape")
for _, l := range sortedKeys(locCount) {
total, failed := 0, 0
for status, n := range failsByLoc[l] {
total += n
if strings.HasPrefix(status, "fail") {
failed += n
}
}
fmt.Printf(" %-20s %d/%d failed (%.0f%%)\n", l, failed, total, pct(failed, total))
}
fmt.Println("\n## edit-tool argument-format usage")
printSorted(byFormat)
fmt.Println("\n## Failure rate per argument format")
for _, fname := range sortedKeys(byFormat) {
total, failed := 0, 0
for status, n := range statusByFormat[fname] {
total += n
if strings.HasPrefix(status, "fail") {
failed += n
}
}
fmt.Printf(" %-32s %6d/%-6d failed (%.0f%%)\n", fname, failed, total, pct(failed, total))
}
fmt.Println("\n## Failure breakdown per top format")
cap := 0
for _, fname := range sortedKeys(byFormat) {
if cap >= 8 {
break
}
cap++
fmt.Printf("\n %s (%d total)\n", fname, byFormat[fname])
printSortedIndent(statusByFormat[fname], " ")
}
fmt.Println("\n## Sample failed edits")
shown := 0
for _, e := range entries {
if !strings.HasPrefix(e.Status, "fail") {
continue
}
fmt.Printf("\n— %s [%s] verbs=%v loc=%v\n result: %s\n",
e.ToolName, e.Status, e.Verbs, e.LocShapes,
truncate(strings.SplitN(e.ResultRaw, "\n\n", 2)[0], 220))
shown++
if shown >= 8 {
break
}
}
}
func countSessions(es []editEntry) int {
s := map[string]struct{}{}
for _, e := range es {
s[e.File] = struct{}{}
}
return len(s)
}
func printSorted(m map[string]int) {
for _, k := range sortedKeys(m) {
fmt.Printf(" %-25s %d\n", k, m[k])
}
}
func printSortedIndent(m map[string]int, indent string) {
for _, k := range sortedKeys(m) {
fmt.Printf("%s%-25s %d\n", indent, k, m[k])
}
}
func sortedKeys(m map[string]int) []string {
type kv struct {
k string
v int
}
pairs := make([]kv, 0, len(m))
for k, v := range m {
pairs = append(pairs, kv{k, v})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].v != pairs[j].v {
return pairs[i].v > pairs[j].v
}
return pairs[i].k < pairs[j].k
})
out := make([]string, len(pairs))
for i, p := range pairs {
out[i] = p.k
}
return out
}
func keys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func pct(a, b int) float64 {
if b == 0 {
return 0
}
return 100 * float64(a) / float64(b)
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " | ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func writeCSV(entries []editEntry) {
csvPath := os.Getenv("EDIT_ANALYSIS_CSV")
if csvPath == "" {
csvPath = "edit-analysis.csv"
}
f, err := os.Create(csvPath)
if err != nil {
fmt.Fprintln(os.Stderr, "csv:", err)
return
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
_ = w.Write([]string{"session", "tool", "status", "num_edits", "verbs", "loc_shapes", "result_first_line"})
for _, e := range entries {
first := strings.SplitN(e.ResultRaw, "\n", 2)[0]
_ = w.Write([]string{
filepath.Base(e.File),
e.ToolName,
e.Status,
fmt.Sprintf("%d", e.NumEdits),
strings.Join(e.Verbs, ","),
strings.Join(e.LocShapes, ","),
truncate(first, 200),
})
}
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}