From 23133e8ffe81700b6be6522d54aa3bf93140a57e Mon Sep 17 00:00:00 2001 From: Carsten Lucke Date: Thu, 6 Aug 2026 13:59:58 +0200 Subject: [PATCH] fix: resolve image paths before the model ever sees them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `~/x.png` reference never reached the vision tool as a usable path, and the paths the model was shown were the raw tokens the user typed rather than the resolved ones. Both stem from the same gap: path resolution happening too late, or not at all. 1. Tilde expansion was broken in three places: - lib/image.ts stripped the tilde (`replace(/^~/, "")`), turning `~/x.png` into the absolute `/x.png`, which then won the resolve() against cwd. - extensions/paste.ts resolved `~/x.png` against cwd, yielding `/~/x.png` (tilde as a literal directory name). - updateComposePreview() had the same bug in a third variant (isAbsolute("~/x") is false → `/~/x.png`). Effect: a home-relative path pasted into the editor produced no [Image-#N] marker, no hint line, no compose preview and no auto-delegation. It only appeared to work because the LLM expanded the tilde itself before calling describe_image, so the extension never saw a tilde on the tool path. Adds a shared, pure `expandTilde()` in lib/image.ts so the tool path and the paste path expand identically. `~user/…` is deliberately not expanded (that needs a passwd lookup; mapping it onto the current user's home would be wrong) — it falls through and fails existsSync. resolveImageFile is exported so the resolution rules are unit-testable without a pi runtime. 2. buildHintLine and buildDescriptionsBlock rendered the raw token while LoadedImage.abs sat in the same object. A `~/x.png` or `./x.png` in the hint is not actionable without $HOME or the cwd, which defeats the stated purpose of the v0.4.0 hint (SPEC-4 §3.4): list the paths "so the model can actually call describe_image". Observed in a real GLM-5.2 session: the model shelled out to `bash echo ~/vision-tilde-test.png` purely to resolve the tilde before it could call the tool. Both now take resolved paths only — buildHintLine as `paths: string[]`, since `index` was never read and `token` only fed a fallback that no call site could reach. Verified end to end against GLM 5.2 with a text-only primary: the hint now lists the absolute path, and the model calls describe_image directly — one tool call instead of two. Tests: +11 (364 total, was 353), including integration tests that drive the full input event with $HOME on a temp dir and cwd deliberately elsewhere. All were verified to fail against the pre-fix code. Co-Authored-By: Claude Opus 5 (1M context) --- extensions/paste.ts | 39 +++++++++++--------- lib/image.ts | 15 +++++++- lib/marker.ts | 20 ++++++---- tests/image.test.ts | 46 +++++++++++++++++++++-- tests/integration.test.ts | 78 +++++++++++++++++++++++++++++++++++++++ tests/marker.test.ts | 38 ++++++------------- tests/paste.test.ts | 67 ++++++++++++++++++++++++++++++++- 7 files changed, 246 insertions(+), 57 deletions(-) diff --git a/extensions/paste.ts b/extensions/paste.ts index ba1d974..da44a87 100644 --- a/extensions/paste.ts +++ b/extensions/paste.ts @@ -26,11 +26,11 @@ * session_start + every mutation). */ import { existsSync, statSync } from "node:fs"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import { resolve as resolvePath } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ImageContent } from "@earendil-works/pi-ai"; import { isMultimodal } from "../lib/capability.ts"; -import { loadImage } from "../lib/image.ts"; +import { expandTilde, loadImage } from "../lib/image.ts"; import { renderMarkers, buildHintLine, buildDescriptionsBlock } from "../lib/marker.ts"; import { getSharedConfig, getSharedCache } from "../lib/state.ts"; import { delegateToVisionModel, type DelegateParams } from "../lib/delegate.ts"; @@ -66,12 +66,12 @@ export function findImagePathTokens(text: string): string[] { /** Resolve a token against cwd and return the absolute path if it's a real * image file, else undefined. Unescapes \ (escaped spaces from terminal - * drag-and-drop) before resolving. */ -function resolveImageFile(token: string, cwd: string): string | undefined { - // Unescape \ → space (terminal drag-paste escaping) - const unescaped = token.replace(/\\ /g, " "); - const expanded = unescaped.startsWith("~/") ? resolvePath(cwd, unescaped) : unescaped; - const abs = isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded); + * drag-and-drop) and expands a leading ~/ before resolving. Exported so the + * resolution rules are unit-testable without a pi runtime. */ +export function resolveImageFile(token: string, cwd: string): string | undefined { + // Unescape \ → space (terminal drag-paste escaping). `resolvePath` returns an + // already-absolute path unchanged, so no isAbsolute branch is needed. + const abs = resolvePath(cwd, expandTilde(token.replace(/\\ /g, " "))); if (!existsSync(abs)) return undefined; try { if (!statSync(abs).isFile()) return undefined; @@ -250,9 +250,8 @@ async function updateComposePreview(ctx: ExtensionContext): Promise { // Resolve + load each token (compress: false — show original quality) const previewImages: ReturnType[] = []; for (const token of tokens) { - const unescaped = token.replace(/\\ /g, " "); - const abs = isAbsolute(unescaped) ? unescaped : resolvePath(ctx.cwd, unescaped); - if (!existsSync(abs)) continue; + const abs = resolveImageFile(token, ctx.cwd); + if (!abs) continue; const result = await loadImage(abs, { compress: false, maxDimension: 1568, @@ -361,9 +360,14 @@ export default function pasteExtension(_pi: ExtensionAPI): void { return { action: "transform" as const, text }; } + // Everything the model is shown from here on names `abs`, never the raw + // token: `~/x.png` or `./x.png` is not actionable without $HOME or the + // cwd, and the model would have to shell out to resolve it first. + const hintPaths = loaded.map((l) => l.abs); + if (mode === "hint") { // Markers + hint line nudging the model to call describe_image. - text = `${text}\n${buildHintLine(loaded.map((l, i) => ({ token: l.token, index: resolved.get(l.token)?.index ?? i })))}`; + text = `${text}\n${buildHintLine(hintPaths)}`; return { action: "transform" as const, text }; } @@ -374,7 +378,6 @@ export default function pasteExtension(_pi: ExtensionAPI): void { // failure (all-fail → hint; per-image fail → that image gets no description). const cache = getSharedCache(); const visionModel = config.provider && config.model ? `${config.provider}/${config.model}` : "(unconfigured)"; - const hintImages = loaded.map((l, i) => ({ token: l.token, index: resolved.get(l.token)?.index ?? i })); // ── Local-only short-circuit (SPEC-5 §3.2) ──────────────────────────── // If local-only mode is on, every delegation would be refused (cache miss) @@ -382,13 +385,13 @@ export default function pasteExtension(_pi: ExtensionAPI): void { // waiting for refused calls to abort. Fall straight to hint so the model // can still call describe_image for cache hits (which local-only allows). if (config.localOnly) { - text = `${text}\n${buildHintLine(hintImages)}`; + text = `${text}\n${buildHintLine(hintPaths)}`; return { action: "transform" as const, text }; } if (!cache || !config.provider || !config.model) { // Can't delegate (no cache or unconfigured) → fall back to hint. - text = `${text}\n${buildHintLine(hintImages)}`; + text = `${text}\n${buildHintLine(hintPaths)}`; return { action: "transform" as const, text }; } @@ -405,12 +408,12 @@ export default function pasteExtension(_pi: ExtensionAPI): void { clearTimeout(timer); } - const descriptions: Array<{ token: string; index: number; text: string; cached: boolean }> = []; + const descriptions: Array<{ path: string; index: number; text: string; cached: boolean }> = []; let ok = 0; for (let i = 0; i < loaded.length; i++) { const r = results[i]; if (r) { - descriptions.push({ token: loaded[i]!.token, index: resolved.get(loaded[i]!.token)?.index ?? i, text: r.text, cached: r.cached }); + descriptions.push({ path: loaded[i]!.abs, index: resolved.get(loaded[i]!.token)?.index ?? i, text: r.text, cached: r.cached }); ok++; } // undefined → that image gets no description (timeout/failure mid-batch) @@ -418,7 +421,7 @@ export default function pasteExtension(_pi: ExtensionAPI): void { if (ok === 0) { // All failed/timed out → hint fallback (with paths, §3.4). - text = `${text}\n${buildHintLine(hintImages)}`; + text = `${text}\n${buildHintLine(hintPaths)}`; return { action: "transform" as const, text }; } diff --git a/lib/image.ts b/lib/image.ts index 1990b47..a46ee1f 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -9,6 +9,7 @@ */ import { readFile, stat } from "node:fs/promises"; import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import { isAbsolute, resolve as resolvePath } from "node:path"; import { createHash } from "node:crypto"; import { resizeImage } from "@earendil-works/pi-coding-agent"; @@ -109,6 +110,18 @@ function parseDataUrl(input: string): ImageLoadResult { return { ok: true, image: { data: payload, mimeType: mime }, sourceHash: hashBytes(bytes) }; } +/** + * Expand a leading `~/` (or a bare `~`) to the user's home directory. + * + * `~user/…` is deliberately NOT expanded: resolving another user's home needs + * a passwd lookup, and silently mapping it onto the current user's home would + * be wrong. Such a token falls through unchanged. + */ +export function expandTilde(input: string): string { + if (input !== "~" && !input.startsWith("~/")) return input; + return resolvePath(homedir(), input.slice(2)); +} + /** Is `input` plausibly a file path we should try to read (vs raw base64)? */ function looksLikeFilePath(input: string): boolean { if (input.startsWith("data:")) return false; @@ -148,7 +161,7 @@ export async function loadImage(input: string, options: LoadOptions): Promise, -): string { - const n = images.length; +export function buildHintLine(paths: string[]): string { + const n = paths.length; if (n === 0) { return "0 images referenced."; } const noun = n === 1 ? "image" : "images"; const verb = n === 1 ? "analyze it" : "analyze them"; const clause = n >= 2 ? " (single, or pass all paths to image_paths for batch analysis)" : ""; - const pathLines = images.map((img) => ` ${img.token}`).join("\n"); + const pathLines = paths.map((p) => ` ${p}`).join("\n"); return `${n} ${noun} referenced. The active model cannot process images natively — use the describe_image tool to ${verb}${clause}. Image paths: ${pathLines}`; @@ -191,9 +193,13 @@ export function buildBatchToolResult( * Build the descriptions block appended in text-only + "auto" mode. * Each image's delegation result is appended as a labeled line. A footer * notes the vision model + how to switch to hint mode (cost awareness). + * + * `path` is the resolved absolute path, for the same reason as + * `buildHintLine`: it is the model's handle for a follow-up + * `describe_image` call on the same image. */ export function buildDescriptionsBlock( - descriptions: Array<{ token: string; index: number; text: string; cached: boolean }>, + descriptions: Array<{ path: string; index: number; text: string; cached: boolean }>, visionModel: string, ): string { if (descriptions.length === 0) return ""; @@ -201,7 +207,7 @@ export function buildDescriptionsBlock( const lines = descriptions.map((d) => { const label = styleMarker(d.index + 1, "code"); const cachedTag = d.cached ? " (cached)" : ""; - return `[${label} ${d.token}]: ${d.text}${cachedTag}`; + return `[${label} ${d.path}]: ${d.text}${cachedTag}`; }); const footer = `[${descriptions.length} image(s) auto-described via ${visionModel}. Set textOnlyPasteMode to "hint" to delegate on-demand instead.]`; diff --git a/tests/image.test.ts b/tests/image.test.ts index bfad727..9a496cb 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -1,9 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import { detectMimeType, hashBytes, loadImage, MAX_IMAGE_BYTES } from "../lib/image.ts"; +import { detectMimeType, expandTilde, hashBytes, loadImage, MAX_IMAGE_BYTES } from "../lib/image.ts"; // 1×1 transparent PNG — decodes to bytes starting with the PNG signature // (89 50 4E 47 0D 0A 1A 0A). @@ -210,4 +210,44 @@ test("loadImage: data URL + raw base64 both return sourceHash", async () => { test("hashBytes: empty + known vector", () => { assert.equal(hashBytes(Buffer.alloc(0)), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); assert.equal(hashBytes(PNG_BYTES).length, 64); -}); \ No newline at end of file +}); +// ── Tilde expansion (fix: ~/ was stripped to "/", never hitting $HOME) ────── + +test("expandTilde: ~/ expands against the home directory", () => { + assert.equal(expandTilde("~/Desktop/x.png"), join(homedir(), "Desktop", "x.png")); +}); + +test("expandTilde: bare ~ expands to the home directory", () => { + assert.equal(expandTilde("~"), homedir()); +}); + +test("expandTilde: ~user is NOT expanded (another user's home)", () => { + assert.equal(expandTilde("~alice/x.png"), "~alice/x.png"); +}); + +test("expandTilde: non-tilde input passes through unchanged", () => { + assert.equal(expandTilde("/tmp/x.png"), "/tmp/x.png"); + assert.equal(expandTilde("./x.png"), "./x.png"); + assert.equal(expandTilde("../a/x.png"), "../a/x.png"); +}); + +test("expandTilde: a tilde mid-path is not a home reference", () => { + assert.equal(expandTilde("/tmp/~/x.png"), "/tmp/~/x.png"); +}); + +test("loadImage: ~/ path resolves against $HOME, not cwd", async () => { + const home = tmpDir(); + const origHome = process.env.HOME; + try { + process.env.HOME = home; + writeFileSync(join(home, "tilde.png"), PNG_BYTES); + // cwd deliberately points elsewhere: a correct expansion must ignore it. + const result = await loadImage("~/tilde.png", { ...LOAD_OPTS, cwd: join(home, "unrelated") }); + assert.equal(result.ok, true, "~/tilde.png must resolve to $HOME/tilde.png"); + if (result.ok) assert.equal(result.image.mimeType, "image/png"); + } finally { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 60ff7ed..292f17a 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -877,6 +877,45 @@ test("T32: text-only + auto → delegate per image + descriptions appended, no a } }); +// ── T32b: the descriptions block names the RESOLVED path too ─────────── +// Same reason as the hint line (T52b): the path in `[[Image-#1] ]:` is +// the model's handle for a follow-up describe_image call on that image, so a +// raw `~/…` token there is just as unusable. +test("T32b: text-only + auto + ~/ token → descriptions block lists the resolved absolute path", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + pasteFactory(pi as unknown as ExtensionAPI); + const home = mkdtempSync(join(tmpdir(), "vision-eval-home-")); + const cwd = mkdtempSync(join(tmpdir(), "vision-eval-cwd-")); + const origHome = process.env.HOME; + const file = join(home, "shot.png"); + writeFileSync(file, make1x1Png(255, 0, 255)); + const fm = mockFetch({ choices: [{ message: { content: "A magenta square." } }] }); + try { + process.env.HOME = home; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd })); + await runVisionCommand(pi, "provider ollama", TEXT_ONLY); + await runVisionCommand(pi, "model minimax-m3:cloud", TEXT_ONLY); + await runVisionCommand(pi, "paste-mode auto", TEXT_ONLY); + const inputResult = await pi.emit( + "input", + { type: "input", text: "analyze ~/shot.png", source: "interactive", images: [] }, + makeCtx({ model: TEXT_ONLY, cwd, registry: makeRegistry({ model: VISION_MODEL }) }), + ); + assert.equal(inputResult?.action, "transform"); + assert.match(inputResult.text, /magenta square/, "description appended"); + // ★ GATE: the labeled line names the resolved path, not the typed token. + assert.ok(inputResult.text.includes(`${file}]:`), "★ descriptions block lists the resolved absolute path"); + assert.ok(!inputResult.text.includes("~/shot.png"), "★ raw tilde token absent from the transformed text"); + } finally { + fm.restore(); + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + // ── T33: text-only + off → markers only, no hint, no delegation ── test("T33: text-only + off → markers only, no hint, no delegation", async () => { const pi = createMockPi(); @@ -1543,6 +1582,45 @@ test("T52: text-only + hint + 2 paths → markers + hint lists paths + batch aff } }); +// ── T52b: the hint lists RESOLVED paths, never the raw token ─────────── +// A `~/x.png` token is not actionable for the model without knowing $HOME — +// observed in a real session, the model shelled out to `echo ~/x.png` just +// to resolve the tilde before it could call describe_image. Guards the +// paste.ts side of that fix (buildHintLine itself only sees paths now). +test("T52b: text-only + hint + ~/ token → hint lists the resolved absolute path", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + pasteFactory(pi as unknown as ExtensionAPI); + const home = mkdtempSync(join(tmpdir(), "vision-eval-home-")); + const cwd = mkdtempSync(join(tmpdir(), "vision-eval-cwd-")); + const origHome = process.env.HOME; + const file = join(home, "shot.png"); + writeFileSync(file, make1x1Png(0, 0, 255)); + try { + process.env.HOME = home; + writeFileSync(join(TMP_AGENT, "vision.json"), JSON.stringify({ + provider: "ollama", model: "minimax-m3:cloud", enabled: true, + textOnlyPasteMode: "hint", + })); + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd })); + const inputResult = await pi.emit( + "input", + { type: "input", text: "analyze ~/shot.png", source: "interactive", images: [] }, + makeCtx({ model: TEXT_ONLY, cwd }), + ); + assert.equal(inputResult?.action, "transform"); + // ★ GATE: the resolved path is listed; the raw tilde token never reaches + // the model — neither in the body (markered) nor in the hint. + assert.ok(inputResult.text.includes(` ${file}`), "★ hint lists the resolved absolute path"); + assert.ok(!inputResult.text.includes("~/shot.png"), "★ raw tilde token absent from the transformed text"); + } finally { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); + // ── T51: clipboard paste path regression guard (★ SPEC-4 §3.3) ───────── // Pi routes ctrl+v clipboard images to /tmp/pi-clipboard-.png then // inserts the path at the cursor (interactive-mode.js:2055). Our pipeline diff --git a/tests/marker.test.ts b/tests/marker.test.ts index 5dbdb62..7415273 100644 --- a/tests/marker.test.ts +++ b/tests/marker.test.ts @@ -160,7 +160,7 @@ test("renderMarkers: empty text", () => { // ── buildHintLine (v0.4.0: lists paths + names batch affordance) ─────────── test("buildHintLine: single image → singular noun, one path, no batch clause", () => { - const line = buildHintLine([{ token: "/tmp/a.png", index: 0 }]); + const line = buildHintLine(["/tmp/a.png"]); assert.ok(line.startsWith("1 image referenced."), "singular noun"); assert.ok(line.includes("analyze it."), "singular verb"); assert.ok(!line.includes("image_paths"), "no batch affordance for 1 image"); @@ -168,10 +168,7 @@ test("buildHintLine: single image → singular noun, one path, no batch clause", }); test("buildHintLine: multiple images → plural noun, N paths, batch affordance", () => { - const line = buildHintLine([ - { token: "/tmp/a.png", index: 0 }, - { token: "/tmp/b.jpeg", index: 1 }, - ]); + const line = buildHintLine(["/tmp/a.png", "/tmp/b.jpeg"]); assert.ok(line.startsWith("2 images referenced."), "plural noun"); assert.ok(line.includes("analyze them"), "plural verb"); assert.ok(line.includes("image_paths"), "names the batch affordance"); @@ -184,24 +181,11 @@ test("buildHintLine: zero images (defensive)", () => { assert.equal(line, "0 images referenced."); }); -test("buildHintLine: paths are extractable via regex", () => { - const line = buildHintLine([ - { token: "/tmp/a.png", index: 0 }, - { token: "/tmp/pi-clipboard-3f1c.png", index: 1 }, - ]); +test("buildHintLine: paths are extractable via regex, in the order given", () => { + // Spaces in a filename must not break the `^ (.+)$` extraction contract. + const line = buildHintLine(["/tmp/a.png", "/tmp/pi-clipboard-3f1c.png", "/tmp/My Shot.png"]); const paths = [...line.matchAll(/^ (.+)$/gm)].map((m) => m[1]); - assert.deepEqual(paths, ["/tmp/a.png", "/tmp/pi-clipboard-3f1c.png"]); -}); - -test("buildHintLine: preserves token order (index not used for ordering)", () => { - // The caller passes tokens in marker order; output lists them in that order. - const line = buildHintLine([ - { token: "/tmp/first.png", index: 0 }, - { token: "/tmp/second.png", index: 1 }, - ]); - const firstIdx = line.indexOf("/tmp/first.png"); - const secondIdx = line.indexOf("/tmp/second.png"); - assert.ok(firstIdx < secondIdx && firstIdx > -1, "first before second"); + assert.deepEqual(paths, ["/tmp/a.png", "/tmp/pi-clipboard-3f1c.png", "/tmp/My Shot.png"]); }); // ── buildBatchToolResult (v0.4.0: structured per-image tool result) ─────── @@ -282,7 +266,7 @@ test("buildBatchToolResult: empty paths (defensive)", () => { test("buildDescriptionsBlock: single image", () => { const out = buildDescriptionsBlock( - [{ token: "/tmp/a.png", index: 0, text: "A red square.", cached: false }], + [{ path: "/tmp/a.png", index: 0, text: "A red square.", cached: false }], "ollama/minimax-m3:cloud", ); assert.ok(out.startsWith("\n\n")); @@ -294,8 +278,8 @@ test("buildDescriptionsBlock: single image", () => { test("buildDescriptionsBlock: multiple images", () => { const out = buildDescriptionsBlock( [ - { token: "/tmp/a.png", index: 0, text: "A red square.", cached: false }, - { token: "/tmp/b.jpeg", index: 1, text: "A blue circle.", cached: true }, + { path: "/tmp/a.png", index: 0, text: "A red square.", cached: false }, + { path: "/tmp/b.jpeg", index: 1, text: "A blue circle.", cached: true }, ], "ollama/minimax-m3:cloud", ); @@ -306,7 +290,7 @@ test("buildDescriptionsBlock: multiple images", () => { test("buildDescriptionsBlock: cached tag appears only when cached", () => { const out = buildDescriptionsBlock( - [{ token: "/tmp/a.png", index: 0, text: "desc", cached: false }], + [{ path: "/tmp/a.png", index: 0, text: "desc", cached: false }], "m", ); assert.ok(!out.includes("(cached)")); @@ -314,4 +298,4 @@ test("buildDescriptionsBlock: cached tag appears only when cached", () => { test("buildDescriptionsBlock: empty → empty string", () => { assert.equal(buildDescriptionsBlock([], "m"), ""); -}); \ No newline at end of file +}); diff --git a/tests/paste.test.ts b/tests/paste.test.ts index 89e0096..65b8b4f 100644 --- a/tests/paste.test.ts +++ b/tests/paste.test.ts @@ -1,6 +1,33 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { findImagePathTokens } from "../extensions/paste.ts"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findImagePathTokens, resolveImageFile } from "../extensions/paste.ts"; + +// 1×1 transparent PNG (same fixture as tests/image.test.ts). +const PNG_BYTES = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M8AAAMBEg1+mP0AAAAASUVORK5CYII=", + "base64", +); + +function tmpDir(): string { + return mkdtempSync(join(tmpdir(), "vision-paste-")); +} + +/** Run `fn` with $HOME pointed at a fresh temp dir, restoring it afterwards. */ +function withTempHome(fn: (home: string) => void): void { + const home = tmpDir(); + const origHome = process.env.HOME; + process.env.HOME = home; + try { + fn(home); + } finally { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + } +} test("findImagePathTokens: absolute path", () => { assert.deepEqual(findImagePathTokens("analyze /tmp/screenshot.png"), ["/tmp/screenshot.png"]); @@ -74,3 +101,41 @@ test("findImagePathTokens: escaped + regular paths mixed", () => { assert.equal(out[0], "/tmp/a.png"); assert.equal(out[1], "/tmp/My\\ B.jpeg"); }); + +// ── resolveImageFile: tilde expansion (fix) ──────────────────────────────── +// Previously `~/x.png` was resolved as `/~/x.png`, so a home-relative +// path pasted into the editor never resolved: no [Image-#N] marker, no hint +// line, no compose preview, no auto-delegation. It only appeared to work +// because the LLM expanded the tilde itself before calling describe_image. + +test("resolveImageFile: ~/ resolves against $HOME, not cwd", () => { + withTempHome((home) => { + const file = join(home, "shot.png"); + writeFileSync(file, PNG_BYTES); + assert.equal(resolveImageFile("~/shot.png", join(home, "unrelated")), file); + }); +}); + +test("resolveImageFile: ~/ with escaped spaces (drag-paste of a home path)", () => { + withTempHome((home) => { + const file = join(home, "My Screenshot.png"); + writeFileSync(file, PNG_BYTES); + assert.equal(resolveImageFile("~/My\\ Screenshot.png", "/tmp"), file); + }); +}); + +test("resolveImageFile: absolute + relative paths still resolve", () => { + const dir = tmpDir(); + try { + const file = join(dir, "a.png"); + writeFileSync(file, PNG_BYTES); + assert.equal(resolveImageFile(file, "/tmp"), file); + assert.equal(resolveImageFile("./a.png", dir), file); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveImageFile: nonexistent path → undefined", () => { + assert.equal(resolveImageFile("/nope/definitely-missing.png", "/tmp"), undefined); +});