diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e8a08d0a3..4b210273f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Removed the `href`, `hrefr`, and `hline` Handlebars prompt helpers along with the shared hashline anchor state; none were referenced by any built-in or user prompt template + ## [15.5.0] - 2026-05-26 ### Added diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index 7daa327de..e98357d7d 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -8,7 +8,6 @@ import { parseFrontmatter, prompt, } from "@oh-my-pi/pi-utils"; -import { HL_LINE_BODY_SEP } from "../hashline/hash"; import { jtdToTypeScript } from "../tools/jtd-to-typescript"; import { parseCommandArgs, substituteArgs } from "../utils/command-args"; @@ -30,130 +29,6 @@ prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => { } }); -function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } { - const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10); - const raw = typeof content === "string" ? content : String(content ?? ""); - const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r"); - const ref = `${num}`; - return { num, text, ref }; -} - -interface HashlineHelperRef { - line: number; - ref: string; -} - -interface HashlineHelperState { - last?: HashlineHelperRef; - byLine: Map; -} - -const HL_HELPER_STATE = Symbol("hashlineHelperState"); - -interface HashlineHelperStateHolder { - [HL_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[HL_HELPER_STATE]) { - holder[HL_HELPER_STATE] = { byLine: new Map() }; - } - return holder[HL_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 hashline line ref for prompt examples. - * {{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 `"lineNum"` (e.g., `"42"`), or `"[42]"` when pre/post are supplied. - */ -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 `"lineNum:content"` (colon between line number and 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}${HL_LINE_BODY_SEP}${text}`; -}); - const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/; const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/; diff --git a/packages/coding-agent/src/hashline/executor.ts b/packages/coding-agent/src/hashline/executor.ts index 4c6849762..2c4ca1ea1 100644 --- a/packages/coding-agent/src/hashline/executor.ts +++ b/packages/coding-agent/src/hashline/executor.ts @@ -30,7 +30,6 @@ type PendingOp = interface Pending { op: PendingOp; payload: string[]; - pendingBlanks: number; } /** @@ -81,16 +80,16 @@ export class HashlineExecutor { this.#terminated = true; return; case "header": - this.#flushPending(false); + this.#flushPending(); return; case "blank": - if (this.#pending) this.#pending.pendingBlanks++; + if (this.#pending) this.#pending.payload.push(""); return; case "payload": this.#handlePayload(token.text, token.lineNum); return; case "op-delete": - this.#flushPending(false); + this.#flushPending(); if (token.trailingPayload) { throw new Error( `line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`, @@ -102,32 +101,34 @@ export class HashlineExecutor { } return; case "op-insert": - this.#flushPending(false); + this.#flushPending(); this.#pending = { op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum }, payload: [token.inlineBody ?? ""], - pendingBlanks: 0, }; return; case "op-replace": - this.#flushPending(false); + this.#flushPending(); validateRangeOrder(token.range, token.lineNum); this.#pending = { op: { kind: "replace", range: token.range, lineNum: token.lineNum }, payload: [token.inlineBody ?? ""], - pendingBlanks: 0, }; return; } } /** - * Flush any open pending op (including its trailing blank lines, which - * are payload-significant) and return the accumulated edits and - * warnings. The executor is single-use; reset() is required for reuse. + * Flush any open pending op (with its full accumulated payload, blanks + * included) and return the accumulated edits and warnings. The executor + * is single-use; reset() is required for reuse. + * Throws if two replace/delete ops target the same line — that pattern + * means the diff is painting a before/after picture instead of stating + * the final state, and applying both would silently duplicate content. */ end(): { edits: HashlineEdit[]; warnings: string[] } { - this.#flushPending(true); + this.#flushPending(); + this.#validateNoOverlappingDeletes(); return { edits: this.#edits, warnings: this.#warnings }; } @@ -140,16 +141,44 @@ export class HashlineExecutor { this.#terminated = false; } + /** + * Each `:` / `!` op contributes a delete edit per line in its range; if + * any line ends up targeted by deletes originating from two different + * source ops (distinguished by their `lineNum`), the patch is internally + * inconsistent. Common shape: a "before" `A-B:` followed by an "after" + * `A-B:` over the same range, or an `A-B:` that overlaps a later `N!` / + * `N:`. The applier would run both literally and the file would end up + * with two copies of the line, not a chosen winner. + */ + #validateNoOverlappingDeletes(): void { + const sourceLinesByAnchor = new Map(); + for (const edit of this.#edits) { + if (edit.kind !== "delete") continue; + let sourceLines = sourceLinesByAnchor.get(edit.anchor.line); + if (sourceLines === undefined) { + sourceLines = []; + sourceLinesByAnchor.set(edit.anchor.line, sourceLines); + } + if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum); + } + for (const [anchorLine, sourceLines] of sourceLinesByAnchor) { + if (sourceLines.length < 2) continue; + const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b); + throw new Error( + `line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` + + `Issue ONE op per range; payload is only the final desired content, never a before/after pair.`, + ); + } + } + #handlePayload(text: string, lineNum: number): void { if (this.#pending) { - this.#flushPendingBlanks(); this.#pending.payload.push(text); return; } - // Whitespace-only payload outside any pending op is a visual - // separator (matches the legacy outer-loop isBlankLine skip); - // only fully-empty lines arrive as `blank` tokens. + // Whitespace-only payload outside any pending op is silently dropped; + // fully empty lines arrive as `blank` tokens. if (text.trim().length === 0) return; // Orphan payload outside any pending op: pick the most specific // diagnostic so the model sees the actionable hint. @@ -174,16 +203,9 @@ export class HashlineExecutor { ); } - #flushPendingBlanks(): void { - if (!this.#pending) return; - for (let count = 0; count < this.#pending.pendingBlanks; count++) this.#pending.payload.push(""); - this.#pending.pendingBlanks = 0; - } - - #flushPending(includeTrailingBlanks: boolean): void { + #flushPending(): void { const pending = this.#pending; if (!pending) return; - if (includeTrailingBlanks) this.#flushPendingBlanks(); const { op, payload } = pending; const linesToInsert = payload; diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 870117100..62a2dca19 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,110 +1,59 @@ Your patch language is a compact, line-anchored edit format. -A patch contains one or more file sections. Each anchored section starts with `¶PATH#HASH`, copied verbatim from the latest `read`/`search` output. `HASH` is a 4-hex file hash; `¶PATH` without `#HASH` is allowed only for new-file / `BOF` / `EOF` boundary inserts. - -Operations reference lines by bare line number (`5`, `123`). Payload text is verbatim — NEVER escape unicode. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions. - - -¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH -¶PATH unbound header: only BOF/EOF boundary inserts -LINE↑PAYLOAD insert ABOVE the anchored line (or BOF) -LINE↓PAYLOAD insert BELOW the anchored line (or EOF) -A-B:PAYLOAD replace the inclusive range A..B with PAYLOAD -A:PAYLOAD shorthand for A-A:PAYLOAD -A-B! delete the inclusive range A..B (payload forbidden) -A! shorthand for A-A! - - -- The first payload line is whatever follows the sigil on the op line. Additional payload lines follow on the next lines and append after the first. -- An empty inline IS an empty first line. So bare `A↓` / `A↑` insert one blank line; bare `A:` / `A-B:` replace with one blank line. `A↓\nfoo` inserts blank-then-`foo`, NOT just `foo`. -- Payload ends at the next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are dropped; blank lines between content lines are preserved. +Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`. +- No context rows, no gutters. +- NEVER prefix payload with diff syntax. +- NEVER restate unchanged lines "for context". +- Payload indentation is literal. + +LINE↑PAYLOAD insert before (or BOF↑) +LINE↓PAYLOAD insert after (or EOF↓) +A-B:PAYLOAD replace A..B (or A: == A..A) +A-B! delete A..B (or A! == A..A) + + -- The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes. -- **Payload is only what's NEW relative to your range.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat the anchor line or neighbors — that duplicates them. -- **Pick a self-contained unit.** Touching a multiline construct (return, array, brace block, JSX element)? Widen the range to span it. Don't bisect. -- Smallest op wins: add with `↑`/`↓`; replace with `:`; delete with `!`. -- Anchors reference the file as last read. ONE patch, ONE coordinate space — later ops still use original line numbers. +- **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors. +- **Go small.** Add → `↑`/`↓`; replace → `:`; delete → `!`. +- **Line numbers are frozen references to what you have seen.** Later ops still use original line numbers. -- **NEVER replay past your range.** Stop before B+1; extend B if it must go. -- **NEVER duplicate chunks inside one payload.** -- **Read lines look like replace ops.** `84:content` already means "make line 84 equal to content" — don't echo a context line before it. +- **NEVER replay past your range.** Stop before B+1; extend B if needed. +- **Read lines look like replace ops.** `84:content` = "make line 84 content" — don't echo context before it. - **NEVER fabricate file hashes.** Missing? Re-`read`. -- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error. -- **Pure removal uses `A-B!`, NEVER `A-B:something`.** If you have nothing to put in the range, use `!`. `A-B:X` where line `A-1` or `B+1` already reads `X` silently produces two copies of `X` — the tool trusts your payload literally. Before writing `A-B:payload`, glance at `A-1` and `B+1` and confirm payload doesn't echo either. - -¶mod.ts#1a2b -{{hline 1 'const TITLE = "Mr";'}} -{{hline 2 'export function greet(name) {'}} -{{hline 3 ' return ['}} -{{hline 4 ' TITLE,'}} -{{hline 5 ' name?.trim() || "guest",'}} -{{hline 6 ' ].join(" ");'}} -{{hline 7 "}"}} - + +```a.ts#1a2b +1:const X = "a"; +2:export function f() { return X; } +``` - -# Replace one line (inline payload preserves original indentation) -¶mod.ts#1a2b -{{hrefr 1}}:const TITLE = "Mrs"; - -# Replace a multiline statement — first line inline, rest below -¶mod.ts#1a2b -{{hrefr 3}}-{{hrefr 6}}: return [ - "Mrs", - name?.trim() || "guest", - ].join(" "); - -# Insert ABOVE / BELOW a line -¶mod.ts#1a2b -{{hrefr 4}}↓ "Dr", -{{hrefr 5}}↑ "Dr", - -# Delete one line / blank a line / insert a blank line -¶mod.ts#1a2b -{{hrefr 5}}! -{{hrefr 6}}: -{{hrefr 7}}↑ - -# Create a file / append to one (hash optional for boundary-only inserts) -¶new.ts -BOF↓export const done = true; -¶mod.ts -EOF↓export const done = true; - -# Multi-file patch -¶src/a.ts#1a2b -12:const enabled = true; -¶src/b.ts#3c4d -20! - +# replace, insert after, delete +``` +¶a.ts#1a2b +1:const X = "b"; +1↓const Y = "c"; +2! +``` + -# WRONG — replaces 2 lines just to add one. -¶mod.ts#1a2b -{{hrefr 1}}-{{hrefr 2}}:const TITLE = "Mr"; -const DEBUG = false; -export function greet(name) { - -# RIGHT — one-line insert -¶mod.ts#1a2b -{{hrefr 1}}↓const DEBUG = false; - -# WRONG — bisects a multiline statement -¶mod.ts#1a2b -{{hrefr 4}}-{{hrefr 5}}: "Dr", - name?.trim() || "guest", - -# RIGHT — widen to the full statement -¶mod.ts#1a2b -{{hrefr 3}}-{{hrefr 6}}: return [ - "Dr", - name?.trim() || "guest", - ].join(" "); +# WRONG — INSERT used to change a line (old line survives) +1↓const X = "b"; +# WRONG — echoing read-style lines as context before the real op +1:const X = "a"; +1-2:const X = "b"; +export const Y = X; + + +- One op per range, ever. +- Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`. +- Payload is only what's NEW; never repeat anchor lines or neighbors. +- Anchor exactly; don't anchor neighbors. + diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index b0b3d0e89..e706439ea 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -366,12 +366,11 @@ describe("hashline parser — suffix-op syntax", () => { expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc"); }); - it("drops blank lines between ops (separator, not payload)", () => { - // Blank lines immediately before a next op are visual separators, not - // payload. This prevents agents from silently inflating a payload and - // shifting downstream line numbers. + it("appends blank lines between ops to the previous payload", () => { + // Blank lines while a payload run is open are payload, never silent + // separators — every blank in `1:AAA\n\n\n3:CCC` ends up on disk. const diff = [`${sameLineRange(tag(1, "aaa"))}:AAA`, "", "", `${sameLineRange(tag(3, "ccc"))}:CCC`].join("\n"); - expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC"); + expect(applyDiff(content, diff)).toBe("AAA\n\n\nbbb\nCCC"); }); it("treats a bare insert op as inserting one empty line", () => { @@ -439,6 +438,44 @@ describe("hashline parser — suffix-op syntax", () => { /deletes only\. Payload is forbidden after !; use : to replace/, ); }); + + it("rejects two replace ops targeting the same single line", () => { + const diff = `${tag(2, "bbb")}:BBB\n${tag(2, "bbb")}:BBB2`; + expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted by the .+ op on line 1/); + }); + + it("rejects two replace ops covering the same range (before/after-block pattern)", () => { + const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:OLD\nOLD2\n${tag(2, "bbb")}-${tag(3, "ccc")}:NEW\nNEW2`; + expect(() => parseHashline(diff).edits).toThrow( + /Issue ONE op per range; payload is only the final desired content/, + ); + }); + + it("rejects a replace overlapping a later delete", () => { + const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:X\n${tag(3, "ccc")}!`; + expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/); + }); + + it("rejects two deletes on the same line", () => { + const diff = `${tag(2, "bbb")}!\n${tag(2, "bbb")}!`; + expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted by the .+ op on line 1/); + }); + + it("accepts multiple inserts at the same anchor (sequential, not duplicates)", () => { + // Two ↑ at the same line is a legitimate accumulation pattern — both + // inserts above land in source order. Only deletes/replaces are + // considered overlapping. + const diff = `${tag(2, "bbb")}↑X\n${tag(2, "bbb")}↑Y`; + expect(() => parseHashline(diff).edits).not.toThrow(); + }); + + it("accepts a replace alongside an insert at the same anchor", () => { + // `N:foo` deletes line N and inserts at before_anchor: N; `N↑bar` + // adds another insert at the same cursor. No conflicting delete, so + // this is allowed. + const diff = `${tag(2, "bbb")}:NEW\n${tag(2, "bbb")}↑ABOVE`; + expect(() => parseHashline(diff).edits).not.toThrow(); + }); }); describe("hashline — file hash binding", () => { @@ -987,29 +1024,29 @@ describe("hashline apply — brace-delete soft warning", () => { }); }); -describe("hashline parser — blank line is a separator before next op", () => { - it("blank line between ops is NOT absorbed into previous payload", () => { +describe("hashline parser — blank line extends the open payload", () => { + it("blank line between ops is appended to the previous payload", () => { const text = "a\nb\nc\nd\ne\n"; const ops = `${header("a.ts", text)}\n1:A\n\n3:C\n`; const { diff } = splitHashlineInput(ops); - // Both replaces land on their target lines without inflating either payload. - expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); + // `1:A` payload becomes [A, ""]; line 3 is then replaced with C. + expect(applyDiff(text, diff)).toBe("A\n\nb\nC\nd\ne\n"); }); - it("multiple blank lines between ops are also dropped", () => { + it("multiple blank lines between ops are all appended as payload", () => { const text = "a\nb\nc\nd\ne\n"; const ops = `${header("a.ts", text)}\n1:A\n\n\n\n3:C\n`; const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); + expect(applyDiff(text, diff)).toBe("A\n\n\n\nb\nC\nd\ne\n"); }); - it("blank-only payload before next op blanks the line", () => { - // Agent typed `2:` then a blank separator then `4:D`. Under bare-`A:` - // blank-replace semantics, `2:` blanks line 2 and `4:D` replaces line 4. + it("bare A: followed by a blank line replaces the line with two blanks", () => { + // `2:` seeds payload with `[""]`, the standalone blank line appends + // another `""`, then `4:D` flushes the run. const text = "a\nb\nc\nd\ne\n"; const ops = `${header("a.ts", text)}\n2:\n\n4:D\n`; const { diff } = splitHashlineInput(ops); - expect(applyDiff(text, diff)).toBe("a\n\nc\nD\ne\n"); + expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n"); }); it("blank line inside payload between two content lines is preserved", () => { diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 461bcbadb..da4bdf947 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -229,52 +229,6 @@ 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).toBe("5"); - }); - - test("href should not reuse hline state across prompt renders", () => { - expect(expandPrompt('{{hline 1 "const x = 1;"}}\n{{hrefr}}')).toBe("1:const x = 1;\n1"); - expect(() => expandPrompt("{{hrefr}}")).toThrow("previous {{hline}}"); - }); -}); - // ============================================================================ // expandSlashCommand + expandPromptTemplate fallback behavior // ============================================================================