fix: corrected optional edit path fallback across chunk hashline modes
- Added raw read output propagation so read/archive commands bypass anchors, line numbers, and chunk formatting. - Fixed atom/hashline editing by tightening hashline prefixes and applying grouped anchor edits in stable order. - Hardened chunk parsing and path handling by using `bigram_end` checks and resolving edit paths via shared `args.path` fallback. - Updated path-related behavior for chunk, replace, patch, and hashline previews to honor optional edit paths. - Removed mode-level param validators and replaced them with runtime handling, then removed obsolete validation tests.
This commit is contained in:
@@ -432,15 +432,17 @@ fn hashline_prefix_len(line: &str) -> Option<usize> {
|
||||
}
|
||||
|
||||
// Match exactly one BPE bigram (2 ASCII chars) from HASHLINE_BIGRAMS.
|
||||
if remainder.len() < 2 {
|
||||
// Use char-boundary-safe slicing to avoid panicking on multi-byte content.
|
||||
let bigram_end = 2;
|
||||
if remainder.len() < bigram_end || !remainder.is_char_boundary(bigram_end) {
|
||||
return None;
|
||||
}
|
||||
let bigram = &remainder[..2];
|
||||
let bigram = &remainder[..bigram_end];
|
||||
if !bigram.is_ascii() || !HASHLINE_BIGRAMS.contains(&bigram) {
|
||||
return None;
|
||||
}
|
||||
offset += 2;
|
||||
remainder = &remainder[2..];
|
||||
offset += bigram_end;
|
||||
remainder = &remainder[bigram_end..];
|
||||
|
||||
remainder.strip_prefix(':').map(|_| offset + 1)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed the hashline and chunk anchor ID format from the prior hex-like tokens to two-letter BPE bigrams (for example `#th`), which invalidates previously captured `LINE#ID`/chunk selectors and requires re-reading to refresh anchors
|
||||
@@ -18,6 +19,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `atom` mode to apply multiple edits on the same anchor line without index-shift artifacts, so mixed operations like `before`, `after`, `set`, `sub`, `ins`, and `del` now resolve consistently
|
||||
- Fixed `atom` mode `append_file` insertion to preserve a file’s trailing newline sentinel when appending content
|
||||
- Fixed `read` output for raw archive entries so hashline anchors, line numbers, and chunked formatting are not injected into raw content
|
||||
- Fixed hashline parsing so lines like `# Note:` or `# TODO:` are no longer misinterpreted and stripped as hashline prefixes
|
||||
- Adjusted patch and replace validation to report a clear missing-path error when neither an entry path nor a top-level path is provided
|
||||
|
||||
|
||||
@@ -18,26 +18,14 @@ import type { ToolSession } from "../tools";
|
||||
import { VimTool, vimSchema } from "../tools/vim";
|
||||
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
||||
import type { VimToolDetails } from "../vim/types";
|
||||
import {
|
||||
type ApplyPatchParams,
|
||||
applyPatchSchema,
|
||||
expandApplyPatchToEntries,
|
||||
isApplyPatchParams,
|
||||
} from "./modes/apply-patch";
|
||||
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,
|
||||
isAtomParams,
|
||||
} from "./modes/atom";
|
||||
import { type AtomParams, type AtomToolEdit, atomEditParamsSchema, executeAtomSingle } from "./modes/atom";
|
||||
import {
|
||||
type ChunkParams,
|
||||
type ChunkToolEdit,
|
||||
chunkEditParamsSchema,
|
||||
executeChunkSingle,
|
||||
isChunkParams,
|
||||
parseChunkEditPath,
|
||||
resolveAnchorStyle,
|
||||
resolveChunkAutoIndent,
|
||||
@@ -47,22 +35,9 @@ import {
|
||||
type HashlineParams,
|
||||
type HashlineToolEdit,
|
||||
hashlineEditParamsSchema,
|
||||
isHashlineParams,
|
||||
} from "./modes/hashline";
|
||||
import {
|
||||
executePatchSingle,
|
||||
isPatchParams,
|
||||
type PatchEditEntry,
|
||||
type PatchParams,
|
||||
patchEditSchema,
|
||||
} from "./modes/patch";
|
||||
import {
|
||||
executeReplaceSingle,
|
||||
isReplaceParams,
|
||||
type ReplaceEditEntry,
|
||||
type ReplaceParams,
|
||||
replaceEditSchema,
|
||||
} from "./modes/replace";
|
||||
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
|
||||
import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
|
||||
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
||||
|
||||
export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
||||
@@ -102,8 +77,6 @@ type EditToolResultDetails = EditToolDetails | VimToolDetails;
|
||||
type EditModeDefinition = {
|
||||
description: (session: ToolSession) => string;
|
||||
parameters: TInput;
|
||||
invalidParamsMessage: string;
|
||||
validate: (params: EditParams) => boolean;
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -126,10 +99,6 @@ function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
|
||||
return editMode;
|
||||
}
|
||||
|
||||
function isVimParams(params: EditParams): params is VimParams {
|
||||
return typeof params === "object" && params !== null && "file" in params && typeof params.file === "string";
|
||||
}
|
||||
|
||||
function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
|
||||
switch (rawValue) {
|
||||
case "true":
|
||||
@@ -343,10 +312,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<EditToolResultDetails, TInput>> {
|
||||
const modeDefinition = this.#getModeDefinition();
|
||||
if (!modeDefinition.validate(params)) {
|
||||
throw new Error(modeDefinition.invalidParamsMessage);
|
||||
}
|
||||
|
||||
return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall), onUpdate);
|
||||
}
|
||||
|
||||
@@ -359,9 +324,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
chunkAutoIndent: resolveChunkAutoIndent(),
|
||||
}),
|
||||
parameters: chunkEditParamsSchema,
|
||||
invalidParamsMessage:
|
||||
"Invalid edit parameters for chunk mode. Expected `{ edits: [{ path: 'file:selector', ...op }, ...] }` with at least one edit. Each edit needs a `path`; supply exactly one of `write: 'content'`, `insert: { loc, body }`, or `delete: true`.",
|
||||
validate: isChunkParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -391,8 +353,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
patch: {
|
||||
description: () => prompt.render(patchDescription),
|
||||
parameters: patchEditSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for patch mode.",
|
||||
validate: isPatchParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -422,8 +382,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
apply_patch: {
|
||||
description: () => prompt.render(applyPatchDescription),
|
||||
parameters: applyPatchSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for apply_patch mode.",
|
||||
validate: isApplyPatchParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -452,8 +410,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
hashline: {
|
||||
description: () => prompt.render(hashlineDescription),
|
||||
parameters: hashlineEditParamsSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for hashline mode.",
|
||||
validate: isHashlineParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -483,9 +439,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
atom: {
|
||||
description: () => prompt.render(atomDescription),
|
||||
parameters: atomEditParamsSchema,
|
||||
invalidParamsMessage:
|
||||
"Edit tool requires `{ edits: [...] }` (an array of entries). Each entry needs exactly one op key (set, before, after, del, sub, ins, append, prepend) plus optional `path`.",
|
||||
validate: isAtomParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -515,8 +468,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
replace: {
|
||||
description: () => prompt.render(replaceDescription),
|
||||
parameters: replaceEditSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for replace mode.",
|
||||
validate: isReplaceParams,
|
||||
execute: (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
@@ -546,8 +497,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
vim: {
|
||||
description: () => this.#vimTool.description,
|
||||
parameters: vimSchema,
|
||||
invalidParamsMessage: "Invalid edit parameters for vim mode.",
|
||||
validate: isVimParams,
|
||||
execute: async (
|
||||
tool: EditTool,
|
||||
params: EditParams,
|
||||
|
||||
@@ -22,15 +22,6 @@ export const applyPatchSchema = Type.Object({
|
||||
|
||||
export type ApplyPatchParams = Static<typeof applyPatchSchema>;
|
||||
|
||||
export function isApplyPatchParams(params: unknown): params is ApplyPatchParams {
|
||||
return (
|
||||
typeof params === "object" &&
|
||||
params !== null &&
|
||||
"input" in params &&
|
||||
typeof (params as { input: unknown }).input === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the envelope and lower each hunk to a `PatchEditEntry` so it can
|
||||
* be routed through `executePatchSingle`.
|
||||
|
||||
@@ -48,7 +48,7 @@ const linesSchema = Type.Union([Type.Array(Type.String()), Type.String()]);
|
||||
|
||||
/**
|
||||
* Flat entry shape: every op key is optional, and the runtime validator
|
||||
* (`isAtomParams` + `resolveAtomToolEdit`) enforces that exactly one op key
|
||||
* (`resolveAtomToolEdit`) enforces that exactly one op key
|
||||
* is present per entry. We use a flat schema instead of a 9-member discriminated
|
||||
* union to keep the tool definition compact (the schema is re-sent on every
|
||||
* turn, so duplicating `path` + descriptions across 9 union members 2×'s
|
||||
@@ -111,15 +111,6 @@ export type AtomEdit =
|
||||
|
||||
const ATOM_OP_KEYS = ["set", "before", "after", "del", "sub", "ins", "append", "prepend"] as const;
|
||||
|
||||
export function isAtomParams(params: unknown): params is AtomParams {
|
||||
// Minimal shape check. Per-entry validation (op key presence, exclusivity,
|
||||
// payload sanity) all happens in `resolveAtomToolEdit` so the model gets a
|
||||
// specific actionable error rather than a generic "invalid parameters" message.
|
||||
if (typeof params !== "object" || params === null) return false;
|
||||
if (!("edits" in params) || !Array.isArray(params.edits)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Resolution
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -273,122 +264,30 @@ function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
||||
// Apply
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getAtomEditSortKey(edit: AtomEdit, fileLineCount: number): { sortLine: number; precedence: number } {
|
||||
switch (edit.op) {
|
||||
case "sub":
|
||||
case "ins":
|
||||
return { sortLine: edit.pos.line, precedence: -1 };
|
||||
case "set":
|
||||
case "del":
|
||||
return { sortLine: edit.pos.line, precedence: 0 };
|
||||
case "after":
|
||||
return { sortLine: edit.pos.line, precedence: 1 };
|
||||
case "before":
|
||||
return { sortLine: edit.pos.line, precedence: 2 };
|
||||
case "append_file":
|
||||
return { sortLine: fileLineCount + 1, precedence: 3 };
|
||||
case "prepend_file":
|
||||
return { sortLine: 0, precedence: 3 };
|
||||
function applySubInsToLine(
|
||||
edit: { op: "sub" | "ins"; pos: Anchor; find: string; to: string },
|
||||
current: string,
|
||||
): string {
|
||||
const first = current.indexOf(edit.find);
|
||||
if (first === -1) {
|
||||
throw new Error(
|
||||
`${edit.op}: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAtomEditToLines(edit: AtomEdit, fileLines: string[], trackFirstChanged: (line: number) => void): void {
|
||||
switch (edit.op) {
|
||||
case "set": {
|
||||
const lines = edit.lines.length === 0 ? [""] : edit.lines;
|
||||
fileLines.splice(edit.pos.line - 1, 1, ...lines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "del": {
|
||||
fileLines.splice(edit.pos.line - 1, 1);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "before": {
|
||||
if (edit.lines.length === 0) break;
|
||||
fileLines.splice(edit.pos.line - 1, 0, ...edit.lines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "after": {
|
||||
if (edit.lines.length === 0) break;
|
||||
fileLines.splice(edit.pos.line, 0, ...edit.lines);
|
||||
trackFirstChanged(edit.pos.line + 1);
|
||||
break;
|
||||
}
|
||||
case "sub": {
|
||||
const idx = edit.pos.line - 1;
|
||||
const current = fileLines[idx];
|
||||
const first = current.indexOf(edit.find);
|
||||
if (first === -1) {
|
||||
throw new Error(
|
||||
`sub: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
const second = current.indexOf(edit.find, first + 1);
|
||||
if (second !== -1) {
|
||||
throw new Error(
|
||||
`sub: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
|
||||
`use a longer substring that uniquely identifies the target. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
const next = current.slice(0, first) + edit.to + current.slice(first + edit.find.length);
|
||||
// Allow `to` to introduce newlines, expanding into multiple lines.
|
||||
const newLines = next.includes("\n") ? next.split("\n") : [next];
|
||||
fileLines.splice(idx, 1, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "ins": {
|
||||
const idx = edit.pos.line - 1;
|
||||
const current = fileLines[idx];
|
||||
const first = current.indexOf(edit.find);
|
||||
if (first === -1) {
|
||||
throw new Error(
|
||||
`ins: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
const second = current.indexOf(edit.find, first + 1);
|
||||
if (second !== -1) {
|
||||
throw new Error(
|
||||
`ins: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
|
||||
`use a longer substring that uniquely identifies the position. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
// Replace from start of `find` to end-of-line with `to` (vim-insert style).
|
||||
const next = current.slice(0, first) + edit.to;
|
||||
const newLines = next.includes("\n") ? next.split("\n") : [next];
|
||||
fileLines.splice(idx, 1, ...newLines);
|
||||
trackFirstChanged(edit.pos.line);
|
||||
break;
|
||||
}
|
||||
case "append_file": {
|
||||
if (edit.lines.length === 0) break;
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...edit.lines);
|
||||
trackFirstChanged(1);
|
||||
} else {
|
||||
fileLines.splice(fileLines.length, 0, ...edit.lines);
|
||||
trackFirstChanged(fileLines.length - edit.lines.length + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "prepend_file": {
|
||||
if (edit.lines.length === 0) break;
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...edit.lines);
|
||||
} else {
|
||||
fileLines.splice(0, 0, ...edit.lines);
|
||||
}
|
||||
trackFirstChanged(1);
|
||||
break;
|
||||
}
|
||||
const second = current.indexOf(edit.find, first + 1);
|
||||
if (second !== -1) {
|
||||
throw new Error(
|
||||
`${edit.op}: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
|
||||
`use a longer substring that uniquely identifies the ${edit.op === "sub" ? "target" : "position"}. ` +
|
||||
`Current line content: ${JSON.stringify(current)}`,
|
||||
);
|
||||
}
|
||||
if (edit.op === "sub") {
|
||||
return current.slice(0, first) + edit.to + current.slice(first + edit.find.length);
|
||||
}
|
||||
// `ins`: replace from start of `find` to end-of-line (vim-insert style).
|
||||
return current.slice(0, first) + edit.to;
|
||||
}
|
||||
|
||||
function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
|
||||
@@ -439,21 +338,127 @@ export function applyAtomEdits(
|
||||
validateNoConflictingAnchorOps(edits);
|
||||
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
||||
|
||||
const annotated = edits
|
||||
.map((edit, idx) => {
|
||||
const { sortLine, precedence } = getAtomEditSortKey(edit, fileLines.length);
|
||||
return { edit, idx, sortLine, precedence };
|
||||
})
|
||||
.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
||||
|
||||
const trackFirstChanged = (line: number) => {
|
||||
if (firstChangedLine === undefined || line < firstChangedLine) {
|
||||
firstChangedLine = line;
|
||||
}
|
||||
};
|
||||
|
||||
for (const { edit } of annotated) {
|
||||
applyAtomEditToLines(edit, fileLines, trackFirstChanged);
|
||||
// Partition: anchor-scoped vs file-scoped. Preserve original order via the
|
||||
// captured idx so multiple before/after/append/prepend on the same target
|
||||
// are emitted in the order the model produced them.
|
||||
type Indexed<T> = { edit: T; idx: number };
|
||||
const anchorEdits: Indexed<Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>>[] = [];
|
||||
const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
|
||||
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
||||
edits.forEach((edit, idx) => {
|
||||
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
||||
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
||||
else anchorEdits.push({ edit, idx });
|
||||
});
|
||||
|
||||
// Group anchor edits by line so all ops on the same line are applied as a
|
||||
// single splice. This makes the per-anchor outcome independent of index
|
||||
// shifts caused by sibling ops (e.g. `after` paired with `del` on the same
|
||||
// anchor, or repeated `before`/`after` inserts that previously reversed).
|
||||
const byLine = new Map<number, Indexed<(typeof anchorEdits)[number]["edit"]>[]>();
|
||||
for (const entry of anchorEdits) {
|
||||
const line = entry.edit.pos.line;
|
||||
let bucket = byLine.get(line);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
byLine.set(line, bucket);
|
||||
}
|
||||
bucket.push(entry);
|
||||
}
|
||||
|
||||
// Process anchor groups bottom-up so earlier-line edits aren't perturbed.
|
||||
const sortedLines = [...byLine.keys()].sort((a, b) => b - a);
|
||||
for (const line of sortedLines) {
|
||||
const bucket = byLine.get(line)!;
|
||||
bucket.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
const idx = line - 1;
|
||||
let currentLine = fileLines[idx];
|
||||
let replacement: string[] = [currentLine];
|
||||
let replacementSet = false;
|
||||
let anchorMutated = false;
|
||||
let anchorDeleted = false;
|
||||
const beforeLines: string[] = [];
|
||||
const afterLines: string[] = [];
|
||||
|
||||
for (const { edit } of bucket) {
|
||||
switch (edit.op) {
|
||||
case "before":
|
||||
beforeLines.push(...edit.lines);
|
||||
break;
|
||||
case "after":
|
||||
afterLines.push(...edit.lines);
|
||||
break;
|
||||
case "del":
|
||||
replacement = [];
|
||||
replacementSet = true;
|
||||
anchorDeleted = true;
|
||||
break;
|
||||
case "set":
|
||||
replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
|
||||
replacementSet = true;
|
||||
anchorMutated = true;
|
||||
break;
|
||||
case "sub":
|
||||
case "ins": {
|
||||
currentLine = applySubInsToLine(edit, currentLine);
|
||||
replacement = currentLine.includes("\n") ? currentLine.split("\n") : [currentLine];
|
||||
replacementSet = true;
|
||||
anchorMutated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
|
||||
if (noOp) continue;
|
||||
|
||||
const combined = [...beforeLines, ...replacement, ...afterLines];
|
||||
fileLines.splice(idx, 1, ...combined);
|
||||
|
||||
if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
|
||||
trackFirstChanged(line);
|
||||
} else if (afterLines.length > 0) {
|
||||
trackFirstChanged(line + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply prepend_file ops in original order so the first one ends up at the
|
||||
// very top of the file.
|
||||
prependEdits.sort((a, b) => a.idx - b.idx);
|
||||
for (const { edit } of prependEdits) {
|
||||
if (edit.lines.length === 0) continue;
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...edit.lines);
|
||||
} else {
|
||||
// Insert in reverse cumulative order so later splices push earlier
|
||||
// content further down, preserving the original op order.
|
||||
fileLines.splice(0, 0, ...edit.lines);
|
||||
}
|
||||
trackFirstChanged(1);
|
||||
}
|
||||
|
||||
// Apply append_file ops in original order. When the file ends with a
|
||||
// trailing newline (last split element is the empty sentinel), insert
|
||||
// before that sentinel so the trailing newline is preserved.
|
||||
appendEdits.sort((a, b) => a.idx - b.idx);
|
||||
for (const { edit } of appendEdits) {
|
||||
if (edit.lines.length === 0) continue;
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...edit.lines);
|
||||
trackFirstChanged(1);
|
||||
continue;
|
||||
}
|
||||
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
||||
const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
||||
fileLines.splice(insertIdx, 0, ...edit.lines);
|
||||
trackFirstChanged(insertIdx + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -552,9 +552,11 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget {
|
||||
|
||||
export const chunkToolEditSchema = Type.Object(
|
||||
{
|
||||
path: Type.String({
|
||||
description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#thth~', 'src/app.ts:class_Bar'.",
|
||||
}),
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#thth~', 'src/app.ts:class_Bar'.",
|
||||
}),
|
||||
),
|
||||
write: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Null()], {
|
||||
description:
|
||||
@@ -602,22 +604,6 @@ export interface ExecuteChunkSingleOptions {
|
||||
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
||||
}
|
||||
|
||||
export function isChunkParams(params: unknown): params is ChunkParams {
|
||||
if (
|
||||
typeof params !== "object" ||
|
||||
params === null ||
|
||||
!("edits" in params) ||
|
||||
!Array.isArray(params.edits) ||
|
||||
params.edits.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const first = params.edits[0];
|
||||
// Accept a bare `{ path }` entry so the executor can return a targeted
|
||||
// "missing operation" error instead of the generic schema failure.
|
||||
return typeof first === "object" && first !== null && "path" in first;
|
||||
}
|
||||
|
||||
/** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on.
|
||||
* Handles two patterns:
|
||||
* 1. Tab-based over-indentation: models include the function's base \t indent.
|
||||
|
||||
@@ -43,12 +43,14 @@ export type HashlineEdit =
|
||||
| { op: "append_file"; lines: string[] }
|
||||
| { op: "prepend_file"; lines: string[] };
|
||||
|
||||
// Tight prefix matchers. The bare `#BIGRAM:` form (no line number) intentionally
|
||||
// disallows whitespace between `#` and the bigram so real comments like `# th: ...`
|
||||
// or `# in: ...` (a `#`, a space, then a common English bigram) are not mistaken
|
||||
// for hashline anchors and stripped.
|
||||
const HASHLINE_PREFIX_RE = new RegExp(
|
||||
`^\\s*(?:>>>|>>)?\\s*(?:\\+?\\s*(?:\\d+\\s*#\\s*|#\\s*)|\\+)\\s*${HASHLINE_BIGRAM_RE_SRC}:`,
|
||||
);
|
||||
const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
||||
`^\\s*(?:>>>|>>)?\\s*\\+\\s*(?:\\d+\\s*#\\s*|#\\s*)?${HASHLINE_BIGRAM_RE_SRC}:`,
|
||||
`^\\s*(?:>>>|>>)?\\s*(?:\\+?\\s*\\d+\\s*#\\s*|\\+?#|\\+\\s*)${HASHLINE_BIGRAM_RE_SRC}:`,
|
||||
);
|
||||
const HASHLINE_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*(?:\\d+\\s*#\\s*|#)?${HASHLINE_BIGRAM_RE_SRC}:`);
|
||||
const DIFF_PLUS_RE = /^[+](?![+])/;
|
||||
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
|
||||
|
||||
@@ -188,15 +190,6 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
|
||||
return stripNewLinePrefixes(edit);
|
||||
}
|
||||
|
||||
export function isHashlineParams(params: unknown): params is HashlineParams {
|
||||
if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits))
|
||||
return false;
|
||||
if (params.edits.length === 0) return true;
|
||||
const first = params.edits[0];
|
||||
if (typeof first !== "object" || first === null) return false;
|
||||
return "loc" in first;
|
||||
}
|
||||
|
||||
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
||||
return edits.map(resolveEditAnchor);
|
||||
}
|
||||
|
||||
@@ -1606,14 +1606,6 @@ export interface ExecutePatchSingleOptions {
|
||||
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
||||
}
|
||||
|
||||
export function isPatchParams(params: unknown): params is PatchParams {
|
||||
if (typeof params !== "object" || params === null) return false;
|
||||
if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
|
||||
const first = (params as any).edits[0];
|
||||
if (!first || typeof first !== "object") return false;
|
||||
return "path" in first && !("old_text" in first) && !("new_text" in first);
|
||||
}
|
||||
|
||||
class LspFileSystem implements FileSystem {
|
||||
#lastDiagnostics: FileDiagnosticsResult | undefined;
|
||||
#fileCache: Record<string, Bun.BunFile> = {};
|
||||
|
||||
@@ -1002,13 +1002,6 @@ export interface ExecuteReplaceSingleOptions {
|
||||
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
||||
}
|
||||
|
||||
export function isReplaceParams(params: unknown): params is ReplaceParams {
|
||||
if (typeof params !== "object" || params === null) return false;
|
||||
if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
|
||||
const first = (params as any).edits[0];
|
||||
return first && typeof first === "object" && "old_text" in first && "new_text" in first;
|
||||
}
|
||||
|
||||
export async function executeReplaceSingle(
|
||||
options: ExecuteReplaceSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
|
||||
|
||||
@@ -131,6 +131,7 @@ export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: stri
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface ReplaceArgs {
|
||||
path?: string;
|
||||
edits?: ReplaceEditEntry[];
|
||||
__partialJson?: string;
|
||||
}
|
||||
@@ -142,10 +143,12 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
|
||||
},
|
||||
async computeDiffPreview(args, ctx) {
|
||||
const first = args.edits?.[0];
|
||||
if (!first?.path || first.old_text === undefined || first.new_text === undefined) return null;
|
||||
if (!first) return null;
|
||||
const path = first.path ?? args.path;
|
||||
if (!path || first.old_text === undefined || first.new_text === undefined) return null;
|
||||
ctx.signal.throwIfAborted();
|
||||
const result = await computeEditDiff(
|
||||
first.path,
|
||||
path,
|
||||
first.old_text,
|
||||
first.new_text,
|
||||
ctx.cwd,
|
||||
@@ -154,7 +157,7 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
|
||||
ctx.fuzzyThreshold,
|
||||
);
|
||||
ctx.signal.throwIfAborted();
|
||||
return [toPerFilePreview(first.path, result)];
|
||||
return [toPerFilePreview(path, result)];
|
||||
},
|
||||
renderStreamingFallback() {
|
||||
return "";
|
||||
@@ -162,6 +165,7 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
|
||||
};
|
||||
|
||||
interface PatchArgs {
|
||||
path?: string;
|
||||
edits?: PatchEditEntry[];
|
||||
__partialJson?: string;
|
||||
}
|
||||
@@ -173,15 +177,16 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
||||
},
|
||||
async computeDiffPreview(args, ctx) {
|
||||
const first = args.edits?.[0];
|
||||
if (!first?.path) return null;
|
||||
const path = first?.path ?? args.path;
|
||||
if (!path) return null;
|
||||
ctx.signal.throwIfAborted();
|
||||
const result = await computePatchDiff(
|
||||
{ path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
|
||||
{ path, op: first?.op ?? "update", rename: first?.rename, diff: first?.diff },
|
||||
ctx.cwd,
|
||||
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
||||
);
|
||||
ctx.signal.throwIfAborted();
|
||||
return [toPerFilePreview(first.path, result)];
|
||||
return [toPerFilePreview(path, result)];
|
||||
},
|
||||
renderStreamingFallback() {
|
||||
return "";
|
||||
@@ -189,6 +194,7 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
||||
};
|
||||
|
||||
interface HashlineArgs {
|
||||
path?: string;
|
||||
edits?: HashlineToolEdit[];
|
||||
__partialJson?: string;
|
||||
}
|
||||
@@ -200,11 +206,16 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
},
|
||||
async computeDiffPreview(args, ctx) {
|
||||
const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
|
||||
if (!first?.path) return null;
|
||||
const path = first.path;
|
||||
const fileEdits = (args.edits ?? []).filter((e): e is HashlineToolEdit & { path: string } => {
|
||||
return !!e && typeof e === "object" && (e as { path?: string }).path === path;
|
||||
});
|
||||
const path = first?.path ?? args.path;
|
||||
if (!path) return null;
|
||||
const fileEdits = (args.edits ?? [])
|
||||
.map(e => {
|
||||
if (!e || typeof e !== "object") return undefined;
|
||||
const entryPath = (e as { path?: string }).path ?? args.path;
|
||||
if (!entryPath || entryPath !== path) return undefined;
|
||||
return { ...(e as HashlineToolEdit), path } as HashlineToolEdit & { path: string };
|
||||
})
|
||||
.filter((e): e is HashlineToolEdit & { path: string } => e !== undefined);
|
||||
ctx.signal.throwIfAborted();
|
||||
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
|
||||
ctx.signal.throwIfAborted();
|
||||
@@ -216,6 +227,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
};
|
||||
|
||||
interface ChunkArgs {
|
||||
path?: string;
|
||||
edits?: ChunkToolEdit[];
|
||||
__partialJson?: string;
|
||||
}
|
||||
@@ -247,8 +259,10 @@ const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
|
||||
const groups = new Map<string, ChunkToolEdit[]>();
|
||||
const fileOrder: string[] = [];
|
||||
for (const edit of edits) {
|
||||
if (!edit?.path) continue;
|
||||
const { filePath } = parseChunkEditPath(edit.path);
|
||||
if (!edit) continue;
|
||||
const editPath = edit.path ?? args.path;
|
||||
if (!editPath) continue;
|
||||
const { filePath } = parseChunkEditPath(editPath);
|
||||
if (!filePath) continue;
|
||||
let bucket = groups.get(filePath);
|
||||
if (!bucket) {
|
||||
@@ -256,7 +270,7 @@ const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
|
||||
groups.set(filePath, bucket);
|
||||
fileOrder.push(filePath);
|
||||
}
|
||||
bucket.push(edit);
|
||||
bucket.push({ ...edit, path: editPath });
|
||||
}
|
||||
if (fileOrder.length === 0) return null;
|
||||
|
||||
|
||||
@@ -140,17 +140,17 @@ Only body changes; doc comment, signature, and closing `}` are preserved.
|
||||
`{ "path": "counter.rs:impl_Counte.fn_increm#ouer", "write": "/// Increments by the given step, clamping at max.\npub fn increment(&mut self, step: i32) {\n\tself.value = (self.value + step).min(self.max);\n}\n" }`
|
||||
Everything is rewritten. Omitting the doc comment or signature deletes them.
|
||||
# Write head (`^` — attributes, doc comments, signature)
|
||||
`{ "path": "counter.rs:impl_Counte.fn_get#arco^", "write": "/// Returns the current counter value.\n#[inline]\npub fn get(&self) → i32 {\n" }`
|
||||
`{ "path": "counter.rs:impl_Counte.fn_get#arco^", "write": "/// Returns the current counter value.\n#[inline]\npub fn get(&self) -> i32 {\n" }`
|
||||
Head changes (all `^` lines + opening brace); body untouched.
|
||||
# Insert before a chunk (`prepend`)
|
||||
`{ "path": "counter.rs:impl_Counte.fn_get", "insert": { "loc": "prepend", "body": "/// Resets the counter to zero.\npub fn reset(&mut self) {\n\tself.value = 0;\n}\n\n" } }`
|
||||
# Insert after a chunk (`append`)
|
||||
`{ "path": "counter.rs:struct_Counte", "insert": { "loc": "append", "body": "\nimpl Default for Counter {\n\tfn default() → Self {\n\t\tSelf { value: 0, max: 100 }\n\t}\n}\n" } }`
|
||||
`{ "path": "counter.rs:struct_Counte", "insert": { "loc": "append", "body": "\nimpl Default for Counter {\n\tfn default() -> Self {\n\t\tSelf { value: 0, max: 100 }\n\t}\n}\n" } }`
|
||||
# Insert at start of container body (`~` + `prepend`)
|
||||
`{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "prepend", "body": "/// Creates a counter starting at the given value.\npub fn with_value(value: i32, max: i32) → Self {\n\tSelf { value: value.min(max), max }\n}\n\n" } }`
|
||||
`{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "prepend", "body": "/// Creates a counter starting at the given value.\npub fn with_value(value: i32, max: i32) -> Self {\n\tSelf { value: value.min(max), max }\n}\n\n" } }`
|
||||
Lands at the top of the impl body, before existing methods.
|
||||
# Insert at end of container body (`~` + `append`)
|
||||
`{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "append", "body": "\n/// Returns true if the counter is at its maximum.\npub fn is_maxed(&self) → bool {\n\tself.value ≥ self.max\n}\n" } }`
|
||||
`{ "path": "counter.rs:impl_Counte~", "insert": { "loc": "append", "body": "\n/// Returns true if the counter is at its maximum.\npub fn is_maxed(&self) -> bool {\n\tself.value >= self.max\n}\n" } }`
|
||||
Lands at the end of the impl body, before the closing `}`.
|
||||
# Delete a chunk
|
||||
`{ "path": "counter.rs:impl_Counte.fn_decrem#arve", "delete": true }`
|
||||
|
||||
@@ -599,9 +599,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
sourceInternal?: string;
|
||||
entityLabel: string;
|
||||
ignoreResultLimits?: boolean;
|
||||
raw?: boolean;
|
||||
},
|
||||
): AgentToolResult<ReadToolDetails> {
|
||||
const displayMode = resolveFileDisplayMode(this.session);
|
||||
const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw });
|
||||
const details = options.details ?? {};
|
||||
const allLines = text.split("\n");
|
||||
const totalLines = allLines.length;
|
||||
@@ -743,6 +744,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
limit: number | undefined,
|
||||
resolvedArchivePath: ResolvedArchiveReadPath,
|
||||
signal?: AbortSignal,
|
||||
options?: { raw?: boolean },
|
||||
): Promise<AgentToolResult<ReadToolDetails>> {
|
||||
throwIfAborted(signal);
|
||||
const archive = await openArchive(resolvedArchivePath.absolutePath);
|
||||
@@ -787,6 +789,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
details,
|
||||
sourcePath: resolvedArchivePath.absolutePath,
|
||||
entityLabel: "archive entry",
|
||||
raw: options?.raw,
|
||||
});
|
||||
const firstText = result.content.find((content): content is TextContent => content.type === "text");
|
||||
if (firstText) {
|
||||
@@ -978,7 +981,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
const archivePath = await this.#resolveArchiveReadPath(localReadPath, signal);
|
||||
if (archivePath) {
|
||||
const { offset, limit } = selToOffsetLimit(parsed);
|
||||
return this.#readArchive(readPath, offset, limit, archivePath, signal);
|
||||
return this.#readArchive(readPath, offset, limit, archivePath, signal, { raw: parsed.kind === "raw" });
|
||||
}
|
||||
|
||||
const sqlitePath = await this.#resolveSqliteReadPath(readPath, signal);
|
||||
|
||||
@@ -22,18 +22,24 @@ export interface FileDisplayModeSession {
|
||||
/**
|
||||
* Computes effective line display mode from session settings/env.
|
||||
* Hashline mode takes precedence and implies line-addressed output everywhere.
|
||||
* Hashlines are suppressed when the edit tool is not available (e.g. explore agents).
|
||||
* Hashlines are suppressed when the edit tool is not available (e.g. explore agents)
|
||||
* and when the caller signals a `raw` read — raw output should be returned as-is
|
||||
* without injecting hashline anchors or line numbers.
|
||||
*/
|
||||
export function resolveFileDisplayMode(session: FileDisplayModeSession): FileDisplayMode {
|
||||
export function resolveFileDisplayMode(
|
||||
session: FileDisplayModeSession,
|
||||
options?: { raw?: boolean },
|
||||
): FileDisplayMode {
|
||||
const { settings } = session;
|
||||
const hasEditTool = session.hasEditTool ?? true;
|
||||
const editMode = resolveEditMode(session);
|
||||
const usesHashLineAnchors = editMode === "hashline" || editMode === "atom";
|
||||
const hashLines = hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
|
||||
const chunked = hasEditTool && resolveEditMode(session) === "chunk";
|
||||
const raw = options?.raw === true;
|
||||
const hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
|
||||
const chunked = !raw && hasEditTool && editMode === "chunk";
|
||||
return {
|
||||
hashLines,
|
||||
lineNumbers: hashLines || settings.get("readLineNumbers") === true,
|
||||
lineNumbers: !raw && (hashLines || settings.get("readLineNumbers") === true),
|
||||
chunked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
applyAtomEdits,
|
||||
computeLineHash,
|
||||
HashlineMismatchError,
|
||||
isAtomParams,
|
||||
resolveAtomToolEdit,
|
||||
} from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import type { Anchor } from "@oh-my-pi/pi-coding-agent/edit/modes/hashline";
|
||||
@@ -13,36 +12,6 @@ function tag(line: number, content: string): Anchor {
|
||||
return { line, hash: computeLineHash(line, content) };
|
||||
}
|
||||
|
||||
describe("isAtomParams", () => {
|
||||
it("accepts empty edits", () => {
|
||||
expect(isAtomParams({ edits: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a `set` op", () => {
|
||||
expect(isAtomParams({ edits: [{ path: "a.ts", set: "1#aa", lines: "x" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a `sub` op", () => {
|
||||
expect(isAtomParams({ edits: [{ path: "a.ts", sub: "1#aa", find: "x", lines: "y" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts file-scoped append", () => {
|
||||
expect(isAtomParams({ edits: [{ path: "a.ts", append: "z" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("defers multi-op entries to the resolver for an actionable error", () => {
|
||||
// Schema-level guard accepts; resolver throws with named keys.
|
||||
expect(isAtomParams({ edits: [{ path: "a.ts", set: "1#aa", lines: "x", del: "1#aa" }] })).toBe(true);
|
||||
expect(() => resolveAtomToolEdit({ path: "a.ts", set: "1#aa", lines: "x", del: "1#aa" } as never)).toThrow(
|
||||
/multiple op keys.*set, del/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts entries without path (resolved at dispatcher from top-level)", () => {
|
||||
expect(isAtomParams({ path: "a.ts", edits: [{ set: "1#XQ", lines: "x" }] as unknown[] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAtomEdits — set", () => {
|
||||
it("replaces a single line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
|
||||
@@ -352,8 +352,8 @@ describe("search tool path lists", () => {
|
||||
const text = getText(result);
|
||||
|
||||
expect(text).toContain("match lines use ':'; context lines use '-'");
|
||||
expect(text).toMatch(/1(?:#[A-Z0-9]+)?-#if FLAG/);
|
||||
expect(text).toMatch(/2(?:#[A-Z0-9]+)?:needle/);
|
||||
expect(text).toMatch(/3(?:#[A-Z0-9]+)?-#endif/);
|
||||
expect(text).toMatch(/1(?:#[A-Za-z0-9]+)?-#if FLAG/);
|
||||
expect(text).toMatch(/2(?:#[A-Za-z0-9]+)?:needle/);
|
||||
expect(text).toMatch(/3(?:#[A-Za-z0-9]+)?-#endif/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,11 +191,11 @@ const HASHLINE_SUBTYPES = ["set", "set_range", "insert"] as const;
|
||||
|
||||
const CHUNK_OP_SUBTYPES = ["append", "prepend", "replace", "delete"] as const;
|
||||
|
||||
const BENCHMARK_TOOL_NAMES = ["read", "edit", "vim", "write"] as const;
|
||||
const EDIT_TOOL_NAMES = ["edit", "vim"] as const;
|
||||
const BENCHMARK_TOOL_NAMES = ["read", "edit", "vim", "write", "apply_patch"] as const;
|
||||
const EDIT_TOOL_NAMES = ["edit", "vim", "apply_patch"] as const;
|
||||
|
||||
function isEditTool(toolName: unknown): toolName is (typeof EDIT_TOOL_NAMES)[number] {
|
||||
return toolName === "edit" || toolName === "vim";
|
||||
return toolName === "edit" || toolName === "vim" || toolName === "apply_patch";
|
||||
}
|
||||
|
||||
function isMutationTool(toolName: unknown): boolean {
|
||||
|
||||
Reference in New Issue
Block a user