feat: added compact atom-mode parser and execution support

- Added compact Lark grammar processing and applied it to OpenAI custom-format tools before conversion.
- Reworked atom mode into `---PATH` compact commands with new grammar, parser, and rm/mv file operations.
- Updated `hline`/`href`/`hrefr` helper behavior and hashline mismatch guidance using shared anchor state.
- Standardized path formatting with `formatPathRelativeToCwd` across LSP, prompts, and edit/search/write tools.
- Added benchmark run-path handling, including `.gitignore` runs mapping, absolute reports, and safer snapshot output.
- Added tests for compact grammar payloads, atom parsing/execution, renderer streaming, and path-list outputs.
This commit is contained in:
can1357
2026-04-29 01:16:04 +02:00
parent c751b9c4d5
commit c6a11079f5
39 changed files with 2488 additions and 1096 deletions
+1 -1
View File
@@ -56,5 +56,5 @@ pi-*.html
# Generated files
packages/coding-agent/src/internal-urls/docs-index.generated.ts
packages/typescript-edit-benchmark/runs/
/runs/
python/omp-rpc/src/omp_rpc.egg-info/
+3
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
### Changed
- Changed OpenAI custom Lark grammar payloads to strip comments and blank lines before sending provider requests.
### Fixed
+70
View File
@@ -0,0 +1,70 @@
export function compactGrammarDefinition(syntax: "lark" | "regex", definition: string): string {
if (syntax !== "lark") {
return definition;
}
return compactLarkGrammarDefinition(definition);
}
function compactLarkGrammarDefinition(definition: string): string {
const lines: string[] = [];
for (const line of definition.split(/\r?\n/)) {
const uncommented = stripLarkLineComment(line).trimEnd();
if (uncommented.trim()) {
lines.push(uncommented);
}
}
return lines.join("\n");
}
function stripLarkLineComment(line: string): string {
let inString: string | undefined;
let inRegex = false;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const next = line[i + 1];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (inString) {
if (char === inString) {
inString = undefined;
}
continue;
}
if (inRegex) {
if (char === "/") {
inRegex = false;
}
continue;
}
if (char === "/" && next === "/") {
return line.slice(0, i);
}
if (char === '"' || char === "'") {
inString = char;
continue;
}
if (char === "/") {
inRegex = true;
}
}
return line;
}
@@ -42,6 +42,7 @@ import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-ins
import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
import { parseStreamingJson } from "../utils/json-parse";
import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
import { compactGrammarDefinition } from "./grammar";
import {
CODEX_BASE_URL,
getCodexAccountId,
@@ -2393,7 +2394,7 @@ export function convertTools(tools: Tool[], model: Model<"openai-codex-responses
format: {
type: "grammar",
syntax: tool.customFormat.syntax,
definition: tool.customFormat.definition,
definition: compactGrammarDefinition(tool.customFormat.syntax, tool.customFormat.definition),
},
};
}
@@ -46,6 +46,7 @@ import {
hasCopilotVisionInput,
resolveGitHubCopilotBaseUrl,
} from "./github-copilot-headers";
import { compactGrammarDefinition } from "./grammar";
import {
appendResponsesToolResultMessages,
collectCustomCallIds,
@@ -577,7 +578,7 @@ export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"o
format: {
type: "grammar",
syntax: tool.customFormat.syntax,
definition: tool.customFormat.definition,
definition: compactGrammarDefinition(tool.customFormat.syntax, tool.customFormat.definition),
},
} as unknown as OpenAITool;
}
+11 -3
View File
@@ -17,7 +17,15 @@ import type { AssistantMessage, Model, Tool, ToolResultMessage } from "@oh-my-pi
import { Type } from "@sinclair/typebox";
import type { ResponseStreamEvent } from "openai/resources/responses/responses";
const GRAMMAR = 'start: "*** Begin Patch" LF';
const GRAMMAR = [
"// top-level comment",
"",
'start: "*** Begin Patch" LF // trailing comment',
"PATH: /https?:\\/\\/[^\\n]+/",
'LITERAL: "//"',
"",
].join("\n");
const COMPACT_GRAMMAR = 'start: "*** Begin Patch" LF\nPATH: /https?:\\/\\/[^\\n]+/\nLITERAL: "//"';
function makeModel(overrides: Partial<Model<"openai-responses">> = {}): Model<"openai-responses"> {
return {
@@ -96,7 +104,7 @@ describe("convertTools: freeform emission", () => {
const [out] = convertTools([editTool], false, freeformModel) as unknown as Array<Record<string, unknown>>;
expect(out.type).toBe("custom");
expect(out.name).toBe("apply_patch"); // wire name from tool.customWireName
expect(out.format).toEqual({ type: "grammar", syntax: "lark", definition: GRAMMAR });
expect(out.format).toEqual({ type: "grammar", syntax: "lark", definition: COMPACT_GRAMMAR });
});
test("regular tools remain function-type alongside a custom one", () => {
@@ -316,7 +324,7 @@ describe("codex-backend convertTools (chatgpt.com/backend-api)", () => {
expect(out.type).toBe("custom");
expect(out.name).toBe("apply_patch");
if (out.type !== "custom") throw new Error("Expected custom tool payload");
expect(out.format).toEqual({ type: "grammar", syntax: "lark", definition: GRAMMAR });
expect(out.format).toEqual({ type: "grammar", syntax: "lark", definition: COMPACT_GRAMMAR });
});
test("wire shape matches direct-OpenAI convertTools (single serializer contract)", () => {
+5 -7
View File
@@ -1,12 +1,9 @@
# 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
- Changed the `atom` edit mode from JSON `{ path, edits }` calls to the compact file-oriented `input` patch language that was previously exposed as `atomd`; `atomd` is no longer a separate edit variant
- 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
- Renamed the built-in content-search tool from `grep` to `search`, including SDK/tool event names and settings keys (`search.enabled`, `search.contextBefore`, `search.contextAfter`), so integrations using `grep` and `grep.*` references must be updated
@@ -14,17 +11,18 @@
- Added internal URL support to the `search` tool, allowing `artifact://`-style paths that resolve to local files to be searched directly
- Added IRC relay observation in the main agent UI so every IRC exchange between agents is rendered in the main transcript, even when the main agent is not a direct participant
- Added stateful `href`/`hrefr` prompt helpers that can reuse anchors remembered from prior `hline` helper calls
### Changed
- Changed file-path rendering across search, find, AST, LSP, and related edit outputs to display targets as cwd-relative paths when they resolve inside the working directory and keep absolute paths for files outside the cwd
- Changed system prompt guidance so in-cwd tool paths must be passed as cwd-relative paths and absolute paths only for out-of-cwd targets or `~` expansion
- 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.find` to remain single-line; `replace.with` now allows multiline replacements
- Bumped default `read.defaultLimit` from 300 to 500 lines, and scaled the read tool's byte budget with the line limit (`max(50KB, lines * 512)`) so the configured line count is no longer truncated by the shared 50KB cap
### Fixed
- Fixed atom edit streaming previews to use atom headers for file names instead of apply_patch parsing errors.
- Fixed collapsed search result rendering so summary and truncation rows stay within the collapsed output budget
- Updated search path handling to support path lists and internal file paths while preserving previous search behavior
@@ -43,24 +43,119 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
return { num, text, ref };
}
interface HashlineHelperRef {
line: number;
ref: string;
}
interface HashlineHelperState {
last?: HashlineHelperRef;
byLine: Map<number, HashlineHelperRef>;
}
const HASHLINE_HELPER_STATE = Symbol("hashlineHelperState");
interface HashlineHelperStateHolder {
[HASHLINE_HELPER_STATE]?: HashlineHelperState;
}
function isHelperOptions(value: unknown): value is prompt.HelperOptions {
return typeof value === "object" && value !== null && "hash" in value;
}
function splitHelperArgs(args: unknown[]): { positional: unknown[]; options?: prompt.HelperOptions } {
const maybeOptions = args.at(-1);
if (!isHelperOptions(maybeOptions)) return { positional: args };
return { positional: args.slice(0, -1), options: maybeOptions };
}
function getHashlineHelperState(context: unknown, options: prompt.HelperOptions | undefined): HashlineHelperState {
const data = options?.data;
const root = data?.root;
const holderTarget = data && typeof data === "object" ? data : root && typeof root === "object" ? root : context;
if (!holderTarget || typeof holderTarget !== "object") {
throw new Error("hashline prompt helpers require an object render context");
}
const holder = holderTarget as HashlineHelperStateHolder;
if (!holder[HASHLINE_HELPER_STATE]) {
holder[HASHLINE_HELPER_STATE] = { byLine: new Map() };
}
return holder[HASHLINE_HELPER_STATE];
}
function isLineNumberArg(value: unknown): boolean {
const num = typeof value === "number" ? value : Number.parseInt(String(value), 10);
return Number.isFinite(num);
}
function rememberHashlineRef(state: HashlineHelperState, line: number, ref: string): void {
const entry = { line, ref };
state.last = entry;
state.byLine.set(line, entry);
}
function requireStoredHashlineRef(state: HashlineHelperState, lineArg?: unknown): string {
if (lineArg === undefined) {
if (!state.last) {
throw new Error("{{href}} requires a previous {{hline}} call in the same prompt render");
}
return state.last.ref;
}
const line = typeof lineArg === "number" ? lineArg : Number.parseInt(String(lineArg), 10);
const entry = state.byLine.get(line);
if (!entry) {
throw new Error(`{{href ${line}}} requires a previous {{hline ${line} ...}} call in the same prompt render`);
}
return entry.ref;
}
function wrapHashlineRef(ref: string, args: unknown[]): string {
const preStr = typeof args[0] === "string" ? args[0] : "";
const postStr = typeof args[1] === "string" ? args[1] : "";
return `${preStr}${ref}${postStr}`;
}
function resolveHashlineRef(state: HashlineHelperState, args: unknown[]): string {
if (args.length === 0) return requireStoredHashlineRef(state);
const [first, second, ...rest] = args;
if (isLineNumberArg(first)) {
if (second === undefined) return requireStoredHashlineRef(state, first);
const { ref } = formatHashlineRef(first, second);
return wrapHashlineRef(ref, rest);
}
return wrapHashlineRef(requireStoredHashlineRef(state), args);
}
/**
* {{href lineNum "content"}} — compute a real hashline ref for prompt examples.
* {{href lineNum "content" "[" "]"}} — wrap the ref with pre/post chars (still quoted).
* {{href lineNum}} — quote the ref remembered by the earlier {{hline lineNum "..."}}
* {{href}} — quote the ref from the previous {{hline}} call.
* {{href "[" "]"}} — wrap the previous {{hline}} ref with pre/post chars.
* Returns `"lineNumBIGRAM"` (e.g., `"42nd"`), or `"[42nd]"` when pre/post are supplied.
*/
prompt.registerHelper("href", (lineNum: unknown, content: unknown, pre?: unknown, post?: unknown): string => {
const { ref } = formatHashlineRef(lineNum, content);
const preStr = typeof pre === "string" ? pre : "";
const postStr = typeof post === "string" ? post : "";
return JSON.stringify(`${preStr}${ref}${postStr}`);
prompt.registerHelper("href", function (this: unknown, ...args: unknown[]): string {
const { positional, options } = splitHelperArgs(args);
const state = getHashlineHelperState(this, options);
return JSON.stringify(resolveHashlineRef(state, positional));
});
prompt.registerHelper("hrefr", function (this: unknown, ...args: unknown[]): string {
const { positional, options } = splitHelperArgs(args);
const state = getHashlineHelperState(this, options);
return resolveHashlineRef(state, positional);
});
/**
* {{hline lineNum "content"}} — format a full read-style line with prefix.
* Returns `"lineNumBIGRAM|content"` (pipe between anchor and content).
*/
prompt.registerHelper("hline", (lineNum: unknown, content: unknown): string => {
const { ref, text } = formatHashlineRef(lineNum, content);
prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): string {
const { positional, options } = splitHelperArgs(args);
const [lineNum, content] = positional;
const { num, ref, text } = formatHashlineRef(lineNum, content);
const state = getHashlineHelperState(this, options);
rememberHashlineRef(state, num, ref);
return `${ref}${HASHLINE_CONTENT_SEPARATOR}${text}`;
});
@@ -1,5 +1,6 @@
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { TASK_SIMPLE_MODES } from "../task/simple-mode";
import { EDIT_MODES } from "../utils/edit-mode";
/** Unified settings schema - single source of truth for all settings.
* Unified settings schema - single source of truth for all settings.
@@ -955,12 +956,12 @@ export const SETTINGS_SCHEMA = {
// Edit tool
"edit.mode": {
type: "enum",
values: ["replace", "patch", "hashline", "vim", "apply_patch", "atom"] as const,
values: EDIT_MODES,
default: "hashline",
ui: {
tab: "editing",
label: "Edit Mode",
description: "Select the edit tool variant (replace, patch, hashline, vim, or apply_patch)",
description: "Select the edit tool variant (replace, patch, hashline, atom, vim, or apply_patch)",
},
},
+1 -1
View File
@@ -326,7 +326,7 @@ export class Settings {
/**
* Get the edit variant for a specific model.
* Returns "patch", "replace", "hashline", "vim", "apply_patch", or null (use global default).
* Returns "patch", "replace", "hashline", "atom", "vim", "apply_patch", or null (use global default).
*/
getEditVariantForModel(model: string | undefined): EditMode | null {
if (!model) return null;
+7 -5
View File
@@ -19,7 +19,8 @@ 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 } from "./modes/atom";
import { type AtomParams, atomEditParamsSchema, executeAtomSingle } from "./modes/atom";
import atomGrammar from "./modes/atom.lark" with { type: "text" };
import {
executeHashlineSingle,
HashlineMismatchError,
@@ -290,8 +291,9 @@ export class EditTool implements AgentTool<TInput> {
* and fall back to emitting a JSON function tool from `parameters`.
*/
get customFormat(): { syntax: "lark"; definition: string } | undefined {
if (this.mode !== "apply_patch") return undefined;
return { syntax: "lark", definition: applyPatchGrammar };
if (this.mode === "apply_patch") return { syntax: "lark", definition: applyPatchGrammar };
if (this.mode === "atom") return { syntax: "lark", definition: atomGrammar };
return undefined;
}
/**
@@ -410,11 +412,11 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits, path } = params as AtomParams;
const { input, path } = params as AtomParams & { path?: string };
return executeAtomSingle({
session: tool.session,
input,
path,
edits: edits as AtomToolEdit[],
signal,
batchRequest,
writethrough: tool.#writethrough,
@@ -0,0 +1,27 @@
start: file_section+
file_section: file_header (line_change | whole_file_change)
file_header: "---" filename LF
filename: /(.+)/
line_change: line* mutation_line line*
line: insert_line | delete_line | set_line | move_line | blank
mutation_line: insert_line | delete_line | set_line
whole_file_change: blank* whole_file_line blank*
whole_file_line: remove_file | move_file
remove_file: "!rm" LF
move_file: "!mv" WS destination LF
destination: /(?:[^ \t\r\n]+|"[^"\r\n]+"|'[^'\r\n]+')/
insert_line: "+" /(.*)/ LF
delete_line: "-" LID LF
set_line: LID "=" /(.*)/ LF
move_line: ("@" LID | "$" | "^") LF
LID: /[1-9][0-9]*[a-z]{2}/
WS: /[ \t]+/
blank: LF
%import common.LF
File diff suppressed because it is too large Load Diff
@@ -565,8 +565,8 @@ export class HashlineMismatchError extends Error {
const sorted = [...displayLines].sort((a, b) => a - b);
const out: string[] = [
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
"Realign your edit to the file state shown below. Copy the full anchors exactly as shown (for example `160sr`, not just `sr`).",
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
"The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
"",
];
@@ -602,8 +602,8 @@ export class HashlineMismatchError extends Error {
const lines: string[] = [];
lines.push(
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
"Use the updated anchors shown below (`*` marks changed lines, leading space marks context) and retry the edit.",
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
"The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
);
lines.push("");
+62 -5
View File
@@ -106,6 +106,10 @@ type EditRenderEntry = {
op?: Operation;
};
interface AtomRenderSummary {
entries: Array<{ path: string }>;
}
interface ApplyPatchRenderSummary {
entries: ApplyPatchEntry[];
error?: string;
@@ -305,8 +309,54 @@ function getCallPreview(
}
const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
const ATOM_HEADER_PREFIX = "---";
function normalizeAtomPreviewPath(rawPath: string): string {
const trimmed = rawPath.trim();
if (trimmed.length < 2) return trimmed;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' || first === "'") && first === last) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseAtomPreviewHeader(line: string): string | null {
if (!line.startsWith(ATOM_HEADER_PREFIX)) return null;
let body = line.slice(ATOM_HEADER_PREFIX.length);
if (body.startsWith(" ")) body = body.slice(1);
const previewPath = normalizeAtomPreviewPath(body);
return previewPath.length > 0 ? previewPath : null;
}
function getAtomInputPaths(input: string): string[] {
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
const paths: string[] = [];
for (const rawLine of stripped.split("\n")) {
const line = rawLine.replace(/\r$/, "");
const path = parseAtomPreviewHeader(line);
if (path) paths.push(path);
}
return paths;
}
function getAtomRenderSummary(args: EditRenderArgs, editMode: EditMode | undefined): AtomRenderSummary | undefined {
if (editMode !== "atom" || typeof args.input !== "string") {
return undefined;
}
return { entries: getAtomInputPaths(args.input).map(path => ({ path })) };
}
function getApplyPatchRenderSummary(
args: EditRenderArgs,
isPartial: boolean,
editMode: EditMode | undefined,
): ApplyPatchRenderSummary | undefined {
if (editMode !== undefined && editMode !== "apply_patch") {
return undefined;
}
function getApplyPatchRenderSummary(args: EditRenderArgs, isPartial: boolean): ApplyPatchRenderSummary | undefined {
if (typeof args.input !== "string") {
return undefined;
}
@@ -397,8 +447,10 @@ export const editToolRenderer = {
}
const editArgs = args as EditRenderArgs;
const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial);
const atomSummary = getAtomRenderSummary(editArgs, renderContext?.editMode);
const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial, renderContext?.editMode);
const firstApplyPatchEntry = applyPatchSummary?.entries[0];
const firstAtomEntry = atomSummary?.entries[0];
// Extract path from first edit entry when top-level path is absent (new schema)
const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
const rawPath =
@@ -406,6 +458,7 @@ export const editToolRenderer = {
editArgs.path ||
filePathFromEditEntry(firstEdit?.path) ||
getPartialJsonEditPath(editArgs) ||
firstAtomEntry?.path ||
firstApplyPatchEntry?.path ||
"";
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
@@ -415,9 +468,10 @@ export const editToolRenderer = {
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
// Show file count hint for multi-file edits
const fileCount = Array.isArray(editArgs.edits)
? countEditFiles(editArgs.edits)
: (applyPatchSummary?.entries.length ?? 0);
let fileCount = atomSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
if (Array.isArray(editArgs.edits)) {
fileCount = countEditFiles(editArgs.edits);
}
if (fileCount > 1) {
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
}
@@ -465,11 +519,14 @@ function renderSingleFileResult(
const details = result.details;
const isError = result.isError ?? (details && "isError" in details ? details.isError : false);
const firstEdit = args?.edits?.[0];
const atomSummary = getAtomRenderSummary(args ?? {}, options.renderContext?.editMode);
const firstAtomEntry = atomSummary?.entries[0];
const rawPath =
args?.file_path ||
args?.path ||
filePathFromEditEntry(firstEdit?.path) ||
(details && "path" in details ? details.path : "") ||
firstAtomEntry?.path ||
"";
const op = args?.op || firstEdit?.op || details?.op;
const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
+6 -7
View File
@@ -290,18 +290,17 @@ const vimStrategy: EditStreamingStrategy<unknown> = {
};
interface AtomArgs {
path?: string;
edits?: unknown[];
input?: string;
__partialJson?: string;
}
const atomStrategy: EditStreamingStrategy<AtomArgs> = {
extractCompleteEdits(args, partialJson) {
if (!args.edits) return args;
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
extractCompleteEdits(args) {
return args;
},
async computeDiffPreview() {
// Atom edits are line-anchored and validated against live file hashes; a
// streaming preview without that validation could mislead. Skip for now.
// Atom edits can target file headers plus compact diff statements.
// We intentionally avoid speculative parsing while args are partial.
return null;
},
renderStreamingFallback() {
+8 -5
View File
@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises";
import path from "node:path";
import { formatPathRelativeToCwd } from "../tools/path-utils";
import type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types";
import { uriToFile } from "./utils";
@@ -67,7 +68,7 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom
for (const [uri, textEdits] of Object.entries(edit.changes)) {
const filePath = uriToFile(uri);
await applyTextEdits(filePath, textEdits);
applied.push(`Applied ${textEdits.length} edit(s) to ${path.relative(cwd, filePath)}`);
applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
}
}
@@ -80,26 +81,28 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom
const filePath = uriToFile(docChange.textDocument.uri);
const textEdits = docChange.edits.filter((e): e is TextEdit => "range" in e && "newText" in e);
await applyTextEdits(filePath, textEdits);
applied.push(`Applied ${textEdits.length} edit(s) to ${path.relative(cwd, filePath)}`);
applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if ("kind" in change && change.kind) {
// Resource operations
if (change.kind === "create") {
const createOp = change as CreateFile;
const filePath = uriToFile(createOp.uri);
await Bun.write(filePath, "");
applied.push(`Created ${path.relative(cwd, filePath)}`);
applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
} else if (change.kind === "rename") {
const renameOp = change as RenameFile;
const oldPath = uriToFile(renameOp.oldUri);
const newPath = uriToFile(renameOp.newUri);
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.rename(oldPath, newPath);
applied.push(`Renamed ${path.relative(cwd, oldPath)} → ${path.relative(cwd, newPath)}`);
applied.push(
`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`,
);
} else if (change.kind === "delete") {
const deleteOp = change as DeleteFile;
const filePath = uriToFile(deleteOp.uri);
await fs.rm(filePath, { recursive: true });
applied.push(`Deleted ${path.relative(cwd, filePath)}`);
applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
}
}
}
+4 -4
View File
@@ -6,7 +6,7 @@ import type { BunFile } from "bun";
import { type Theme, theme } from "../modes/theme/theme";
import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
import type { ToolSession } from "../tools";
import { resolveToCwd } from "../tools/path-utils";
import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
import { clampTimeout } from "../tools/tool-timeouts";
import {
@@ -562,7 +562,7 @@ async function getDiagnosticsForFile(
}
const uri = fileToUri(absolutePath);
const relPath = path.relative(cwd, absolutePath);
const relPath = formatPathRelativeToCwd(absolutePath, cwd);
const allDiagnostics: Diagnostic[] = [];
const serverNames: string[] = [];
@@ -1229,7 +1229,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
}
const uri = fileToUri(resolved);
const relPath = path.relative(this.session.cwd, resolved);
const relPath = formatPathRelativeToCwd(resolved, this.session.cwd);
const allDiagnostics: Diagnostic[] = [];
// Query all applicable servers for this file
@@ -1707,7 +1707,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
if (!result || result.length === 0) {
output = "No symbols found";
} else {
const relPath = path.relative(this.session.cwd, targetFile);
const relPath = formatPathRelativeToCwd(targetFile, this.session.cwd);
if ("selectionRange" in result[0]) {
const lines = (result as DocumentSymbol[]).flatMap(s => formatDocumentSymbol(s));
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
+7 -7
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { type Theme, theme } from "../modes/theme/theme";
import { formatGroupedFiles } from "../tools/grouped-file-output";
import { resolveToCwd } from "../tools/path-utils";
import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
import type {
CodeAction,
Command,
@@ -229,7 +229,7 @@ export function formatDiagnosticsSummary(diagnostics: Diagnostic[]): string {
* Format a location as file:line:col relative to cwd.
*/
export function formatLocation(location: Location, cwd: string): string {
const file = path.relative(cwd, uriToFile(location.uri));
const file = formatPathRelativeToCwd(uriToFile(location.uri), cwd);
const line = location.range.start.line + 1;
const col = location.range.start.character + 1;
return `${file}:${line}:${col}`;
@@ -255,7 +255,7 @@ export function formatWorkspaceEdit(edit: WorkspaceEdit, cwd: string): string[]
// Handle changes map (legacy format)
if (edit.changes) {
for (const [uri, textEdits] of Object.entries(edit.changes)) {
const file = path.relative(cwd, uriToFile(uri));
const file = formatPathRelativeToCwd(uriToFile(uri), cwd);
results.push(`${file}: ${textEdits.length} edit${textEdits.length > 1 ? "s" : ""}`);
}
}
@@ -264,20 +264,20 @@ export function formatWorkspaceEdit(edit: WorkspaceEdit, cwd: string): string[]
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("edits" in change && change.textDocument) {
const file = path.relative(cwd, uriToFile(change.textDocument.uri));
const file = formatPathRelativeToCwd(uriToFile(change.textDocument.uri), cwd);
results.push(`${file}: ${change.edits.length} edit${change.edits.length > 1 ? "s" : ""}`);
} else if ("kind" in change) {
switch (change.kind) {
case "create":
results.push(`CREATE: ${path.relative(cwd, uriToFile(change.uri))}`);
results.push(`CREATE: ${formatPathRelativeToCwd(uriToFile(change.uri), cwd)}`);
break;
case "rename":
results.push(
`RENAME: ${path.relative(cwd, uriToFile(change.oldUri))} ${theme.nav.cursor} ${path.relative(cwd, uriToFile(change.newUri))}`,
`RENAME: ${formatPathRelativeToCwd(uriToFile(change.oldUri), cwd)} ${theme.nav.cursor} ${formatPathRelativeToCwd(uriToFile(change.newUri), cwd)}`,
);
break;
case "delete":
results.push(`DELETE: ${path.relative(cwd, uriToFile(change.uri))}`);
results.push(`DELETE: ${formatPathRelativeToCwd(uriToFile(change.uri), cwd)}`);
break;
}
}
@@ -198,7 +198,8 @@ You **MUST NOT** use Python or Bash when a specialized tool exists.
{{/ifAny}}
### Paths
- For tools that take a `path` (or path-like field), prefer cwd-relative paths for files inside the cwd. Use absolute paths only when targeting files outside the cwd or when expanding `~`.
- For tools that take a `path` or path-like field, you **MUST** use cwd-relative paths for files inside the current working directory.
- You **MUST** use absolute paths only when targeting files outside the current working directory or when expanding `~`.
{{#has tools "lsp"}}
### LSP guidance
@@ -334,7 +335,7 @@ Some values in tool output are intentionally redacted as `#XXXX#` tokens. Treat
{{SECTION_SEPARATOR "Now"}}
The current working directory is '{{cwd}}'.
The current working directory is '{{cwd}}'. Paths inside this directory **MUST** be passed to tools as relative paths.
Today is '{{date}}'. Begin now.
<critical>
@@ -1,5 +1,3 @@
## `apply_patch`
Use the `apply_patch` shell command to edit files.
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
+69 -46
View File
@@ -1,59 +1,82 @@
Applies precise file edits using anchors (line+hash).
Your patch language is a compact, file-oriented edit format.
When emitting a patch, the first non-blank line **MUST** be `---PATH`.
A Lid is the anchor emitted in read/grep etc. (line number + id, e.g. `5th`).
<ops>
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:"$"`.
- `post:[…]` inserts after the anchor, or EOF with `loc:"$"`.
- `replace:{find,with,all?}` is a literal substring substitution on the anchored line (or every line with `loc:"$"`). No regex — `find` matches as a literal string. `all:true` replaces every occurrence on the line; default replaces only the first.
---PATH start editing PATH with cursor at EOF
!rm delete PATH
!mv X move file to X
$ move cursor to BOF
^ move cursor to EOF
@Lid move cursor after Lid
+X insert X at the cursor; `+` alone inserts a blank line
Lid=X replace whole line with X; `Lid=` blanks it out
-Lid delete line (repeat for multi)
</ops>
<replace>
Use for tiny inline edits: names, operators, literals.
- `find` is a literal substring; do **NOT** escape regex metacharacters — `(`, `)`, `.`, `?`, `[`, `]`, `*`, `+` all match themselves.
- Keep `find` as short as possible while still being unique on the line; it does **NOT** have to be unique across the file.
- `all:false` by default; set `all:true` to replace every occurrence on the line instead of only the first.
</replace>
<rules>
- You may have multiple `---PATH` sections to edit multiple files at once.
- Ops starting with `$` / `^` / `@Lid` do not alter lines; you must still issue an op like `+` afterwards.
- Consecutive `+X` ops insert consecutive lines.
- `Lid=X` replaces the whole line. X must be the complete new line, not a fragment.
</rules>
<examples>
```ts title="a.ts"
{{hline 1 "const FALLBACK = \"guest\";"}}
<case file="a.ts">
{{hline 1 "const DEF = \"guest\";"}}
{{hline 2 ""}}
{{hline 3 "export function label(name) {"}}
{{hline 4 "\tconst clean = name || FALLBACK;"}}
{{hline 5 "\treturn clean.trim().toLowerCase();"}}
{{hline 4 "\tconst clean = name || DEF;"}}
{{hline 5 "\treturn clean.trim();"}}
{{hline 6 "}"}}
```
</case>
# Single-line replacement:
`{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";"}},splice:["const FALLBACK = \"anonymous\";"]}]}`
# Small token edit: prefer `replace`:
`{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},replace:{find:"toLowerCase",with:"toUpperCase"}}]}`
# Insert before / after an anchor:
`{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},pre:["\tif (!clean) return FALLBACK;"],post:["\t// normalized label"]}]}`
# Delete a line vs make it blank:
`{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[]}]}`
`{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[""]}]}`
# File edges:
`{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
`{path:"a.ts",edits:[{loc:"$",post:["","export { FALLBACK };"]}]}`
# 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.
# WRONG: bare-anchor `splice` only owns the anchored line. If you list 2 replacement lines, you replace 1 line with 2 — the original line 5 still shifts down.
`{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;"}},splice:["\tconst clean = String(name ?? FALLBACK).trim();","\treturn clean.toLowerCase();"]}]}`
This produces a function with two `return` statements. To replace lines 4 and 5 together, include the original line 6 (`}`) so the splice covers all the lines you intend to replace.
<examples>
# Replace line
---a.ts
{{hrefr 5}}= return clean.trim().toUpperCase();
# Append after
---a.ts
@{{hrefr 4}}
+ const suffix = "";
# Delete a line
---a.ts
-{{hrefr 2}}
# Prepend and append
---a.ts
$
+// Copyright (c) 2026
+
^
+export { DEF };
# File ops
---a.ts
!rm
---b.ts
!mv a.ts
# Wrong: `@Lid=TEXT` is not replacement syntax
---a.ts
@{{hrefr 5}}= return clean.trim().toUpperCase();
# Wrong: do not split `Lid=TEXT` across lines
---a.ts
{{hrefr 5}}=
return clean.trim().toUpperCase();
# Wrong: do not replace by deleting then adding
---a.ts
-{{hrefr 5}}
+{{hrefr 5}}= return clean.trim().toUpperCase();
</examples>
<critical>
- You **MUST** copy full anchors exactly from a read op (e.g. `160sr`); you **MUST NOT** send only the 2-letter suffix.
- You **MUST** make the minimum exact edit; you **MUST NOT** reformat unrelated code.
- A bare anchor owns exactly one line. To replace N lines, anchor the first one and list all N replacement lines in `splice`.
- You **MUST NOT** include unchanged adjacent lines in `splice`/`pre`/`post`; they shift and duplicate.
- Copy Lids **EXACTLY** from prior tool output. Never guess, shorten, or omit the letters.
- Only emit lines that change. Never repeat unchanged context — anchors imply it.
- This is **NOT** unified diff. Never send `@@`, `-OLD` / `+NEW` pairs, or unchanged context.
- Never split `Lid=TEXT` across two physical lines.
</critical>
@@ -43,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) {`.
`{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;"]}]}`
`{path:"a.ts",edits:[{loc:{range:{pos:{{href 15}},end:{{href 16}}}},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 `}`.
`{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 `alpha`'s entire body including the closing `}`. `end` **MUST** be {{href 7}} because `content` includes `}`.
`{path:"a.ts",edits:[{loc:{range:{pos:{{href 6}},end:{{href 7}}}},content:["\tvalidate();","\tlog();","}"]}]}`
**Wrong**: `end: {{href 6}}` — line 7 (`}`) survives AND content emits `}`, producing two closing braces.
# Replace one line
Single-line replace uses `pos == end`.
`{path:"a.ts",edits:[{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}},end:{{href 2}}}},content:["const timeout = 30_000;"]}]}`
# Delete a range
`{path:"a.ts",edits:[{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}},end:{{href 11}}}},content:null}]}`
# Insert before a sibling
When adding a sibling declaration, prefer `prepend` on the next declaration.
`{path:"a.ts",edits:[{loc:{prepend:{{href 9 "function beta() {"}}},content:["function gamma() {","\tvalidate();","}",""]}]}`
`{path:"a.ts",edits:[{loc:{prepend:{{href 9}}},content:["function gamma() {","\tvalidate();","}",""]}]}`
</examples>
<critical>
+4 -6
View File
@@ -1,4 +1,3 @@
import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
@@ -16,6 +15,7 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import type { OutputMeta } from "./output-meta";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
@@ -106,10 +106,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
const normalizedRewrites = Object.fromEntries(ops);
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
const formatScopePath = (targetPath: string): string => {
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
return relative.length === 0 ? "." : relative;
};
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string | undefined;
let scopePath: string | undefined;
let globFilter: string | undefined;
@@ -164,7 +161,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
});
const dedupedParseErrors = dedupeParseErrors(result.parseErrors);
const formatPath = (filePath: string): string => formatResultPath(filePath, isDirectory);
const formatPath = (filePath: string): string =>
formatResultPath(filePath, isDirectory, resolvedSearchPath, this.session.cwd);
const { record: recordFile, list: fileList } = createFileRecorder();
const fileReplacementCounts = new Map<string, number>();
+4 -6
View File
@@ -1,4 +1,3 @@
import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
@@ -16,6 +15,7 @@ import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format";
import type { OutputMeta } from "./output-meta";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
@@ -87,10 +87,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
if (!Number.isFinite(skip) || skip < 0) {
throw new ToolError("skip must be a non-negative number");
}
const formatScopePath = (targetPath: string): string => {
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
return relative.length === 0 ? "." : relative;
};
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string | undefined;
let scopePath: string | undefined;
let globFilter: string | undefined;
@@ -147,7 +144,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
return parseError?.[1] ?? error;
});
const dedupedParseErrors = dedupeParseErrors(normalizedParseErrors);
const formatPath = (filePath: string): string => formatResultPath(filePath, isDirectory);
const formatPath = (filePath: string): string =>
formatResultPath(filePath, isDirectory, resolvedSearchPath, this.session.cwd);
const { record: recordFile, list: fileList } = createFileRecorder();
const fileMatchCounts = new Map<string, number>();
@@ -1,4 +1,5 @@
import * as path from "node:path";
import { formatPathRelativeToCwd } from "./path-utils";
/**
* Creates a deduplicating recorder for relative file paths.
@@ -22,14 +23,13 @@ export function createFileRecorder(): {
}
/**
* Strip a leading slash and, when the search scope is a directory, normalize
* Windows-style separators. For single-file scopes, fall back to the basename
* so tool output does not leak absolute paths.
* Strip native virtual-root prefixes and format file paths relative to cwd when
* they are inside cwd. Paths outside cwd remain absolute.
*/
export function formatResultPath(filePath: string, isDirectory: boolean): string {
export function formatResultPath(filePath: string, isDirectory: boolean, basePath: string, cwd: string): string {
const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
if (isDirectory) {
return cleanPath.replace(/\\/g, "/");
return formatPathRelativeToCwd(path.resolve(basePath, cleanPath), cwd);
}
return path.basename(cleanPath);
return formatPathRelativeToCwd(basePath, cwd);
}
+11 -13
View File
@@ -23,7 +23,13 @@ import {
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import { normalizePathLikeInput, parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils";
import {
formatPathRelativeToCwd,
normalizePathLikeInput,
parseFindPattern,
resolveMultiFindPattern,
resolveToCwd,
} from "./path-utils";
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
@@ -101,10 +107,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
const { pattern, limit, hidden } = params;
return untilAborted(signal, async () => {
const formatScopePath = (targetPath: string): string => {
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
return relative.length === 0 ? "." : relative;
};
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
if (!normalizedPattern) {
throw new ToolError("Pattern must not be empty");
@@ -132,14 +135,9 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(searchPath, matchPath);
let relativePath = path.relative(this.session.cwd, absolutePath).replace(/\\/g, "/");
if (relativePath.length === 0) {
relativePath = ".";
}
if ((fileType === natives.FileType.Dir || hadTrailingSlash) && !relativePath.endsWith("/")) {
relativePath += "/";
}
return relativePath;
return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
});
};
const buildResult = (files: string[]): AgentToolResult<FindToolDetails> => {
+31 -4
View File
@@ -157,6 +157,27 @@ export function resolveToCwd(filePath: string, cwd: string): string {
return path.resolve(cwd, expanded);
}
export function formatPathRelativeToCwd(
filePath: string,
cwd: string,
options: { trailingSlash?: boolean } = {},
): string {
const resolvedCwd = path.resolve(cwd);
const normalized = normalizeLocalScheme(filePath);
if (isInternalUrlPath(normalized)) {
return normalized;
}
const expanded = expandPath(normalized);
const resolvedPath = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded);
const relative = path.relative(resolvedCwd, resolvedPath);
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
let displayPath = normalizePosixPath(isWithinCwd ? relative || "." : resolvedPath);
if (options.trailingSlash && displayPath !== "." && !displayPath.endsWith("/")) {
displayPath += "/";
}
return displayPath;
}
/**
* Strip matching surrounding double quotes from a path string.
* Common when users paste quoted paths from Windows Explorer or shell copy-paste.
@@ -381,8 +402,14 @@ function findCommonBasePath(paths: string[]): string {
return joined || path.parse(path.resolve(paths[0])).root;
}
function toScopeDisplay(items: string[]): string {
return items.map(item => normalizePosixPath(item)).join(", ");
function toScopeDisplay(items: string[], cwd: string): string {
return items
.map(item =>
formatPathRelativeToCwd(item, cwd, {
trailingSlash: item.endsWith("/") || item.endsWith("\\"),
}),
)
.join(", ");
}
function looksLikeDelimitedPathToken(token: string): boolean {
@@ -533,7 +560,7 @@ export async function resolveMultiSearchPath(
return {
basePath: commonBasePath,
glob: buildBraceUnion(combinedPatterns),
scopePath: toScopeDisplay(pathItems),
scopePath: toScopeDisplay(pathItems, cwd),
exactFilePaths: allExactFiles ? parsedItems.map(item => item.absoluteBasePath) : undefined,
};
}
@@ -571,7 +598,7 @@ export async function resolveMultiFindPattern(
return {
basePath: commonBasePath,
globPattern: buildBraceUnion(combinedPatterns) ?? "**/*",
scopePath: toScopeDisplay(patternItems),
scopePath: toScopeDisplay(patternItems, cwd),
};
}
+5 -2
View File
@@ -40,7 +40,7 @@ import {
} from "./fetch";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
import { expandPath, resolveReadPath } from "./path-utils";
import { expandPath, formatPathRelativeToCwd, resolveReadPath } from "./path-utils";
import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils";
import {
executeReadQuery,
@@ -1046,7 +1046,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
? "- Alpha: no"
: "- Alpha: unknown",
"",
`If you want to analyze the image, call inspect_image with path="${readPath}" and a question describing what to inspect and the desired output format.`,
`If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd(
absolutePath,
this.session.cwd,
)}" and a question describing what to inspect and the desired output format.`,
];
content = [{ type: "text", text: metadataLines.join("\n") }];
details = {};
+5 -13
View File
@@ -13,11 +13,12 @@ import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../sess
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import { createFileRecorder } from "./file-recorder";
import { createFileRecorder, formatResultPath } from "./file-recorder";
import { formatGroupedFiles } from "./grouped-file-output";
import { formatMatchLine } from "./match-line-format";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import {
formatPathRelativeToCwd,
hasGlobPathChars,
normalizePathLikeInput,
parseSearchPath,
@@ -112,10 +113,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const effectiveMultiline = patternHasNewline;
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
const formatScopePath = (targetPath: string): string => {
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
return relative.length === 0 ? "." : relative;
};
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
let searchPath: string;
let scopePath: string;
let exactFilePaths: string[] | undefined;
@@ -225,14 +223,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
throw err;
}
const formatPath = (filePath: string): string => {
// returns paths starting with / (the virtual root)
const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
if (isDirectory) {
return cleanPath.replace(/\\/g, "/");
}
return path.basename(cleanPath);
};
const formatPath = (filePath: string): string =>
formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
// Build output
const roundRobinSelect = (matches: GrepMatch[], limit: number): GrepMatch[] => {
+8 -4
View File
@@ -19,6 +19,7 @@ import { parseArchivePathCandidates } from "./archive-reader";
import { assertEditableFile } from "./auto-generated-guard";
import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
import { type OutputMeta, outputMeta } from "./output-meta";
import { formatPathRelativeToCwd } from "./path-utils";
import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
import {
formatDiagnostics,
@@ -212,7 +213,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
}
async #writeArchiveEntry(
displayPath: string,
content: string,
resolvedArchivePath: ResolvedArchiveWritePath,
): Promise<AgentToolResult<WriteToolDetails>> {
@@ -278,8 +278,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
}
invalidateFsScanAfterWrite(resolvedArchivePath.absolutePath);
const outputPath = `${formatPathRelativeToCwd(resolvedArchivePath.absolutePath, this.session.cwd)}:${
resolvedArchivePath.archiveSubPath
}`;
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${displayPath}` }],
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
details: {},
};
}
@@ -426,7 +429,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
op: resolvedArchivePath.exists ? "update" : "create",
});
const archiveResult = await this.#writeArchiveEntry(path, cleanContent, resolvedArchivePath);
const archiveResult = await this.#writeArchiveEntry(cleanContent, resolvedArchivePath);
if (stripped) {
const firstText = archiveResult.content.find(
(block): block is { type: "text"; text: string } =>
@@ -468,7 +471,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
invalidateFsScanAfterWrite(absolutePath);
let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`;
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
if (stripped) {
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
}
+607 -318
View File
@@ -1,387 +1,676 @@
import { describe, expect, it } from "bun:test";
import { beforeAll, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
type AtomEdit,
type AtomToolEdit,
applyAtomEdits,
atomEditSchema,
atomEditParamsSchema,
computeLineHash,
type ExecuteAtomSingleOptions,
executeAtomSingle,
HashlineMismatchError,
resolveAtomEntryPaths,
resolveAtomToolEdit,
parseAtom,
splitAtomInput,
splitAtomInputs,
} from "@oh-my-pi/pi-coding-agent/edit";
import type { Anchor } from "@oh-my-pi/pi-coding-agent/edit/modes/hashline";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { Value } from "@sinclair/typebox/value";
function tag(line: number, content: string): Anchor {
return { line, hash: computeLineHash(line, content) };
beforeAll(async () => {
_resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
function tag(line: number, content: string): string {
return `${line}${computeLineHash(line, content)}`;
}
describe("applyAtomEdits — splice", () => {
it("replaces a single line", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "splice", pos: tag(2, "bbb"), lines: ["BBB"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
expect(result.firstChangedLine).toBe(2);
// Convenience: parse a diff against a content snapshot and return the resulting text.
function applyDiff(content: string, diff: string): string {
const edits = parseAtom(diff);
return applyAtomEdits(content, edits).lines;
}
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "atom-edit-"));
try {
await fn(tempDir);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
function atomExecuteOptions(tempDir: string, input: string): ExecuteAtomSingleOptions {
return {
session: { cwd: tempDir } as ToolSession,
input,
writethrough: async () => {
throw new Error("unexpected write");
},
beginDeferredDiagnosticsForPath: () => {
throw new Error("unexpected diagnostics");
},
};
}
// ───────────────────────────────────────────────────────────────────────────
// Form coverage
// ───────────────────────────────────────────────────────────────────────────
describe("atom parser — basic forms", () => {
const content = "aaa\nbbb\nccc";
it("canonical set replaces a single line", () => {
const diff = `${tag(2, "bbb")}=BBB`;
expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc");
});
it("expands one line into many", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "splice", pos: tag(2, "bbb"), lines: ["X", "Y", "Z"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nX\nY\nZ\nccc");
it("prefix delete `-Lid` removes a single line", () => {
const diff = `-${tag(2, "bbb")}`;
expect(applyDiff(content, diff)).toBe("aaa\nccc");
});
it("rejects on stale hash", () => {
it("`@Lid` moves the cursor after the anchored line", () => {
const diff = `@${tag(2, "bbb")}\n+INSERTED`;
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nINSERTED\nccc");
});
it("$ + + lines prepend to the file", () => {
const diff = `$\n+ZZZ\n+YYY`;
expect(applyDiff(content, diff)).toBe("ZZZ\nYYY\naaa\nbbb\nccc");
});
it("^ + + lines append to the file", () => {
const diff = `^\n+DDD\n+EEE`;
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\nDDD\nEEE");
});
it("+ lines with no cursor move append to the file", () => {
const diff = `+DDD\n+EEE`;
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\nDDD\nEEE");
});
});
// ───────────────────────────────────────────────────────────────────────────
// Cursor binding rule
// ───────────────────────────────────────────────────────────────────────────
describe("atom parser — cursor binding", () => {
const content = "aaa\nbbb\nccc";
it("set moves the cursor after the set line", () => {
const diff = `${tag(1, "aaa")}=AAA\n+INSERTED\n@${tag(2, "bbb")}`;
expect(applyDiff(content, diff)).toBe("AAA\nINSERTED\nbbb\nccc");
});
it("set before another set keeps inserts at the previous cursor", () => {
const diff = `${tag(1, "aaa")}=AAA\n+INSERTED\n${tag(2, "bbb")}=BBB`;
expect(applyDiff(content, diff)).toBe("AAA\nINSERTED\nBBB\nccc");
});
it("bare Lid moves the cursor before a following set", () => {
const diff = `@${tag(1, "aaa")}\n+INSERTED\n${tag(2, "bbb")}=BBB`;
expect(applyDiff(content, diff)).toBe("aaa\nINSERTED\nBBB\nccc");
});
it("preserves contiguous + lines at the same cursor", () => {
const diff = `${tag(1, "aaa")}=AAA\n+I1\n+I2\n+I3\n@${tag(2, "bbb")}`;
expect(applyDiff(content, diff)).toBe("AAA\nI1\nI2\nI3\nbbb\nccc");
});
it("+ with only a previous anchor inserts after that anchor", () => {
const diff = `@${tag(2, "bbb")}\n+INSERTED`;
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nINSERTED\nccc");
});
it("+ before any cursor move uses the initial EOF cursor", () => {
const diff = `+INSERTED\n@${tag(2, "bbb")}`;
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\nINSERTED");
});
});
// ───────────────────────────────────────────────────────────────────────────
// Edge cases
// ───────────────────────────────────────────────────────────────────────────
describe("atom parser — edge cases", () => {
const content = "aaa\nbbb\nccc";
it("empty set blanks the line", () => {
const diff = `${tag(2, "bbb")}=`;
expect(applyDiff(content, diff)).toBe("aaa\n\nccc");
});
it("set value starting with `!` keeps the leading `!`", () => {
const diff = `${tag(2, "bbb")}=!hello`;
expect(applyDiff(content, diff)).toBe("aaa\n!hello\nccc");
});
it("set value starting with `|` keeps the leading `|`", () => {
const diff = `${tag(2, "bbb")}=|hello`;
expect(applyDiff(content, diff)).toBe("aaa\n|hello\nccc");
});
it("set preserves leading/trailing whitespace exactly", () => {
const diff = `${tag(2, "bbb")}= spaced `;
expect(applyDiff(content, diff)).toBe("aaa\n spaced \nccc");
});
it("empty `+` line inserts a blank line", () => {
const diff = `${tag(1, "aaa")}=AAA\n+\n@${tag(2, "bbb")}`;
expect(applyDiff(content, diff)).toBe("AAA\n\nbbb\nccc");
});
it("`+` block with no cursor emits EOF cursor inserts", () => {
expect(parseAtom(`+lonely`)).toMatchObject([{ kind: "insert", cursor: { kind: "eof" }, text: "lonely" }]);
});
it("out-of-order anchors are accepted (sorted internally)", () => {
const diff = `${tag(3, "ccc")}=CCC\n${tag(1, "aaa")}=AAA`;
expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC");
});
it("delete + post: insertions take the deleted line's slot", () => {
const diff = `-${tag(2, "bbb")}\n+INSERTED`;
expect(applyDiff(content, diff)).toBe("aaa\nINSERTED\nccc");
});
it("CRLF input is normalized line-by-line", () => {
const diff = `${tag(2, "bbb")}=BBB\r\n${tag(1, "aaa")}=AAA\r`;
expect(applyDiff(content, diff)).toBe("AAA\nBBB\nccc");
});
it("trailing newline at file end is preserved", () => {
const c = "aaa\nbbb\nccc\n";
const diff = `${tag(2, "bbb")}=BBB`;
expect(applyDiff(c, diff)).toBe("aaa\nBBB\nccc\n");
});
it("empty diff is a no-op", () => {
expect(parseAtom("")).toEqual([]);
expect(parseAtom("\n\n\n")).toEqual([]);
});
it("`Lid=TEXT` is the canonical set form", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "splice", pos: { line: 2, hash: "ZZ" }, lines: ["BBB"] }];
expect(applyDiff(content, `${tag(2, "bbb")}=BBB`)).toBe("aaa\nBBB\nccc");
});
it("legacy set and locator forms remain accepted", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
expect(applyDiff(content, `${t}|BBB`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `@@ ${t}\n+INSERTED`)).toBe("aaa\nbbb\nINSERTED\nccc");
});
it("recovers common replacement slips with @ and @@ prefixes", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
expect(applyDiff(content, `@${t}=BBB`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `@${t}|BBB`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `@@ ${t}=BBB`)).toBe("aaa\nBBB\nccc");
expect(applyDiff(content, `@@ ${t}|BBB`)).toBe("aaa\nBBB\nccc");
});
it("recovers common delete slips with whitespace and @@ prefixes", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
expect(applyDiff(content, `- ${t}`)).toBe("aaa\nccc");
expect(applyDiff(content, `@@ -${t}`)).toBe("aaa\nccc");
expect(applyDiff(content, `@@ - ${t}`)).toBe("aaa\nccc");
});
it("ignores whitespace before replacement separator and preserves whitespace after it", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
expect(applyDiff(content, `${t} = BBB `)).toBe("aaa\n BBB \nccc");
expect(applyDiff(content, `@${t} | BBB `)).toBe("aaa\n BBB \nccc");
expect(applyDiff(content, `@@ ${t} | BBB `)).toBe("aaa\n BBB \nccc");
});
it("rejects partial and missing Lids with repairable diagnostics", () => {
expect(() => parseAtom("@@ 98")).toThrow(/`@@ 98` is missing the two-letter Lid suffix/);
expect(() => parseAtom("yh=TEXT")).toThrow(/`yh` is not a full Lid/);
expect(() => parseAtom("123=TEXT")).toThrow(/`123` is missing the two-letter Lid suffix/);
expect(() => parseAtom("123|TEXT")).toThrow(/`123` is missing the two-letter Lid suffix/);
});
it("canonical equals replacement does not apply legacy OLD|NEW repair", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
expect(applyDiff(content, `${t}=bbb|BBB`)).toBe("aaa\nbbb|BBB\nccc");
});
it("silently ignores identical replacements when another operation changes the file", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
const result = applyAtomEdits(content, parseAtom(`${t}=bbb\n+DDD`));
expect(result.lines).toBe("aaa\nbbb\nDDD\nccc");
expect(result.noopEdits).toBeUndefined();
expect(result.warnings).toBeUndefined();
});
it("locator-only patches report the cursor diagnostic", async () => {
await expect(
executeAtomSingle({
session: { cwd: process.cwd() } as ToolSession,
input: "---a.ts\n@123ab",
writethrough: async () => {
throw new Error("unexpected write");
},
beginDeferredDiagnosticsForPath: () => {
throw new Error("unexpected diagnostics");
},
} as ExecuteAtomSingleOptions),
).rejects.toThrow(
"Cursor moved but no mutation found. Add +TEXT to insert, -Lid to delete, or Lid=TEXT to replace.",
);
});
it("@Lid move syntax is accepted as canonical", () => {
expect(parseAtom(`@${tag(2, "bbb")}`)).toEqual([]);
});
it("anchor with non-pipe trailing characters is leniently treated as an insert", () => {
const content = "aaa\nbbb\nccc";
// `1aa/foo/bar/` doesn't match `Lid=...`, so it falls through to a
// best-effort insert at EOF rather than throwing.
expect(applyDiff(content, `${tag(1, "aaa")}/foo/bar/`)).toBe(`aaa\nbbb\nccc\n${tag(1, "aaa")}/foo/bar/`);
});
it("duplicate sets on the same anchor: last set wins", () => {
const t = tag(2, "bbb");
const diff = `${t}=OLD\n${t}=NEW`;
expect(applyDiff(content, diff)).toBe("aaa\nNEW\nccc");
});
it("same-line OLD|NEW repairs to the new line when OLD is current content", () => {
const t = tag(2, "bbb");
const diff = `${t}|bbb|BBB`;
expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc");
});
it("same-line OLD|NEW repair works through `@` prefix slip", () => {
const t = tag(2, "bbb");
const diff = `@${t}|bbb|BBB`;
expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc");
});
it("same-line OLD|NEW repair works through `@@ ` prefix slip", () => {
const t = tag(2, "bbb");
const diff = `@@ ${t}|bbb|BBB`;
expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc");
});
it("`-Lid` followed by `+Lid|TEXT` fuses into a single replacement", () => {
const t = tag(2, "bbb");
const diff = `-${t}\n+${t}|REPLACED`;
expect(applyDiff(content, diff)).toBe("aaa\nREPLACED\nccc");
});
it("`-Lid` followed by `+Lid=TEXT` fuses into a single replacement", () => {
const t = tag(2, "bbb");
const diff = `-${t}\n+${t}=REPLACED`;
expect(applyDiff(content, diff)).toBe("aaa\nREPLACED\nccc");
});
it("standalone `+Lid|TEXT` is rejected with a diff-ish replacement diagnostic", () => {
const t = tag(2, "bbb");
expect(() => parseAtom(`+${t}|REPLACED`)).toThrow(
new RegExp(`\`\\+${t}\\|\\.\\.\\.\` looks like a unified-diff replacement marker. Use \`${t}=TEXT\``),
);
});
it("standalone `+Lid=TEXT` is rejected with a diff-ish replacement diagnostic", () => {
const t = tag(2, "bbb");
expect(() => parseAtom(`+${t}=REPLACED`)).toThrow(/looks like a unified-diff replacement marker/);
});
it("`-Lid` followed by `+OtherLid|TEXT` (mismatched) is rejected", () => {
const t1 = tag(1, "aaa");
const t2 = tag(2, "bbb");
expect(() => parseAtom(`-${t1}\n+${t2}|REPLACED`)).toThrow(
/references a Lid that was not deleted in the preceding run/,
);
});
it("plain `+TEXT` insertion is unaffected by diff-ish detection", () => {
// `+5xa hello` — no `=` or `|` separator after the Lid-shaped prefix —
// remains a literal insert, including the `5xa hello` text.
const diff = `+5xa hello`;
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\n5xa hello");
});
it("multi-line hunk: deletes followed by inserts becomes a block replacement at the FIRST deleted slot", () => {
const c = "aaa\nbbb\nccc\nddd\neee";
const t2 = tag(2, "bbb");
const t3 = tag(3, "ccc");
const t4 = tag(4, "ddd");
const diff = `-${t2}\n-${t3}\n-${t4}\n+X1\n+X2\n+X3`;
expect(applyDiff(c, diff)).toBe("aaa\nX1\nX2\nX3\neee");
});
it("multi-line hunk: more inserts than deletes is allowed", () => {
const c = "aaa\nbbb\nccc\nddd";
const t2 = tag(2, "bbb");
const diff = `-${t2}\n+X1\n+X2\n+X3`;
expect(applyDiff(c, diff)).toBe("aaa\nX1\nX2\nX3\nccc\nddd");
});
it("multi-line hunk: fewer inserts than deletes is allowed", () => {
const c = "aaa\nbbb\nccc\nddd\neee";
const t2 = tag(2, "bbb");
const t3 = tag(3, "ccc");
const t4 = tag(4, "ddd");
const diff = `-${t2}\n-${t3}\n-${t4}\n+X`;
expect(applyDiff(c, diff)).toBe("aaa\nX\neee");
});
it("multi-line hunk: `+Lid|TEXT` add lines must reference a deleted Lid", () => {
const c = "aaa\nbbb\nccc\nddd";
const t2 = tag(2, "bbb");
const t3 = tag(3, "ccc");
const t4 = tag(4, "ddd");
// `+Lid|TEXT` for t2 and t3 (in delete run) is OK; t4 (also deleted) is OK.
const diff = `-${t2}\n-${t3}\n-${t4}\n+${t2}|X1\n+${t3}|X2\n+${t4}|X3`;
expect(applyDiff(c, diff)).toBe("aaa\nX1\nX2\nX3");
});
it("multi-line hunk: `+Lid|TEXT` referencing a Lid not in the delete run is rejected", () => {
const t1 = tag(1, "aaa");
const t2 = tag(2, "bbb");
const t3 = tag(3, "ccc");
expect(() => parseAtom(`-${t1}\n-${t2}\n+${t3}|X`)).toThrow(
/references a Lid that was not deleted in the preceding run/,
);
});
it("`-Lid|OLD` deletes when OLD matches the current line", () => {
const t = tag(2, "bbb");
const diff = `-${t}|bbb`;
expect(applyDiff(content, diff)).toBe("aaa\nccc");
});
it("`-Lid=OLD` deletes when OLD matches the current line", () => {
const t = tag(2, "bbb");
const diff = `-${t}=bbb`;
expect(applyDiff(content, diff)).toBe("aaa\nccc");
});
it("`-Lid|OLD` is rejected when OLD does not match the current line", () => {
const t = tag(2, "bbb");
const diff = `-${t}|XXX`;
expect(() => applyAtomEdits(content, parseAtom(diff))).toThrow(
/asserts the deleted line is "XXX", but the file has "bbb"/,
);
});
it("hunk with `-Lid|OLD` validates each OLD against current line", () => {
const c = "aaa\nbbb\nccc\nddd";
const t2 = tag(2, "bbb");
const t3 = tag(3, "ccc");
// Both OLDs match → accepted.
const ok = `-${t2}|bbb\n-${t3}|ccc\n+X1\n+X2`;
expect(applyDiff(c, ok)).toBe("aaa\nX1\nX2\nddd");
// Second OLD wrong → rejected.
const bad = `-${t2}|bbb\n-${t3}|wrong\n+X1\n+X2`;
expect(() => applyAtomEdits(c, parseAtom(bad))).toThrow(/asserts the deleted line is "wrong"/);
});
it("set with current content reports identical replacement as a no-op", () => {
const t = tag(2, "bbb");
const result = applyAtomEdits(content, parseAtom(`${t}=bbb`));
expect(result.lines).toBe(content);
expect(result.noopEdits).toEqual([
{
editIndex: 0,
loc: t,
reason:
"replacement is identical to the current line content; use `Lid=NEW_TEXT` and do not copy an unchanged read line",
current: "bbb",
},
]);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Combined ops on a single anchor
// ───────────────────────────────────────────────────────────────────────────
describe("atom — combining set + post on one anchor", () => {
it("set then `+` lines insert after the set line", () => {
const content = "aaa\nbbb\nccc";
const t = tag(2, "bbb");
const diff = `${t}=NEW\n+POST`;
expect(applyDiff(content, diff)).toBe("aaa\nNEW\nPOST\nccc");
});
it("a leading `+` starts at EOF, and a trailing `+` uses the current anchor cursor", () => {
const content = "aaa\nbbb\nccc\nddd";
const t2 = tag(2, "bbb");
const t3 = tag(3, "ccc");
const diff = `+BEFORE\n${t2}=BBB\n+AFTER\n@${t3}`;
expect(applyDiff(content, diff)).toBe("aaa\nBBB\nAFTER\nccc\nddd\nBEFORE");
});
});
// ───────────────────────────────────────────────────────────────────────────
// Hash mismatch flow
// ───────────────────────────────────────────────────────────────────────────
describe("atom — hash mismatch", () => {
it("propagates HashlineMismatchError on stale hash", () => {
const content = "aaa\nbbb\nccc";
const diff = `2zz=BBB`;
const edits = parseAtom(diff);
expect(() => applyAtomEdits(content, edits)).toThrow(HashlineMismatchError);
});
});
describe("applyAtomEdits — del", () => {
it("removes a line", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "del", pos: tag(2, "bbb") }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nccc");
});
it("multiple deletes apply bottom-up so anchors stay valid", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: AtomEdit[] = [
{ op: "del", pos: tag(2, "bbb") },
{ op: "del", pos: tag(3, "ccc") },
];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nddd");
it("does not use replacement text as a rebase content hint", () => {
const content = "aaa\nchanged\nNEW";
const stale = tag(2, "bbb");
expect(() => applyAtomEdits(content, parseAtom(`${stale}=NEW`))).toThrow(HashlineMismatchError);
});
});
describe("applyAtomEdits — pre/post", () => {
it("pre inserts above the anchor", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "pre", pos: tag(2, "bbb"), lines: ["NEW"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nNEW\nbbb\nccc");
// ───────────────────────────────────────────────────────────────────────────
// Internal AtomEdit shapes
// ───────────────────────────────────────────────────────────────────────────
describe("parseAtom — emits internal AtomEdit shapes", () => {
it("emits delete op", () => {
const t = tag(2, "bbb");
const edits = parseAtom(`-${t}`);
expect(edits).toHaveLength(1);
expect(edits[0]).toMatchObject({ kind: "delete", anchor: { line: 2 } });
});
it("post inserts below the anchor", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "post", pos: tag(2, "bbb"), lines: ["NEW"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\nNEW\nccc");
it("emits EOF cursor insert for ^ + +", () => {
const edits = parseAtom(`^\n+x`);
expect(edits).toMatchObject([{ kind: "insert", cursor: { kind: "eof" }, text: "x" }]);
});
it("pre + post on same anchor coexist with splice", () => {
it("emits EOF cursor insert for bare + +", () => {
const edits = parseAtom(`+x`);
expect(edits).toMatchObject([{ kind: "insert", cursor: { kind: "eof" }, text: "x" }]);
});
it("emits BOF cursor insert for $ + +", () => {
const edits = parseAtom(`$\n+x`);
expect(edits).toMatchObject([{ kind: "insert", cursor: { kind: "bof" }, text: "x" }]);
});
it("delete + set on same anchor is rejected by validateNoConflictingAnchorOps", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [
{ op: "pre", pos: tag(2, "bbb"), lines: ["B"] },
{ op: "splice", pos: tag(2, "bbb"), lines: ["BBB"] },
{ op: "post", pos: tag(2, "bbb"), lines: ["A"] },
];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nB\nBBB\nA\nccc");
const t = tag(2, "bbb");
const diff = `${t}=NEW\n-${t}`;
const edits = parseAtom(diff);
expect(() => applyAtomEdits(content, edits)).toThrow(/Conflicting ops/);
});
});
describe("atom edit schema", () => {
it("rejects sub edits", () => {
expect(Value.Check(atomEditSchema, { loc: "1ab", sub: ["5000", "30_000"] })).toBe(false);
// ───────────────────────────────────────────────────────────────────────────
// Wire format header
// ───────────────────────────────────────────────────────────────────────────
describe("splitAtomInput — wire-format header", () => {
it("extracts path and diff body from `--- path` header", () => {
const input = `---src/foo.ts\n${tag(2, "bbb")}=BBB`;
const { path, diff } = splitAtomInput(input);
expect(path).toBe("src/foo.ts");
expect(diff).toBe(`${tag(2, "bbb")}=BBB`);
});
it("rejects bracketed loc forms (no longer supported)", () => {
// `(A)` and `[A]` were dropped — they are valid at the schema level
// (loc is a string) but the runtime parser rejects anything that isn't
// a bare anchor or `$`.
expect(() => resolveAtomToolEdit({ loc: "(2ab)", splice: ["X"] })).toThrow();
expect(() => resolveAtomToolEdit({ loc: "[2ab]", splice: ["X"] })).toThrow();
it("extracts path and diff body from legacy `--- path` header", () => {
const input = `--- src/foo.ts\n${tag(2, "bbb")}=BBB`;
const { path, diff } = splitAtomInput(input);
expect(path).toBe("src/foo.ts");
expect(diff).toBe(`${tag(2, "bbb")}=BBB`);
});
it("rejects sed-shaped replace specs", () => {
expect(Value.Check(atomEditSchema, { loc: "1ab", sed: { pat: "x", rep: "y" } })).toBe(false);
});
});
describe("resolveAtomToolEdit — loc syntax", () => {
it('loc:"$" appends at EOF', () => {
const content = "aaa\nbbb";
const resolved = resolveAtomToolEdit({ loc: "$", post: ["ccc"] });
expect(resolved).toHaveLength(1);
expect(resolved[0]?.op).toBe("append_file");
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nbbb\nccc");
it("strips leading blank lines before the first header", () => {
const input = `\n\n---a.ts\n+export const A = 1;`;
expect(splitAtomInput(input)).toEqual({ path: "a.ts", diff: "+export const A = 1;" });
});
it('loc:"$" + pre prepends to the file', () => {
const content = "aaa\nbbb";
const resolved = resolveAtomToolEdit({ loc: "$", pre: ["ZZZ"] });
expect(resolved).toHaveLength(1);
expect(resolved[0]?.op).toBe("prepend_file");
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("ZZZ\naaa\nbbb");
it("unquotes matching path quotes", () => {
expect(splitAtomInput(`---"foo bar.ts"\n+x`).path).toBe("foo bar.ts");
expect(splitAtomInput(`---'foo bar.ts'\n+x`).path).toBe("foo bar.ts");
});
it('loc:"$" + replace substitutes across all lines', () => {
const content = "aaa\nfoo\nbar foo";
const resolved = resolveAtomToolEdit({ loc: "$", replace: { find: "foo", with: "FOO" } });
expect(resolved).toHaveLength(1);
expect(resolved[0]?.op).toBe("replace_file");
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nFOO\nbar FOO");
it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => {
const cwd = path.join(process.cwd(), "packages", "coding-agent");
const absolute = path.join(cwd, "src", "foo.ts");
expect(splitAtomInput(`---${absolute}\n+x`, { cwd }).path).toBe("src/foo.ts");
});
it('loc:"$" + replace with all:true substitutes every occurrence', () => {
const content = "aaa\nfoo foo\nbar foo";
const resolved = resolveAtomToolEdit({ loc: "$", replace: { find: "foo", with: "FOO", all: true } });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nFOO FOO\nbar FOO");
it("preserves absolute paths outside cwd", () => {
const cwd = path.join(process.cwd(), "packages", "coding-agent");
const outside = path.resolve(process.cwd(), "..", "outside.ts");
expect(splitAtomInput(`---${outside}\n+x`, { cwd }).path).toBe(outside);
});
it('loc:"$" + replace preserves trailing newline', () => {
const content = "aaa\nbbb\n";
const resolved = resolveAtomToolEdit({ loc: "$", replace: { find: "bbb", with: "BBB" } });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nBBB\n");
});
it('loc:"$" + replace throws when no line matches', () => {
const content = "aaa\nbbb";
const resolved = resolveAtomToolEdit({ loc: "$", replace: { find: "zzz", with: "yyy" } });
expect(() => applyAtomEdits(content, resolved)).toThrow(/did not match any line/);
});
it('loc:"$" + pre + post + replace combined', () => {
const content = "aaa\nbbb";
const resolved = resolveAtomToolEdit({
loc: "$",
pre: ["PRE"],
replace: { find: "bbb", with: "BBB" },
post: ["POST"],
it("uses explicit fallback path only when input has operations and no header", () => {
expect(splitAtomInput(`${tag(1, "aaa")}=AAA`, { path: "a.ts" })).toEqual({
path: "a.ts",
diff: `${tag(1, "aaa")}=AAA`,
});
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("PRE\naaa\nBBB\nPOST");
expect(() => splitAtomInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
expect(() => splitAtomInput("---\n+x", { path: "a.ts" })).toThrow(/empty/);
});
it('loc:"$" rejects splice', () => {
expect(() => resolveAtomToolEdit({ loc: "$", splice: ["X"] })).toThrow(/supports pre, post, and replace/);
it("throws if header is missing", () => {
expect(() => splitAtomInput("124aa=NEW")).toThrow(/must begin with/);
});
it('loc:"^" is no longer supported', () => {
expect(() => resolveAtomToolEdit({ loc: "^", pre: ["ZZZ"] })).toThrow();
it("throws if header path is empty", () => {
expect(() => splitAtomInput("--- \n124aa=NEW")).toThrow(/empty/);
});
it("expands pre + splice + post from one entry", () => {
const content = "aaa\nbbb\nccc";
const loc = `2${computeLineHash(2, "bbb")}`;
const resolved = resolveAtomToolEdit({ loc, pre: ["B"], splice: ["BBB"], post: ["A"] });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nB\nBBB\nA\nccc");
it("tolerates CRLF after header", () => {
const input = `--- a.ts\r\n${tag(1, "alpha")}=ALPHA`;
const { path, diff } = splitAtomInput(input);
expect(path).toBe("a.ts");
expect(diff).toBe(`${tag(1, "alpha")}=ALPHA`);
});
it("splice: [] deletes the anchor line", () => {
const content = "aaa\nbbb\nccc";
const loc = `2${computeLineHash(2, "bbb")}`;
const resolved = resolveAtomToolEdit({ loc, splice: [] });
expect(resolved[0]?.op).toBe("del");
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nccc");
it("strips BOM from input", () => {
const input = `\uFEFF---a.ts\n${tag(1, "alpha")}=ALPHA`;
const { path } = splitAtomInput(input);
expect(path).toBe("a.ts");
});
it('splice: [""] preserves a blank line', () => {
const content = "aaa\nbbb\nccc";
const loc = `2${computeLineHash(2, "bbb")}`;
const resolved = resolveAtomToolEdit({ loc, splice: [""] });
expect(resolved[0]?.op).toBe("splice");
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\n\nccc");
it("splits multiple --- path sections", () => {
const input = `---a.ts\n+export const A = 1;\n--- b.ts\n+export const B = 2;`;
expect(splitAtomInputs(input)).toEqual([
{ path: "a.ts", diff: "+export const A = 1;" },
{ path: "b.ts", diff: "+export const B = 2;" },
]);
});
it("ignores null optional verb fields", () => {
const content = "aaa\nbbb\nccc";
const loc = `2${computeLineHash(2, "bbb")}`;
const toolEdit = { loc, pre: null, splice: "BBB", post: null } as unknown as AtomToolEdit;
const resolved = resolveAtomToolEdit(toolEdit);
expect(resolved).toEqual([{ op: "splice", pos: tag(2, "bbb"), lines: ["BBB"] }]);
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nBBB\nccc");
});
it("supports path override inside loc", () => {
const resolved = resolveAtomEntryPaths([{ loc: "a.ts:1ab", splice: ["X"] }], undefined);
expect(resolved[0]?.path).toBe("a.ts");
expect(resolved[0]?.loc).toBe("1ab");
});
it("accepts a content-suffix anchor and uses it for hint-based rebase", () => {
// Models sometimes paste line content after the anchor, e.g.
// `loc: "82zu| for (let i = 0; i--; ...) {"`. The bare `--` in the content
// must not break parsing.
const content = "alpha\nbravo\ncharlie";
const loc = `2${computeLineHash(2, "bravo")}| for (let i = 0; i--; ...) {`;
const resolved = resolveAtomToolEdit({ loc, splice: ["BRAVO"] });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("alpha\nBRAVO\ncharlie");
});
it("resolveAtomEntryPaths peels off path even when the loc content suffix contains colons", () => {
// Mimics a real failure: model wrote `image-input.ts:263ti| " const data: x"`.
// `lastIndexOf(":")` would have picked the colon inside `data:` and broken the split.
const [resolved] = resolveAtomEntryPaths(
[{ loc: 'image-input.ts:263ti| " const data: x"', replace: { find: "x", with: "y" } }],
undefined,
);
expect(resolved?.path).toBe("image-input.ts");
expect(resolved?.loc).toBe('263ti| " const data: x"');
it("rejects the old colon header after the syntax cutover", () => {
expect(() => splitAtomInput(":a.ts\n+export const A = 1;")).toThrow(/must begin with/);
});
});
describe("applyAtomEdits — out of range", () => {
it("rejects line beyond file length", () => {
const content = "aaa\nbbb";
const edits: AtomEdit[] = [{ op: "splice", pos: { line: 99, hash: "ZZ" }, lines: ["x"] }];
expect(() => applyAtomEdits(content, edits)).toThrow(/does not exist/);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Whole-file operations
// ───────────────────────────────────────────────────────────────────────────
describe("parseAnchor (atom tolerant) + applyAtomEdits", () => {
it("surfaces correct anchor + content when the model invents an out-of-alphabet hash", () => {
const content = "alpha\nbravo\ncharlie";
// `XG` is not in the alphabet; should be rejected with the actual anchor exposed.
const toolEdit = { path: "a.ts", loc: "2XG", splice: ["BRAVO"] };
const resolved = resolveAtomToolEdit(toolEdit);
expect(() => applyAtomEdits(content, resolved)).toThrow(HashlineMismatchError);
try {
applyAtomEdits(content, resolved);
} catch (err) {
const msg = (err as Error).message;
expect(msg).toMatch(/^\*\d+[a-z]{2}\|/m);
expect(msg).toContain("bravo");
expect(msg).toContain(`2${computeLineHash(2, "bravo")}`);
}
});
describe("atom executor — whole-file operations", () => {
it("deletes the section file with !rm", async () => {
await withTempDir(async tempDir => {
const filePath = path.join(tempDir, "file.ts");
await Bun.write(filePath, "export const x = 1;\n");
it("surfaces correct anchor + content when the model omits the hash entirely", () => {
const content = "alpha\nbravo\ncharlie";
const toolEdit = { path: "a.ts", loc: "2", splice: ["BRAVO"] };
const resolved = resolveAtomToolEdit(toolEdit);
expect(() => applyAtomEdits(content, resolved)).toThrow(HashlineMismatchError);
});
const result = await executeAtomSingle(atomExecuteOptions(tempDir, "---file.ts\n!rm\n"));
it("surfaces correct anchor when the model uses pipe-separator (LINE|content) form", () => {
const content = "alpha\nbravo\ncharlie";
const toolEdit = { path: "a.ts", loc: "2|bravo", splice: ["BRAVO"] };
const resolved = resolveAtomToolEdit(toolEdit);
expect(() => applyAtomEdits(content, resolved)).toThrow(HashlineMismatchError);
});
it("throws a usage-style error when no line number can be extracted", () => {
const toolEdit = { path: "a.ts", loc: " if (!x) return;", splice: ["x"] };
expect(() => resolveAtomToolEdit(toolEdit)).toThrow(/Could not find a line number/);
});
});
describe("applyAtomEdits — replace", () => {
it("applies a literal substring substitution to the anchored line (first occurrence by default)", () => {
const content = "aaa\nfoo bar foo\nccc";
const loc = `2${computeLineHash(2, "foo bar foo")}`;
const resolved = resolveAtomToolEdit({ loc, replace: { find: "foo", with: "baz" } });
expect(resolved[0]?.op).toBe("replace");
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nbaz bar foo\nccc");
});
it("`all: true` replaces every occurrence on the line", () => {
const content = "foo foo foo";
const loc = `1${computeLineHash(1, "foo foo foo")}`;
const first = resolveAtomToolEdit({ loc, replace: { find: "foo", with: "bar" } });
expect(applyAtomEdits(content, first).lines).toBe("bar foo foo");
const all = resolveAtomToolEdit({ loc, replace: { find: "foo", with: "bar", all: true } });
expect(applyAtomEdits(content, all).lines).toBe("bar bar bar");
});
it("treats regex metacharacters as literal", () => {
// `(a, b)` would be a capture group in regex; here it must match the
// literal parens in the source line.
const content = "return wrap(foo(a, b));";
const loc = `1${computeLineHash(1, content)}`;
const resolved = resolveAtomToolEdit({ loc, replace: { find: "foo(a, b)", with: "foo(b, a)" } });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("return wrap(foo(b, a));");
});
it("treats unbalanced parens as literal characters, not as a regex error", () => {
const content = "x = bar());";
const loc = `1${computeLineHash(1, content)}`;
const resolved = resolveAtomToolEdit({ loc, replace: { find: "bar())", with: "baz()" } });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("x = baz();");
});
it("throws when the literal substring is not present on the anchor line", () => {
const content = "aaa\nbbb";
const loc = `2${computeLineHash(2, "bbb")}`;
const resolved = resolveAtomToolEdit({ loc, replace: { find: "zzz", with: "yyy" } });
expect(() => applyAtomEdits(content, resolved)).toThrow(/did not match line 2/);
});
it("combines with pre and post on the same anchor", () => {
const content = "aaa\nfoo\nccc";
const loc = `2${computeLineHash(2, "foo")}`;
const resolved = resolveAtomToolEdit({
loc,
pre: ["BEFORE"],
replace: { find: "foo", with: "FOO" },
post: ["AFTER"],
expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("Deleted file.ts");
expect(result.details?.op).toBe("delete");
expect(await Bun.file(filePath).exists()).toBe(false);
});
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nBEFORE\nFOO\nAFTER\nccc");
});
it("prefers splice when replace is also present on the same anchor", () => {
const content = "aaa\nfoo\nccc";
const loc = `2${computeLineHash(2, "foo")}`;
const resolved = resolveAtomToolEdit({ loc, splice: ["X"], replace: { find: "foo", with: "Y" } });
// Models sometimes duplicate intent on the same line; the explicit `splice`
// wins and the redundant `replace` is dropped silently.
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nX\nccc");
it("renames the section file with !mv", async () => {
await withTempDir(async tempDir => {
const sourcePath = path.join(tempDir, "file.ts");
const destinationPath = path.join(tempDir, "file2.ts");
await Bun.write(sourcePath, "export const x = 1;\n");
const result = await executeAtomSingle(atomExecuteOptions(tempDir, "---file.ts\n!mv file2.ts\n"));
expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain(
"Moved file.ts to file2.ts",
);
expect(result.details?.op).toBe("update");
expect(result.details?.move).toBe("file2.ts");
expect(await Bun.file(sourcePath).exists()).toBe(false);
expect(await Bun.file(destinationPath).text()).toBe("export const x = 1;\n");
});
});
it("treats empty `splice: []` as no-op when paired with replace", () => {
const content = "aaa\nfoo\nccc";
const loc = `2${computeLineHash(2, "foo")}`;
const resolved = resolveAtomToolEdit({ loc, splice: [], replace: { find: "foo", with: "FOO" } });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nFOO\nccc");
it("rejects sections that mix whole-file operations with line edits", async () => {
await withTempDir(async tempDir => {
await Bun.write(path.join(tempDir, "file.ts"), "export const x = 1;\n");
await expect(
executeAtomSingle(atomExecuteOptions(tempDir, "---file.ts\n!rm\n+export const y = 2;\n")),
).rejects.toThrow(/mixes !rm with line edits/);
await expect(
executeAtomSingle(atomExecuteOptions(tempDir, "---file.ts\n!mv file2.ts\n-1ab\n")),
).rejects.toThrow(/mixes !mv with line edits/);
});
});
it("rejects replace.find containing a newline with a splice hint", () => {
const loc = "1ab";
expect(() => resolveAtomToolEdit({ loc, replace: { find: "a\nb", with: "x" } })).toThrow(
/must be a single line.*splice/,
);
});
it("rejects !mv without a destination", async () => {
await withTempDir(async tempDir => {
await Bun.write(path.join(tempDir, "file.ts"), "export const x = 1;\n");
it("supports replace.with containing a newline", () => {
const content = "aaa\nfoo\nccc";
const loc = `2${computeLineHash(2, "foo")}`;
const resolved = resolveAtomToolEdit({ loc, replace: { find: "foo", with: "x\ny" } });
const result = applyAtomEdits(content, resolved);
expect(result.lines).toBe("aaa\nx\ny\nccc");
});
it("drops cross-entry `del` when another edit replaces the same anchor", () => {
// Models sometimes emit a `splice: []` cleanup alongside a `replace`/`splice` that
// already replaces the line. Prefer the replacement and silently drop the del.
const content = "aaa\nfoo\nccc";
const loc = `2${computeLineHash(2, "foo")}`;
const edits = [
...resolveAtomToolEdit({ loc, replace: { find: "foo", with: "FOO" } }),
...resolveAtomToolEdit({ loc, splice: [] }),
];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nFOO\nccc");
await expect(executeAtomSingle(atomExecuteOptions(tempDir, "---file.ts\n!mv\n"))).rejects.toThrow(
/!mv requires exactly one non-empty destination path/,
);
});
});
});
// ───────────────────────────────────────────────────────────────────────────
// Schema is permissive for small models that pass extra fields
// ───────────────────────────────────────────────────────────────────────────
describe("atomEditParamsSchema — extra-field tolerance", () => {
it("accepts extra `path` field alongside `input`", () => {
const args = { path: "x.ts", input: "---x.ts\n1aa=NEW" };
expect(Value.Check(atomEditParamsSchema, args)).toBe(true);
});
it("accepts extra free-form fields like `_`", () => {
const args = { _: "fixing inverted boolean", input: "---x.ts\n1aa=NEW" };
expect(Value.Check(atomEditParamsSchema, args)).toBe(true);
});
it("still requires `input`", () => {
const args = { path: "x.ts" };
expect(Value.Check(atomEditParamsSchema, args)).toBe(false);
});
});
@@ -298,6 +298,52 @@ describe("parseCommandArgs + substituteArgs integration", () => {
});
});
// ============================================================================
// Hashline prompt helpers
// ============================================================================
describe("hashline prompt helpers", () => {
function createPromptTemplate(content: string): PromptTemplate {
return {
name: "test-template",
description: "Test template",
content,
source: "test",
};
}
function expandPrompt(content: string): string {
return expandPromptTemplate("/test-template", [createPromptTemplate(content)]);
}
test("href and hrefr should reuse anchors remembered from hline", () => {
const result = expandPrompt(
'{{hline 2 "const timeout = 5000;"}}\nquoted={{href 2}}\nraw={{hrefr 2}}\nlast={{hrefr}}',
);
const [line, quoted, raw, last] = result.split("\n");
const ref = line.split("|", 1)[0];
expect(line).toBe(`${ref}|const timeout = 5000;`);
expect(quoted).toBe(`quoted="${ref}"`);
expect(raw).toBe(`raw=${ref}`);
expect(last).toBe(`last=${ref}`);
});
test("href and hrefr should still support explicit content without hline state", () => {
const result = expandPrompt('quoted={{href 5 "\treturn clean;"}}\nraw={{hrefr 5 "\treturn clean;"}}');
const [quoted, raw] = result.split("\n");
const ref = raw.slice("raw=".length);
expect(quoted).toBe(`quoted="${ref}"`);
expect(ref).toMatch(/^5[a-z]{2}$/);
});
test("href should not reuse hline state across prompt renders", () => {
expect(expandPrompt('{{hline 1 "const x = 1;"}}\n{{hrefr}}')).toMatch(/^1[a-z]{2}\|const x = 1;\n1[a-z]{2}$/);
expect(() => expandPrompt("{{hrefr}}")).toThrow("previous {{hline}}");
});
});
// ============================================================================
// expandSlashCommand + expandPromptTemplate fallback behavior
// ============================================================================
@@ -24,4 +24,65 @@ describe("editToolRenderer", () => {
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts");
});
it("uses atom input headers for streaming call path without apply_patch errors", async () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall(
{
input: "---packages/coding-agent/src/edit/renderer.ts\n$\n+// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "atom" } },
uiTheme,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts");
expect(rendered).not.toContain("The first line of the patch must be");
});
it("recognizes compact and quoted atom input headers", async () => {
const uiTheme = await getUiTheme();
const compactComponent = editToolRenderer.renderCall(
{
input: "---foo bar.ts\n^\n+// preview",
},
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "atom" } },
uiTheme,
);
const quotedComponent = editToolRenderer.renderCall(
{
input: "---'baz qux.ts'\n+// preview",
},
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "atom" } },
uiTheme,
);
const compactRendered = Bun.stripANSI(compactComponent.render(160).join("\n"));
const quotedRendered = Bun.stripANSI(quotedComponent.render(160).join("\n"));
expect(compactRendered).toContain("foo bar.ts");
expect(quotedRendered).toContain("baz qux.ts");
});
it("uses atom input headers for completed single-file result path", async () => {
const uiTheme = await getUiTheme();
const component = editToolRenderer.renderResult(
{
content: [{ type: "text", text: "Updated packages/coding-agent/src/edit/renderer.ts" }],
details: {
diff: "+1|// preview",
op: "update",
},
},
{ expanded: false, isPartial: false, renderContext: { editMode: "atom" } },
uiTheme,
{
input: "---packages/coding-agent/src/edit/renderer.ts\n$\n+// preview",
},
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts");
expect(rendered).not.toContain(" …");
});
});
@@ -127,6 +127,45 @@ describe("search tool path lists", () => {
expect(details?.scopePath).toBe("packages");
});
it("search formats absolute in-cwd paths relative to cwd", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "search");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing search tool");
const absoluteAppsPath = path.join(tempDir, "apps");
const result = await tool.execute("search-absolute-in-cwd", {
pattern: "shared-needle",
path: absoluteAppsPath,
});
const text = getText(result);
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
expect(text).toContain("# apps");
expect(text).toContain("## grep.txt");
expect(text).not.toContain(tempDir);
expect(details?.fileCount).toBe(1);
expect(details?.scopePath).toBe("apps");
});
it("write reports absolute in-cwd targets relative to cwd", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "write");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing write tool");
const absoluteTarget = path.join(tempDir, "written.txt");
const result = await tool.execute("write-absolute-in-cwd", {
path: absoluteTarget,
content: "written\n",
});
const text = getText(result);
expect(text).toContain("Successfully wrote 8 bytes to written.txt");
expect(text).not.toContain(tempDir);
expect(await Bun.file(absoluteTarget).text()).toBe("written\n");
});
it("ast_grep accepts quoted path and glob filters", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "ast_grep");
@@ -253,6 +292,31 @@ describe("search tool path lists", () => {
expect(details?.scopePath).toBe("packages");
});
it("find keeps paths outside cwd absolute", async () => {
const outsideDir = await fs.mkdtemp(path.join(path.dirname(tempDir), "find-outside-"));
try {
await Bun.write(path.join(outsideDir, "outside.txt"), "outside\n");
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "find");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing find tool");
const result = await tool.execute("find-outside-cwd", {
pattern: outsideDir,
});
const text = getText(result);
const expectedPath = path.join(outsideDir, "outside.txt").replace(/\\/g, "/");
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
expect(text).toContain(expectedPath);
expect(text).not.toContain("../");
expect(details?.fileCount).toBe(1);
expect(details?.scopePath).toBe(outsideDir.replace(/\\/g, "/"));
} finally {
await fs.rm(outsideDir, { recursive: true, force: true });
}
});
it("grep accepts bare space-separated directory names (no trailing slash)", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "search");
+82 -15
View File
@@ -14,9 +14,15 @@ import { parseArgs } from "node:util";
import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { padding, visibleWidth } from "@oh-my-pi/pi-tui";
import { TempDir } from "@oh-my-pi/pi-utils";
import { postmortem, TempDir } from "@oh-my-pi/pi-utils";
import { generateJsonReport, generateReport } from "./report";
import { type BenchmarkConfig, type ProgressEvent, runBenchmark } from "./runner";
import {
type BenchmarkConfig,
type BenchmarkResult,
buildBenchmarkResult,
type ProgressEvent,
runBenchmark,
} from "./runner";
import { type EditTask, loadTasksFromDir, validateFixturesFromDir } from "./tasks";
const COLOR_ENABLED = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -33,6 +39,10 @@ const ANSI = {
cyan: "\x1b[36m",
} as const;
const RUNS_DIR = path.resolve(import.meta.dir, "..", "..", "..", "runs");
fs.mkdirSync(RUNS_DIR, { recursive: true });
function paint(code: string, text: string): string {
return COLOR_ENABLED ? `${code}${text}${ANSI.reset}` : text;
}
@@ -59,7 +69,7 @@ function generateReportFilename(config: BenchmarkConfig, format: "markdown" | "j
const variant = config.editVariant ?? "replace";
const timestamp = new Date().toISOString().replace(/:/g, "-").replace(/\..+$/, "").replace(/Z$/, "Z");
const ext = format === "json" ? "json" : "md";
return `runs/${modelName}_${variant}_${timestamp}.${ext}`;
return path.join(RUNS_DIR, `${modelName}_${variant}_${timestamp}.${ext}`);
}
async function resolveConversationDumpDir(outputPath: string): Promise<string> {
@@ -77,6 +87,21 @@ async function resolveConversationDumpDir(outputPath: string): Promise<string> {
return path.join(parsed.dir, `${parsed.name}.${timestamp}.dump`);
}
async function conversationDumpStatus(dumpDir: string): Promise<string> {
try {
const stat = await fs.promises.stat(dumpDir);
if (stat.isDirectory()) {
return `Conversation dumps written to: ${dumpDir}`;
}
return `Conversation dump path is not a directory: ${dumpDir}`;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return `No conversation dumps written: ${dumpDir}`;
}
throw error;
}
}
function printUsage(tasks?: EditTask[]): void {
const taskList = tasks
? tasks.map(t => ` ${t.id.padEnd(30)} ${t.name}`).join("\n")
@@ -436,10 +461,55 @@ async function main(): Promise<void> {
console.log("");
const progress = new LiveProgress(tasksToRun.length * config.runsPerTask, config.runsPerTask);
const result = await runBenchmark(tasksToRun, config, event => {
progress.handleEvent(event);
let latestResult = buildBenchmarkResult({
tasks: tasksToRun,
config,
resultsByTask: new Map(),
startTime: new Date().toISOString(),
});
progress.finish();
let progressFinished = false;
let reportWritePromise: Promise<void> | undefined;
const finishProgress = () => {
if (progressFinished) return;
progress.finish();
progressFinished = true;
};
const writeReport = async (result: BenchmarkResult, interrupted: boolean) => {
if (reportWritePromise) return reportWritePromise;
reportWritePromise = (async () => {
if (interrupted) {
console.log("");
console.log("Benchmark interrupted; writing partial report...");
}
const report = formatType === "json" ? generateJsonReport(result) : generateReport(result);
await Bun.write(outputPath, report);
console.log(`Report written to: ${outputPath}`);
if (config.conversationDumpDir) {
console.log(await conversationDumpStatus(config.conversationDumpDir));
}
})();
return reportWritePromise;
};
const unregisterReportCleanup = postmortem.register("typescript-edit-benchmark-report", async reason => {
if (reason === postmortem.Reason.EXIT) return;
finishProgress();
await writeReport(latestResult, true);
if (cleanup) {
await cleanup();
}
});
const result = await runBenchmark(
tasksToRun,
config,
event => {
progress.handleEvent(event);
},
snapshot => {
latestResult = snapshot;
},
);
latestResult = result;
finishProgress();
console.log("");
console.log("Benchmark complete!");
@@ -453,11 +523,8 @@ async function main(): Promise<void> {
}
console.log("");
const report = formatType === "json" ? generateJsonReport(result) : generateReport(result);
await Bun.write(outputPath, report);
console.log(`Report written to: ${outputPath}`);
console.log(`Conversation dumps written to: ${config.conversationDumpDir}`);
await writeReport(result, false);
unregisterReportCleanup();
if (cleanup) {
await cleanup();
@@ -571,9 +638,9 @@ class LiveProgress {
#printSummary(): void {
const n = this.#completed;
if (n === 0) return;
const denom = n || 1;
const successRate = (this.#success / n) * 100;
const successRate = (this.#success / denom) * 100;
const editSuccessRate = this.#totalEdits > 0 ? (this.#totalEditSuccesses / this.#totalEdits) * 100 : 100;
const avgIndent =
this.#indentScores.length > 0 ? this.#indentScores.reduce((a, b) => a + b, 0) / this.#indentScores.length : 0;
@@ -590,9 +657,9 @@ class LiveProgress {
console.log(` Tool calls: read=${this.#totalReads} edit=${this.#totalEdits} write=${this.#totalWrites}`);
console.log(` Tool input chars: ${this.#totalToolInputChars.toLocaleString()}`);
console.log(
` Avg tokens/task: ${Math.round(this.#totalInput / n)} in / ${Math.round(this.#totalOutput / n)} out`,
` Avg tokens/task: ${Math.round(this.#totalInput / denom)} in / ${Math.round(this.#totalOutput / denom)} out`,
);
console.log(` Avg time/task: ${Math.round(this.#totalDuration / n)}ms`);
console.log(` Avg time/task: ${Math.round(this.#totalDuration / denom)}ms`);
}
#renderLine(): void {
@@ -31,12 +31,22 @@ function escapeMarkdown(text: string): string {
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function getStringField(value: unknown, field: string): string | null {
if (!value || typeof value !== "object") return null;
const fieldValue = (value as Record<string, unknown>)[field];
return typeof fieldValue === "string" ? fieldValue : null;
}
function formatEditArgsBlock(args: unknown): string {
if (!args || typeof args !== "object") return "—";
const diff = (args as { diff?: unknown }).diff;
if (typeof diff === "string") {
const diff = getStringField(args, "diff");
if (diff !== null) {
return diff;
}
const input = getStringField(args, "input");
if (input !== null) {
return input;
}
try {
return JSON.stringify(args, null, 2);
} catch {
@@ -9,7 +9,6 @@ import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentMessage, ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { computeLineHash, formatSessionDumpText, RpcClient } from "@oh-my-pi/pi-coding-agent";
import { prompt } from "@oh-my-pi/pi-utils";
import { diffLines } from "diff";
@@ -21,9 +20,16 @@ import benchmarkTaskPrompt from "./prompts/benchmark-task.md" with { type: "text
import type { EditTask } from "./tasks";
import { verifyExpectedFileSubset, verifyExpectedFiles } from "./verify";
const TMP = `/tmp/rb-${Math.random().toString(36).slice(2, 10)}`;
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", "..");
const RUNS_DIR = path.join(REPO_ROOT, "runs");
const TMP = path.join(RUNS_DIR, `rb-${Math.random().toString(36).slice(2, 10)}`);
const CLI_PATH = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-coding-agent/cli"));
function formatLogPath(logFile: string): string {
const relativePath = path.relative(REPO_ROOT, logFile);
return relativePath === "" ? "." : relativePath;
}
/** Subset of session state used for markdown conversation dumps (parity with /dump). */
type ConversationDumpSessionState = {
sessionFile?: string;
@@ -48,7 +54,7 @@ interface BenchmarkClient {
dispose(): Promise<void>;
}
fs.mkdirSync(TMP);
fs.mkdirSync(TMP, { recursive: true });
let n = 0;
function subtmp(pre: string): string {
@@ -191,9 +197,8 @@ function getEditPathFromArgs(args: unknown): string | null {
}
const HASHLINE_SUBTYPES = ["set", "set_range", "insert"] as const;
const BENCHMARK_TOOL_NAMES = ["read", "edit", "vim", "write", "apply_patch"] as const;
const EDIT_TOOL_NAMES = ["edit", "vim", "apply_patch"] as const;
const BENCHMARK_TOOL_NAMES = ["read", "edit", "write", "apply_patch"] as const;
const EDIT_TOOL_NAMES = ["edit", "apply_patch"] as const;
function isEditTool(toolName: unknown): toolName is (typeof EDIT_TOOL_NAMES)[number] {
return toolName === "edit" || toolName === "vim" || toolName === "apply_patch";
@@ -1196,7 +1201,7 @@ async function runSingleTask(
? `Verification failed: ${error}${diff ? `\n\nDiff (expected vs actual):\n\n\`\`\`diff\n${diff}\n\`\`\`` : ""}${mutationIntentSuffix}`
: `Previous attempt failed.${mutationIntentSuffix}`;
}
if (!useInProcess) {
if (config.conversationDumpDir) {
conversationSnapshot = await snapshotConversationDump(client);
}
} finally {
@@ -1225,7 +1230,7 @@ async function runSingleTask(
timeoutTelemetry,
mutationIntentValidation,
});
console.log(` Log: ${logFile}`);
console.log(` Log: ${formatLogPath(logFile)}`);
if (config.conversationDumpDir && conversationSnapshot) {
await writeConversationDump({
@@ -1542,7 +1547,7 @@ async function _runRpcBenchmarkRun(
timeoutTelemetry,
mutationIntentValidation,
});
console.log(` Log: ${logFile}`);
console.log(` Log: ${formatLogPath(logFile)}`);
await persistConversationDump({
client,
@@ -2013,52 +2018,16 @@ export async function runTask(
return summarizeTaskRuns(task, runs);
}
export async function runBenchmark(
tasks: EditTask[],
config: BenchmarkConfig,
onProgress?: (event: ProgressEvent) => void,
): Promise<BenchmarkResult> {
const startTime = new Date().toISOString();
export function buildBenchmarkResult(params: {
tasks: EditTask[];
config: BenchmarkConfig;
resultsByTask: Map<string, TaskRunResult[]>;
startTime: string;
endTime?: string;
}): BenchmarkResult {
const taskResults = params.tasks.map(task => summarizeTaskRuns(task, params.resultsByTask.get(task.id) ?? []));
// Discover shared infrastructure once for in-process mode
const useInProcess = config.inProcess !== false;
const shared = useInProcess
? await discoverSharedInfra({
editVariant: config.editVariant,
editFuzzy: config.editFuzzy,
editFuzzyThreshold: config.editFuzzyThreshold,
})
: undefined;
const runItems: TaskRunItem[] = tasks.flatMap(task =>
Array.from({ length: config.runsPerTask }, (_, runIndex) => ({ task, runIndex })),
);
const pending = shuffle(runItems);
const resultsByTask = new Map<string, TaskRunResult[]>();
const concurrency = Math.max(1, Math.floor(config.taskConcurrency));
const running: Promise<void>[] = [];
const runNext = async (): Promise<void> => {
const nextItem = pending.shift();
if (!nextItem) return;
const { task, result } = await runConcurrentBenchmarkRun(nextItem, config, onProgress, shared);
const list = resultsByTask.get(task.id) ?? [];
list.push(result);
resultsByTask.set(task.id, list);
await runNext();
};
const slots = Math.min(concurrency, pending.length);
for (let i = 0; i < slots; i++) {
running.push(runNext());
}
await Promise.all(running);
const taskResults = tasks.map(task => summarizeTaskRuns(task, resultsByTask.get(task.id) ?? []));
const endTime = new Date().toISOString();
const endTime = params.endTime ?? new Date().toISOString();
const allRuns = taskResults.flatMap(t => t.runs);
const totalRuns = allRuns.length;
@@ -2115,7 +2084,7 @@ export async function runBenchmark(
: undefined;
const hashlineEditSubtypes: Record<string, number> | undefined =
config.editVariant === "hashline"
params.config.editVariant === "hashline"
? Object.fromEntries(
HASHLINE_SUBTYPES.map(key => [
key,
@@ -2126,7 +2095,7 @@ export async function runBenchmark(
const denom = effectiveRuns || 1;
const summary: BenchmarkSummary = {
totalTasks: tasks.length,
totalTasks: params.tasks.length,
totalRuns: effectiveRuns,
successfulRuns,
overallSuccessRate: successfulRuns / denom,
@@ -2168,10 +2137,58 @@ export async function runBenchmark(
};
return {
config,
config: params.config,
tasks: taskResults,
summary,
startTime,
startTime: params.startTime,
endTime,
};
}
export async function runBenchmark(
tasks: EditTask[],
config: BenchmarkConfig,
onProgress?: (event: ProgressEvent) => void,
onResultSnapshot?: (result: BenchmarkResult) => void,
): Promise<BenchmarkResult> {
const startTime = new Date().toISOString();
// Discover shared infrastructure once for in-process mode
const useInProcess = config.inProcess !== false;
const shared = useInProcess
? await discoverSharedInfra({
editVariant: config.editVariant,
editFuzzy: config.editFuzzy,
editFuzzyThreshold: config.editFuzzyThreshold,
})
: undefined;
const runItems: TaskRunItem[] = tasks.flatMap(task =>
Array.from({ length: config.runsPerTask }, (_, runIndex) => ({ task, runIndex })),
);
const pending = shuffle(runItems);
const resultsByTask = new Map<string, TaskRunResult[]>();
const concurrency = Math.max(1, Math.floor(config.taskConcurrency));
const running: Promise<void>[] = [];
const runNext = async (): Promise<void> => {
const nextItem = pending.shift();
if (!nextItem) return;
const { task, result } = await runConcurrentBenchmarkRun(nextItem, config, onProgress, shared);
const list = resultsByTask.get(task.id) ?? [];
list.push(result);
resultsByTask.set(task.id, list);
onResultSnapshot?.(buildBenchmarkResult({ tasks, config, resultsByTask, startTime }));
await runNext();
};
const slots = Math.min(concurrency, pending.length);
for (let i = 0; i < slots; i++) {
running.push(runNext());
}
await Promise.all(running);
return buildBenchmarkResult({ tasks, config, resultsByTask, startTime });
}
@@ -4,7 +4,9 @@ import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { formatSessionDumpText, SessionManager } from "@oh-my-pi/pi-coding-agent";
import { TempDir } from "@oh-my-pi/pi-utils";
import { writeConversationDump } from "../src/runner";
import { generateReport } from "../src/report";
import { buildBenchmarkResult, type TaskRunResult, writeConversationDump } from "../src/runner";
import type { EditTask } from "../src/tasks";
const tempDirs: TempDir[] = [];
@@ -22,6 +24,126 @@ afterEach(async () => {
);
});
function createTask(id: string): EditTask {
return {
id,
name: id,
prompt: `Fix ${id}`,
files: [`${id}.ts`],
inputDir: "/tmp/input",
expectedDir: "/tmp/expected",
};
}
function createRun(runIndex: number, success: boolean): TaskRunResult {
return {
runIndex,
success,
patchApplied: success,
verificationPassed: success,
tokens: { input: 12, output: 8, total: 20 },
duration: 100,
toolCalls: {
read: 1,
edit: 1,
write: 0,
editSuccesses: success ? 1 : 0,
editFailures: success ? 0 : 1,
editWarnings: 0,
editAutocorrects: 0,
totalInputChars: 50,
},
editFailures: [],
editWarnings: [],
editAutocorrectCount: 0,
};
}
describe("buildBenchmarkResult", () => {
it("summarizes completed runs without requiring every scheduled run to finish", () => {
const completedTask = createTask("completed");
const pendingTask = createTask("pending");
const resultsByTask = new Map([[completedTask.id, [createRun(0, true)]]]);
const result = buildBenchmarkResult({
tasks: [completedTask, pendingTask],
config: {
provider: "anthropic",
model: "claude",
runsPerTask: 2,
timeout: 1000,
taskConcurrency: 1,
},
resultsByTask,
startTime: "2026-04-28T00:00:00.000Z",
endTime: "2026-04-28T00:00:01.000Z",
});
expect(result.summary.totalTasks).toBe(2);
expect(result.summary.totalRuns).toBe(1);
expect(result.summary.successfulRuns).toBe(1);
expect(result.tasks.find(task => task.id === "pending")?.runs).toEqual([]);
expect(result.startTime).toBe("2026-04-28T00:00:00.000Z");
expect(result.endTime).toBe("2026-04-28T00:00:01.000Z");
});
it("can generate a report before any run completes", () => {
const result = buildBenchmarkResult({
tasks: [createTask("pending")],
config: {
provider: "anthropic",
model: "claude",
runsPerTask: 2,
timeout: 1000,
taskConcurrency: 1,
},
resultsByTask: new Map(),
startTime: "2026-04-28T00:00:00.000Z",
endTime: "2026-04-28T00:00:01.000Z",
});
expect(result.summary.totalRuns).toBe(0);
expect(generateReport(result)).toContain("| Total Runs | 0 |");
});
it("renders atom input args directly in edit error patch blocks", () => {
const task = createTask("atom");
const titleExpression = "$" + "{title}";
const input = [
"---orcid.ts",
"276ka= if (works.length > 0) {",
"277fo= for (const title of works) {",
`278hu= md += \`- ${titleExpression}\\n\`;`,
"279he= }",
"280nd= } else {",
"281he= md += 'No works available.\\n';",
"282rd= }",
].join("\n");
const failedRun: TaskRunResult = {
...createRun(0, false),
editFailures: [{ toolCallId: "edit-1", args: { input }, error: "No changes made" }],
};
const result = buildBenchmarkResult({
tasks: [task],
config: {
provider: "anthropic",
model: "claude",
runsPerTask: 1,
timeout: 1000,
taskConcurrency: 1,
editVariant: "atom",
},
resultsByTask: new Map([[task.id, [failedRun]]]),
startTime: "2026-04-28T00:00:00.000Z",
endTime: "2026-04-28T00:00:01.000Z",
});
const report = generateReport(result);
expect(report).toContain(`\`\`\`diff\n${input}\n\`\`\``);
expect(report).not.toContain('"input":');
});
});
describe("writeConversationDump", () => {
it("writes benchmark conversations as session dumps and copies artifacts", async () => {
const sourceRoot = await createTempDir("@typescript-edit-benchmark-source-");