feat(coding-agent): introduced keyed union format for chunk operations
- Changed chunk edit operation format from flat `op` field to nested keyed union structure with operation name as object key. - Added parseKeyedChunkOp() function to normalize both keyed and legacy flat operation formats for backward compatibility. - Enhanced chunk selector sanitization with filename prefix stripping and case-insensitive CRC checksum matching. - Updated chunk edit prompt documentation and test suite to reflect new nested operation JSON structure.
This commit is contained in:
@@ -1978,4 +1978,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
|
||||
## [0.9.4] - 2025-11-26
|
||||
|
||||
Initial release with multi-provider LLM support.
|
||||
Initial release with multi-provider LLM support.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `new_segment` option to `init_experiment` to force a new segment even when contract fields match, enabling re-initialization with unchanged parameters
|
||||
@@ -15,6 +14,9 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed chunk edit operation format from flat `{ "op": "splice", ... }` to keyed `{ "splice": { ... } }` format for improved clarity and schema validation
|
||||
- Updated chunk edit schema to use discriminated union types with operation-specific field requirements (insert ops no longer require CRC, mutation ops require CRC)
|
||||
- Improved chunk selector sanitization to strip filename prefixes and normalize checksums to uppercase, handling model output variations
|
||||
- Changed `log_experiment` revert behavior to only restore run-modified files instead of reverting entire working tree, preserving pre-existing uncommitted changes
|
||||
- Changed `init_experiment` error message for pending unlogged runs to include command, metric, and pass/fail status for better context
|
||||
- Changed `init_experiment` to detect when contract fields match current state and skip re-initialization (no-op) unless `new_segment=true`
|
||||
|
||||
@@ -104,45 +104,84 @@ const patchEditSchema = Type.Object({
|
||||
diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
|
||||
});
|
||||
|
||||
const CHUNK_EDIT_OPS_BASE = [
|
||||
"append_child",
|
||||
"prepend_child",
|
||||
"append_sibling",
|
||||
"prepend_sibling",
|
||||
"replace",
|
||||
"delete",
|
||||
] as const;
|
||||
|
||||
const chunkEditOperationSchemaBaseProps = {
|
||||
sel: Type.Optional(Type.String({ description: "Chunk path selector" })),
|
||||
crc: Type.Optional(Type.String({ description: "Chunk checksum for staleness validation" })),
|
||||
const chunkEditSelProp = { sel: Type.Optional(Type.String({ description: "Chunk path selector" })) } as const;
|
||||
const chunkEditCrcProp = { crc: Type.String({ description: "Chunk checksum for staleness validation" }) } as const;
|
||||
const chunkEditContentProp = {
|
||||
content: Type.Union([Type.String(), Type.Array(Type.String())], { description: "Inserted or replacement content" }),
|
||||
} as const;
|
||||
const chunkEditOptionalContentProp = {
|
||||
content: Type.Optional(
|
||||
Type.Union([Type.String(), Type.Array(Type.String())], { description: "Inserted or replacement content" }),
|
||||
Type.Union([Type.String(), Type.Array(Type.String())], {
|
||||
description: "Replacement content (empty string to delete lines)",
|
||||
}),
|
||||
),
|
||||
} as const;
|
||||
|
||||
const chunkEditOperationSchemaWithoutSplice = Type.Object({
|
||||
op: StringEnum(CHUNK_EDIT_OPS_BASE, { description: "Chunk edit operation" }),
|
||||
...chunkEditOperationSchemaBaseProps,
|
||||
});
|
||||
|
||||
const chunkEditOperationSchemaWithSplice = Type.Object({
|
||||
op: StringEnum([...CHUNK_EDIT_OPS_BASE, "splice"], { description: "Chunk edit operation" }),
|
||||
...chunkEditOperationSchemaBaseProps,
|
||||
const chunkEditBegEndProps = {
|
||||
beg: Type.Integer({
|
||||
description:
|
||||
"Start line: absolute file line (1-indexed, same as read gutter). With end, inclusive when beg ≤ end; when beg = end + 1, zero-width insertion.",
|
||||
}),
|
||||
end: Type.Integer({
|
||||
description:
|
||||
"End line: absolute file line (1-indexed). Inclusive when beg ≤ end; for zero-width insertion use end = beg − 1.",
|
||||
}),
|
||||
} as const;
|
||||
const chunkEditOptionalBegEndProps = {
|
||||
beg: Type.Optional(
|
||||
Type.Integer({
|
||||
description:
|
||||
"Start line: absolute file line (1-indexed, same as read gutter). Used by splice and replace with line range. With end, inclusive when beg \u2264 end; when beg = end + 1, zero-width insertion.",
|
||||
}),
|
||||
),
|
||||
end: Type.Optional(
|
||||
Type.Integer({
|
||||
description:
|
||||
"End line: absolute file line (1-indexed). Inclusive when beg \u2264 end; for zero-width insertion use end = beg \u2212 1.",
|
||||
"Start line for line-scoped replace. When both beg and end are provided, replaces only those file lines (same as splice).",
|
||||
}),
|
||||
),
|
||||
end: Type.Optional(Type.Integer({ description: "End line for line-scoped replace." })),
|
||||
} as const;
|
||||
|
||||
// Insert ops (no CRC)
|
||||
const appendChildOp = Type.Object({ append_child: Type.Object({ ...chunkEditSelProp, ...chunkEditContentProp }) });
|
||||
const prependChildOp = Type.Object({ prepend_child: Type.Object({ ...chunkEditSelProp, ...chunkEditContentProp }) });
|
||||
const appendSiblingOp = Type.Object({ append_sibling: Type.Object({ ...chunkEditSelProp, ...chunkEditContentProp }) });
|
||||
const prependSiblingOp = Type.Object({
|
||||
prepend_sibling: Type.Object({ ...chunkEditSelProp, ...chunkEditContentProp }),
|
||||
});
|
||||
|
||||
// Mutation ops (CRC required)
|
||||
const replaceOp = Type.Object({
|
||||
replace: Type.Object({
|
||||
...chunkEditSelProp,
|
||||
...chunkEditCrcProp,
|
||||
...chunkEditContentProp,
|
||||
...chunkEditOptionalBegEndProps,
|
||||
}),
|
||||
});
|
||||
const deleteOp = Type.Object({ delete: Type.Object({ ...chunkEditSelProp, ...chunkEditCrcProp }) });
|
||||
const spliceOp = Type.Object({
|
||||
splice: Type.Object({
|
||||
...chunkEditSelProp,
|
||||
...chunkEditCrcProp,
|
||||
...chunkEditBegEndProps,
|
||||
...chunkEditOptionalContentProp,
|
||||
}),
|
||||
});
|
||||
|
||||
const chunkEditOperationSchemaWithoutSplice = Type.Union([
|
||||
appendChildOp,
|
||||
prependChildOp,
|
||||
appendSiblingOp,
|
||||
prependSiblingOp,
|
||||
replaceOp,
|
||||
deleteOp,
|
||||
]);
|
||||
|
||||
const chunkEditOperationSchemaWithSplice = Type.Union([
|
||||
appendChildOp,
|
||||
prependChildOp,
|
||||
appendSiblingOp,
|
||||
prependSiblingOp,
|
||||
replaceOp,
|
||||
deleteOp,
|
||||
spliceOp,
|
||||
]);
|
||||
|
||||
const chunkEditOperationSchema = chunkSplicesEnabled()
|
||||
? chunkEditOperationSchemaWithSplice
|
||||
: chunkEditOperationSchemaWithoutSplice;
|
||||
@@ -382,6 +421,44 @@ function isChunkParams(params: ReplaceParams | PatchParams | HashlineParams | Ch
|
||||
return "operations" in params;
|
||||
}
|
||||
|
||||
const CHUNK_OP_KEYS = [
|
||||
"append_child",
|
||||
"prepend_child",
|
||||
"append_sibling",
|
||||
"prepend_sibling",
|
||||
"replace",
|
||||
"delete",
|
||||
"splice",
|
||||
] as const;
|
||||
type ChunkOpKey = (typeof CHUNK_OP_KEYS)[number];
|
||||
|
||||
interface ParsedChunkOp {
|
||||
op: ChunkOpKey;
|
||||
sel?: string;
|
||||
crc?: string;
|
||||
content?: string | string[];
|
||||
beg?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
/** Normalize keyed `{ splice: { ... } }` format to flat internal format. Also tolerates legacy `{ op: "splice", ... }`. */
|
||||
function parseKeyedChunkOp(raw: Record<string, unknown>): ParsedChunkOp {
|
||||
// Legacy flat format: { op: "splice", sel: "...", ... }
|
||||
if (typeof raw.op === "string" && CHUNK_OP_KEYS.includes(raw.op as ChunkOpKey)) {
|
||||
return raw as unknown as ParsedChunkOp;
|
||||
}
|
||||
|
||||
// Keyed format: { splice: { sel: "...", ... } }
|
||||
for (const key of CHUNK_OP_KEYS) {
|
||||
if (key in raw && typeof raw[key] === "object" && raw[key] !== null) {
|
||||
const inner = raw[key] as Record<string, unknown>;
|
||||
return { op: key, ...inner } as unknown as ParsedChunkOp;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown chunk edit operation shape: ${JSON.stringify(Object.keys(raw))}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit tool implementation.
|
||||
*
|
||||
@@ -535,9 +612,10 @@ export class EditTool implements AgentTool<TInput> {
|
||||
: undefined;
|
||||
|
||||
const normalizedOperations: ChunkEditOperation[] = operations.map(operation => {
|
||||
const anchorSelector = operation.sel ?? defaultSelector;
|
||||
const anchorCrc = operation.crc ?? (operation.sel === undefined ? defaultCrc : undefined);
|
||||
const content = normalizeChunkContentInput(operation.content);
|
||||
const opEntry = parseKeyedChunkOp(operation as Record<string, unknown>);
|
||||
const anchorSelector = opEntry.sel ?? defaultSelector;
|
||||
const anchorCrc = opEntry.crc ?? (opEntry.sel === undefined ? defaultCrc : undefined);
|
||||
const content = normalizeChunkContentInput(opEntry.content);
|
||||
|
||||
const requireChecksum = (op: string): void => {
|
||||
if (anchorCrc) return;
|
||||
@@ -559,31 +637,31 @@ export class EditTool implements AgentTool<TInput> {
|
||||
);
|
||||
};
|
||||
|
||||
switch (operation.op) {
|
||||
switch (opEntry.op) {
|
||||
case "append_child":
|
||||
case "prepend_child":
|
||||
case "append_sibling":
|
||||
case "prepend_sibling":
|
||||
if (!content) {
|
||||
throw new Error(`Content required for ${operation.op} on ${anchorSelector ?? "<root>"}`);
|
||||
throw new Error(`Content required for ${opEntry.op} on ${anchorSelector ?? "<root>"}`);
|
||||
}
|
||||
return { op: operation.op, sel: operation.sel, crc: operation.crc, content } as ChunkEditOperation;
|
||||
return { op: opEntry.op, sel: opEntry.sel, crc: opEntry.crc, content } as ChunkEditOperation;
|
||||
case "replace":
|
||||
requireChecksum("replace");
|
||||
if (content.length === 0) {
|
||||
return { op: "delete", sel: operation.sel, crc: anchorCrc } as ChunkEditOperation;
|
||||
return { op: "delete", sel: opEntry.sel, crc: anchorCrc } as ChunkEditOperation;
|
||||
}
|
||||
return {
|
||||
op: "replace",
|
||||
sel: operation.sel,
|
||||
sel: opEntry.sel,
|
||||
crc: anchorCrc,
|
||||
content,
|
||||
beg: (operation as { beg?: number }).beg,
|
||||
end: (operation as { end?: number }).end,
|
||||
beg: opEntry.beg,
|
||||
end: opEntry.end,
|
||||
} as ChunkEditOperation;
|
||||
case "delete":
|
||||
requireChecksum("delete");
|
||||
return { op: "delete", sel: operation.sel, crc: anchorCrc } as ChunkEditOperation;
|
||||
return { op: "delete", sel: opEntry.sel, crc: anchorCrc } as ChunkEditOperation;
|
||||
case "splice":
|
||||
if (!chunkSplicesEnabled()) {
|
||||
throw new Error(
|
||||
@@ -591,19 +669,19 @@ export class EditTool implements AgentTool<TInput> {
|
||||
);
|
||||
}
|
||||
requireChecksum("splice");
|
||||
if (operation.beg == null || operation.end == null) {
|
||||
if (opEntry.beg == null || opEntry.end == null) {
|
||||
throw new Error(`beg and end required for splice on ${anchorSelector}`);
|
||||
}
|
||||
return {
|
||||
op: "splice",
|
||||
sel: operation.sel,
|
||||
sel: opEntry.sel,
|
||||
crc: anchorCrc,
|
||||
beg: operation.beg,
|
||||
end: operation.end,
|
||||
beg: opEntry.beg,
|
||||
end: opEntry.end,
|
||||
content,
|
||||
} as ChunkEditOperation;
|
||||
default:
|
||||
throw new Error(`Unknown chunk edit operation: ${(operation as { op: string }).op}`);
|
||||
throw new Error(`Unknown chunk edit operation: ${opEntry.op}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Read the file first with `read(path="file.ts")`. Use chunk paths exactly as show
|
||||
|
||||
Successful edit responses already include the updated root chunk rendering for that file. Do not immediately re-read the file just to refresh checksums unless the file may have changed externally.
|
||||
|
||||
**Checksum scope:** Each chunk has its own CRC over that chunk’s source span. Editing non-overlapping lines elsewhere in the file does not change unrelated chunks’ checksums. There is no separate whole-file guard.
|
||||
**Checksum scope:** Each chunk has its own CRC over that chunk's source span. Editing non-overlapping lines elsewhere in the file does not change unrelated chunks' checksums. There is no separate whole-file guard.
|
||||
|
||||
<operations>
|
||||
{{#if chunkSplices}}
|
||||
@@ -15,43 +15,42 @@ Successful edit responses already include the updated root chunk rendering for t
|
||||
- `splice` is almost always the right choice for small, targeted fixes
|
||||
- `replace` with `beg`/`end` also works for line-level fixes (same behavior as `splice`)
|
||||
{{/if}}
|
||||
|op|effect|
|
||||
|---|---|
|
||||
|`append_child`|insert as last child inside a container (before closing delimiter)|
|
||||
|`prepend_child`|insert as first child inside a container (after opening delimiter)|
|
||||
|`append_sibling`|insert after the selected chunk|
|
||||
|`prepend_sibling`|insert before the selected chunk|
|
||||
|`replace`|without `beg`/`end`: rewrite the **entire** chunk from first line to last line; supply full replacement content. With `beg`/`end`: replace only those **file lines** within the chunk (same as `splice` but semantically a replacement). `splice` is almost always the right choice for small, targeted fixes|
|
||||
|`delete`|remove the entire chunk|
|
||||
|operation|format|effect|
|
||||
|---|---|---|
|
||||
|`append_child`|`{ "append_child": { "sel": "…", "content": "…" } }`|insert as last child inside a container (before closing delimiter)|
|
||||
|`prepend_child`|`{ "prepend_child": { "sel": "…", "content": "…" } }`|insert as first child inside a container (after opening delimiter)|
|
||||
|`append_sibling`|`{ "append_sibling": { "sel": "…", "content": "…" } }`|insert after the selected chunk|
|
||||
|`prepend_sibling`|`{ "prepend_sibling": { "sel": "…", "content": "…" } }`|insert before the selected chunk|
|
||||
|`replace`|`{ "replace": { "sel": "…", "crc": "…", "content": "…" } }`|without `beg`/`end`: rewrite the **entire** chunk from first line to last line; supply full replacement content. With `beg`/`end`: replace only those **file lines** within the chunk (same as `splice` but semantically a replacement). `splice` is almost always the right choice for small, targeted fixes|
|
||||
|`delete`|`{ "delete": { "sel": "…", "crc": "…" } }`|remove the entire chunk|
|
||||
{{#if chunkSplices}}
|
||||
|`splice`|when `beg` ≤ `end`, replace **file** lines `beg`–`end` (inclusive) within the selected chunk; empty `content` deletes them. When `beg` = `end` + 1, **zero-width splice**: insert in the gap after file line `end` and before file line `beg` (no lines removed)|
|
||||
|`splice`|`{ "splice": { "sel": "…", "crc": "…", "beg": N, "end": N } }`|when `beg` ≤ `end`, replace **file** lines `beg`–`end` (inclusive) within the selected chunk; empty `content` deletes them. When `beg` = `end` + 1, **zero-width splice**: insert in the gap after file line `end` and before file line `beg` (no lines removed)|
|
||||
{{/if}}
|
||||
- `op` is a single keyword only (e.g. `"splice"`). Do not put other fields like `sel` inside `op`.
|
||||
- `sel` is the chunk path (a separate field from `op`)
|
||||
- `sel` is the chunk path
|
||||
- `crc` is the chunk checksum used for staleness validation on `replace`, `delete`{{#if chunkSplices}}, and `splice`{{/if}}; it guards the selected chunk, not unrelated file changes elsewhere in the file
|
||||
{{#if chunkSplices}}
|
||||
- `beg`/`end` are **absolute file line numbers** (same as the gutter in `read`). They must fall within the selected chunk’s file line span (or form a valid zero-width gap on its boundary).
|
||||
- `beg`/`end` are **absolute file line numbers** (same as the gutter in `read`). They must fall within the selected chunk's file line span (or form a valid zero-width gap on its boundary).
|
||||
{{/if}}
|
||||
- `path="file.ts:chunk_path"` sets a default `sel` for operations that omit it
|
||||
- top-level `crc` sets the default checksum for the path-level selector
|
||||
- **Auto-indent (flat):** The tool adds the target insertion column’s base indent to every **non-empty** line of your `content`. It does not parse or understand block nesting; put any deeper relative indentation in the `content` yourself (extra leading spaces/tabs on inner lines).
|
||||
- **Auto-indent (flat):** The tool adds the target insertion column's base indent to every **non-empty** line of your `content`. It does not parse or understand block nesting; put any deeper relative indentation in the `content` yourself (extra leading spaces/tabs on inner lines).
|
||||
- Chunk paths are always fully qualified: `class_Server.fn_start`, not bare `fn_start`
|
||||
- Batch ops observe earlier edits in the same request. If op 1 changes a chunk’s checksum, span, or path, op 2 must use the **post-op-1** checksum/span/path.
|
||||
- Batch ops observe earlier edits in the same request. If op 1 changes a chunk's checksum, span, or path, op 2 must use the **post-op-1** checksum/span/path.
|
||||
- `replace`/`delete` operate on the selected chunk range, including leading comments or attributes that the parser attached to that chunk.
|
||||
</operations>
|
||||
|
||||
{{#if chunkSplices}}
|
||||
<splice>
|
||||
**Zero-width `splice` (insert only, `beg` = `end` + 1), using file line numbers `S` = chunk’s first line, `E` = chunk’s last line:**
|
||||
**Zero-width `splice` (insert only, `beg` = `end` + 1), using file line numbers `S` = chunk's first line, `E` = chunk's last line:**
|
||||
|
||||
Use zero-width splice for separator gaps between neighboring chunks. Gap edits are independent from either chunk’s checksum: inserting into the gap does not update the following chunk’s `crc`, and later `replace`/`delete` operations only affect that chunk’s own selected range.
|
||||
Use zero-width splice for separator gaps between neighboring chunks. Gap edits are independent from either chunk's checksum: inserting into the gap does not update the following chunk's `crc`, and later `replace`/`delete` operations only affect that chunk's own selected range.
|
||||
|
||||
|Goal|`beg`|`end`|
|
||||
|---|---|---|
|
||||
|Insert before file line L|L|L − 1|
|
||||
|Insert after file line L|L + 1|L|
|
||||
|Insert before the chunk’s first line S|S|S − 1|
|
||||
|Insert after the chunk’s last line E|E + 1|E|
|
||||
|Insert before the chunk's first line S|S|S − 1|
|
||||
|Insert after the chunk's last line E|E + 1|E|
|
||||
</splice>
|
||||
{{/if}}
|
||||
|
||||
@@ -97,10 +96,11 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
"path": "server.ts",
|
||||
"operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"content": "start(): void {\n log(\"starting\");\n this.tryBind();\n}"
|
||||
"replace": {
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"content": "start(): void {\n log(\"starting\");\n this.tryBind();\n}"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -112,12 +112,13 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
"path": "server.ts",
|
||||
"operations": [
|
||||
{
|
||||
"op": "splice",
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"beg": 13,
|
||||
"end": 13,
|
||||
"content": " warn(\"booting on \" + this.port);"
|
||||
"splice": {
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"beg": 13,
|
||||
"end": 13,
|
||||
"content": " warn(\"booting on \" + this.port);"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -128,12 +129,13 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
"path": "server.ts",
|
||||
"operations": [
|
||||
{
|
||||
"op": "splice",
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"beg": 13,
|
||||
"end": 12,
|
||||
"content": "const startedAt = Date.now();"
|
||||
"splice": {
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"beg": 13,
|
||||
"end": 12,
|
||||
"content": "const startedAt = Date.now();"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -144,9 +146,10 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
"path": "server.ts",
|
||||
"operations": [
|
||||
{
|
||||
"op": "delete",
|
||||
"sel": "class_Server.fn_tryBind",
|
||||
"crc": "VNWR"
|
||||
"delete": {
|
||||
"sel": "class_Server.fn_tryBind",
|
||||
"crc": "VNWR"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -158,17 +161,19 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
"path": "server.ts",
|
||||
"operations": [
|
||||
{
|
||||
"op": "splice",
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"beg": 13,
|
||||
"end": 13,
|
||||
"content": " warn(\"booting on \" + this.port);"
|
||||
"splice": {
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"beg": 13,
|
||||
"end": 13,
|
||||
"content": " warn(\"booting on \" + this.port);"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "delete",
|
||||
"sel": "class_Server.fn_tryBind",
|
||||
"crc": "VNWR"
|
||||
"delete": {
|
||||
"sel": "class_Server.fn_tryBind",
|
||||
"crc": "VNWR"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -179,15 +184,17 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
"path": "server.ts",
|
||||
"operations": [
|
||||
{
|
||||
"op": "replace",
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"content": "start(): void {\n warn(\"booting on \" + this.port);\n for (let i = 0; i < MAX_RETRIES; i++) {\n this.tryBind();\n }\n}"
|
||||
"replace": {
|
||||
"sel": "class_Server.fn_start",
|
||||
"crc": "HTST",
|
||||
"content": "start(): void {\n warn(\"booting on \" + this.port);\n for (let i = 0; i < MAX_RETRIES; i++) {\n this.tryBind();\n }\n}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "delete",
|
||||
"sel": "class_Server.fn_tryBind",
|
||||
"crc": "VNWR"
|
||||
"delete": {
|
||||
"sel": "class_Server.fn_tryBind",
|
||||
"crc": "VNWR"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -205,8 +212,8 @@ All examples reference this `read` output (`beg`/`end` match the gutter):
|
||||
{{#if chunkSplices}}
|
||||
- For `splice`, copy `beg`/`end` from the **file line numbers** in `read` output (the same values as elision `sel=L…` ranges).
|
||||
- **Do NOT batch multiple `splice` operations on the same chunk** in one edit call. Each splice changes the chunk's checksum, and you cannot predict the new checksum. Instead: either combine the changes into a single splice with a wider `beg`/`end` range covering all affected lines, or make separate edit calls (one splice per call).
|
||||
- Each `splice` operation requires its own `crc` field (not just a top-level `crc`). Put `"crc": "XXXX"` inside the operation object.
|
||||
{{/if}}
|
||||
- When restoring a deleted statement, check surrounding lines carefully — insert at the exact position relative to existing code. Do not duplicate adjacent lines.
|
||||
- Use only chunk paths that appear in the `read` output. If you need deeper chunks, `read` the parent chunk first to discover its children.
|
||||
</critical>
|
||||
</output>
|
||||
|
||||
@@ -725,6 +725,7 @@ function resolveAnchorChunk(state: MutableChunkState, selector: string | undefin
|
||||
|
||||
/**
|
||||
* Normalize a chunk selector from model output:
|
||||
* - Strip `filename.ext:` prefix (model puts `path:chunk` format in `sel`)
|
||||
* - Strip leading `:` (model artifact)
|
||||
* - Strip trailing `#XXXX` hash suffix (model confuses checksum with path)
|
||||
* - Convert literal `"null"` string to empty
|
||||
@@ -733,10 +734,14 @@ function sanitizeChunkSelector(selector: string | undefined): string | undefined
|
||||
if (selector === undefined || selector === null) return undefined;
|
||||
let s = typeof selector === "string" ? selector : String(selector);
|
||||
if (s === "null" || s === "undefined") return undefined;
|
||||
// Strip filename prefix: "pirate.ts:expression_2" -> "expression_2"
|
||||
// Models sometimes put the path:chunk format in the sel field instead of path.
|
||||
const colonIdx = chunkReadPathSeparatorIndex(s);
|
||||
if (colonIdx !== -1) s = s.slice(colonIdx + 1);
|
||||
// Strip leading colon (":error" -> "error")
|
||||
if (s.startsWith(":")) s = s.slice(1);
|
||||
// Strip trailing #XXXX checksum suffix ("imports#YKXY" -> "imports")
|
||||
s = s.replace(/#[A-Z]{4}$/, "");
|
||||
s = s.replace(/#[A-Z]{4}$/i, "");
|
||||
return s.trim() || undefined;
|
||||
}
|
||||
|
||||
@@ -754,11 +759,12 @@ function resolveAnchorWithCrc(
|
||||
* Normalize a CRC value from model output:
|
||||
* - Convert literal `"null"` / `"undefined"` strings to undefined
|
||||
* - Treat empty strings as undefined
|
||||
* - Normalize to uppercase (models sometimes emit lowercase checksums)
|
||||
*/
|
||||
function sanitizeCrc(crc: string | undefined): string | undefined {
|
||||
if (crc === undefined || crc === null) return undefined;
|
||||
if (crc === "null" || crc === "undefined" || crc === "") return undefined;
|
||||
return crc;
|
||||
return crc.toUpperCase();
|
||||
}
|
||||
|
||||
function validateCrc(chunk: ChunkNode, crc: string | undefined): void {
|
||||
|
||||
@@ -208,14 +208,15 @@ describe("chunk mode tools", () => {
|
||||
crc: checksum,
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
content: ` private handleError(err: Error): string {
|
||||
replace: {
|
||||
content: ` private handleError(err: Error): string {
|
||||
return \`normalized:\${err.message}\`;
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} as never);
|
||||
const editText = getText(editResult);
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
|
||||
@@ -256,20 +257,22 @@ describe("chunk mode tools", () => {
|
||||
crc: checksum,
|
||||
operations: [
|
||||
{
|
||||
op: "splice",
|
||||
beg: 63,
|
||||
end: 63,
|
||||
content: "return err.message.toUpperCase() + total;",
|
||||
splice: {
|
||||
beg: 63,
|
||||
end: 63,
|
||||
content: "return err.message.toUpperCase() + total;",
|
||||
},
|
||||
},
|
||||
{
|
||||
op: "splice",
|
||||
crc: checksum2,
|
||||
beg: 3,
|
||||
end: 3,
|
||||
content: "let total = 1;",
|
||||
splice: {
|
||||
crc: checksum2,
|
||||
beg: 3,
|
||||
end: 3,
|
||||
content: "let total = 1;",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} as never);
|
||||
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
expect(updatedSource).toContain("let total = 1;");
|
||||
@@ -306,9 +309,10 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "append_child",
|
||||
sel: "class_Server",
|
||||
content: 'status(): string {\n return "ok";\n}\n',
|
||||
append_child: {
|
||||
sel: "class_Server",
|
||||
content: 'status(): string {\n return "ok";\n}\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
@@ -350,20 +354,22 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "splice",
|
||||
sel: chunkPath,
|
||||
crc: checksum,
|
||||
beg: 4,
|
||||
end: 3,
|
||||
content: "const end = Date.now();",
|
||||
splice: {
|
||||
sel: chunkPath,
|
||||
crc: checksum,
|
||||
beg: 4,
|
||||
end: 3,
|
||||
content: "const end = Date.now();",
|
||||
},
|
||||
},
|
||||
{
|
||||
op: "splice",
|
||||
sel: chunkPath,
|
||||
crc: checksum2,
|
||||
beg: 3,
|
||||
end: 2,
|
||||
content: "const start = Date.now();",
|
||||
splice: {
|
||||
sel: chunkPath,
|
||||
crc: checksum2,
|
||||
beg: 3,
|
||||
end: 2,
|
||||
content: "const start = Date.now();",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
@@ -386,10 +392,11 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
sel: "fn_main",
|
||||
crc: checksum,
|
||||
content: "",
|
||||
replace: {
|
||||
sel: "fn_main",
|
||||
crc: checksum,
|
||||
content: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -427,12 +434,13 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "splice",
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
beg: 5,
|
||||
end: 2,
|
||||
content: " let total = 1;",
|
||||
splice: {
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
beg: 5,
|
||||
end: 2,
|
||||
content: " let total = 1;",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -453,14 +461,16 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "append_child",
|
||||
sel: "class_Server",
|
||||
content: ' status(): string {\n return "ok";\n }',
|
||||
append_child: {
|
||||
sel: "class_Server",
|
||||
content: ' status(): string {\n return "ok";\n }',
|
||||
},
|
||||
},
|
||||
{
|
||||
op: "delete",
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: "ZZZZ",
|
||||
delete: {
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: "ZZZZ",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -482,10 +492,11 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
content: " private handleError(err: Error): string {\n if (err) {\n",
|
||||
replace: {
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
content: " private handleError(err: Error): string {\n if (err) {\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -507,18 +518,20 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
content: " private handleError(err: Error): string {\n return err.message;\n }",
|
||||
replace: {
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
content: " private handleError(err: Error): string {\n return err.message;\n }",
|
||||
},
|
||||
},
|
||||
{
|
||||
op: "splice",
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
beg: 63,
|
||||
end: 63,
|
||||
content: " return err.message.toUpperCase();",
|
||||
splice: {
|
||||
sel: "class_Server.fn_handleError",
|
||||
crc: checksum,
|
||||
beg: 63,
|
||||
end: 63,
|
||||
content: " return err.message.toUpperCase();",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -541,13 +554,14 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "splice",
|
||||
sel: "class_Server.fn_handleError",
|
||||
// no crc!
|
||||
beg: 3,
|
||||
end: 3,
|
||||
content: " let total = 1;",
|
||||
},
|
||||
splice: {
|
||||
sel: "class_Server.fn_handleError",
|
||||
// no crc!
|
||||
beg: 3,
|
||||
end: 3,
|
||||
content: " let total = 1;",
|
||||
},
|
||||
} as never,
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/Checksum required/);
|
||||
@@ -567,10 +581,11 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
sel: "main",
|
||||
crc: checksum,
|
||||
content: 'function main(): void {\n console.log("started");\n}\n',
|
||||
replace: {
|
||||
sel: "main",
|
||||
crc: checksum,
|
||||
content: 'function main(): void {\n console.log("started");\n}\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user