From 78cb293c41c077d1050741dd777d20c7c0b23c74 Mon Sep 17 00:00:00 2001 From: Matt Vance Date: Fri, 20 Mar 2026 12:40:54 -0500 Subject: [PATCH 1/3] fix: include context in summary cache hash to prevent collisions --- src/cache-keys.ts | 17 ++++++++++++++--- src/cache.ts | 2 +- tests/cache.keys.test.ts | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/cache-keys.ts b/src/cache-keys.ts index a8d22fd4..928d980a 100644 --- a/src/cache-keys.ts +++ b/src/cache-keys.ts @@ -14,7 +14,10 @@ export function normalizeContentForHash(content: string): string { return content.replaceAll("\r\n", "\n").trim(); } -export function extractTaggedBlock(prompt: string, tag: "instructions" | "content"): string | null { +export function extractTaggedBlock( + prompt: string, + tag: "instructions" | "content" | "context", +): string | null { const open = `<${tag}>`; const close = ``; const start = prompt.indexOf(open); @@ -25,8 +28,16 @@ export function extractTaggedBlock(prompt: string, tag: "instructions" | "conten } export function buildPromptHash(prompt: string): string { - const instructions = extractTaggedBlock(prompt, "instructions") ?? prompt; - return hashString(instructions.trim()); + const instructions = extractTaggedBlock(prompt, "instructions") ?? ""; + const context = extractTaggedBlock(prompt, "context") ?? ""; + + // If we have both, we hash both. If we have only one, we hash that. + // If we have neither (tags missing), we hash the whole trimmed prompt. + if (instructions || context) { + return hashString(`${instructions}\n${context}`.trim()); + } + + return hashString(prompt.trim()); } export function buildPromptContentHash({ diff --git a/src/cache.ts b/src/cache.ts index 7442c86e..a4ea9b77 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -40,7 +40,7 @@ export type CacheConfig = { path?: string; }; -export const CACHE_FORMAT_VERSION = 1; +export const CACHE_FORMAT_VERSION = 2; export const DEFAULT_CACHE_MAX_MB = 512; export const DEFAULT_CACHE_TTL_DAYS = 30; diff --git a/tests/cache.keys.test.ts b/tests/cache.keys.test.ts index 21c47adb..b8d82a78 100644 --- a/tests/cache.keys.test.ts +++ b/tests/cache.keys.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildExtractCacheKey, buildPromptContentHash, + buildPromptHash, buildSummaryCacheKey, extractTaggedBlock, } from "../src/cache.js"; @@ -11,9 +12,24 @@ describe("cache keys and tags", () => { const prompt = "Do the thing.\nBody"; expect(extractTaggedBlock(prompt, "instructions")).toBe("Do the thing."); expect(extractTaggedBlock(prompt, "content")).toBe("Body"); + expect(extractTaggedBlock(prompt, "context")).toBeNull(); + expect(extractTaggedBlock("Site", "context")).toBe("Site"); expect(extractTaggedBlock("no tags here", "instructions")).toBeNull(); }); + it("changes prompt hashes when context changes", () => { + const instructions = "Summarize it."; + const contextA = "URL: https://a.com"; + const contextB = "URL: https://b.com"; + const prompt1 = `${instructions}\n${contextA}\n`; + const prompt2 = `${instructions}\n${contextB}\n`; + + const hash1 = buildPromptHash(prompt1); + const hash2 = buildPromptHash(prompt2); + + expect(hash1).not.toBe(hash2); + }); + it("changes summary keys when inputs change", () => { const base = buildSummaryCacheKey({ contentHash: "content", From 4da0a38b17d677d3ef0be70702de637d09340ee9 Mon Sep 17 00:00:00 2001 From: Matt Vance Date: Fri, 20 Mar 2026 13:09:26 -0500 Subject: [PATCH 2/3] test: verify consistent hashing for empty/missing context --- tests/cache.keys.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/cache.keys.test.ts b/tests/cache.keys.test.ts index b8d82a78..e98b6953 100644 --- a/tests/cache.keys.test.ts +++ b/tests/cache.keys.test.ts @@ -30,6 +30,17 @@ describe("cache keys and tags", () => { expect(hash1).not.toBe(hash2); }); + it("hashes instructions-only prompt consistently", () => { + const promptWithEmptyContext = "Summarize.\n\nBody"; + const promptWithNoContextTag = "Summarize.\nBody"; + + const hash1 = buildPromptHash(promptWithEmptyContext); + const hash2 = buildPromptHash(promptWithNoContextTag); + + // Both should hash just the instructions since context is empty/missing + expect(hash1).toBe(hash2); + }); + it("changes summary keys when inputs change", () => { const base = buildSummaryCacheKey({ contentHash: "content", From 7f1e258e23cd929361e62c2b858a4cbafd84317f Mon Sep 17 00:00:00 2001 From: Matt Vance Date: Fri, 20 Mar 2026 13:40:46 -0500 Subject: [PATCH 3/3] refactor: improve prompt hashing consistency for empty tags --- src/cache-keys.ts | 12 +++++++----- tests/cache.keys.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/cache-keys.ts b/src/cache-keys.ts index 928d980a..ac29270a 100644 --- a/src/cache-keys.ts +++ b/src/cache-keys.ts @@ -28,15 +28,17 @@ export function extractTaggedBlock( } export function buildPromptHash(prompt: string): string { - const instructions = extractTaggedBlock(prompt, "instructions") ?? ""; - const context = extractTaggedBlock(prompt, "context") ?? ""; + const instructionsContent = extractTaggedBlock(prompt, "instructions"); + const contextContent = extractTaggedBlock(prompt, "context"); - // If we have both, we hash both. If we have only one, we hash that. - // If we have neither (tags missing), we hash the whole trimmed prompt. - if (instructions || context) { + // If at least one of the tags is present (even if empty), hash their contents. + if (instructionsContent !== null || contextContent !== null) { + const instructions = instructionsContent ?? ""; + const context = contextContent ?? ""; return hashString(`${instructions}\n${context}`.trim()); } + // Fallback for prompts without any tags. return hashString(prompt.trim()); } diff --git a/tests/cache.keys.test.ts b/tests/cache.keys.test.ts index e98b6953..1303f30e 100644 --- a/tests/cache.keys.test.ts +++ b/tests/cache.keys.test.ts @@ -5,6 +5,7 @@ import { buildPromptHash, buildSummaryCacheKey, extractTaggedBlock, + hashString, } from "../src/cache.js"; describe("cache keys and tags", () => { @@ -41,6 +42,24 @@ describe("cache keys and tags", () => { expect(hash1).toBe(hash2); }); + it("treats multiple empty tags consistently", () => { + const p1 = ""; + const p2 = ""; + const p3 = ""; + const p4 = " "; + + const h1 = buildPromptHash(p1); + const h2 = buildPromptHash(p2); + const h3 = buildPromptHash(p3); + const h4 = buildPromptHash(p4); + + expect(h1).toBe(h2); + expect(h2).toBe(h3); + expect(h3).toBe(h4); + // They should all hash to an empty string's hash (after trim) + expect(h1).toBe(hashString("")); + }); + it("changes summary keys when inputs change", () => { const base = buildSummaryCacheKey({ contentHash: "content",