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:
+1
-1
@@ -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/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Changed OpenAI custom Lark grammar payloads to strip comments and blank lines before sending provider requests.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user