diff --git a/packages/tools/src/shared/cache.ts b/packages/tools/src/shared/cache.ts index 15ce16fe..0fe1beed 100644 --- a/packages/tools/src/shared/cache.ts +++ b/packages/tools/src/shared/cache.ts @@ -5,6 +5,9 @@ import type { MemoryMode } from "./types" * Generic memory cache for storing per-turn memories to avoid redundant API calls. * Used to cache memory retrieval results during tool-call loops within the same turn. */ +const escapeKeySegment = (segment: string): string => + segment.replace(/%/g, "%25").replace(/:/g, "%3A") + export class MemoryCache { private cache: LRUCache = new LRUCache({ max: 100 }) @@ -25,7 +28,9 @@ export class MemoryCache { message: string, ): string { const normalizedMessage = message.trim().replace(/\s+/g, " ") - return `${containerTag}:${threadId || ""}:${mode}:${normalizedMessage}` + const safeContainerTag = escapeKeySegment(containerTag) + const safeThreadId = escapeKeySegment(threadId ?? "") + return `${safeContainerTag}:${safeThreadId}:${mode}:${normalizedMessage}` } /** diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 3cc070ba..718b286b 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { makeTurnKey } from "./shared/cache" import { DEFAULT_VALUES, SEARCH_LIMIT_BOUNDS, @@ -130,3 +131,39 @@ describe("deduplicateMemoriesForMode", () => { expect(deduplicated.searchResults).toEqual([]) }) }) + +describe("makeTurnKey", () => { + it("generates predictable turn key for standard inputs", () => { + expect(makeTurnKey("user-123", "thread-456", "full", "hello world")).toBe( + "user-123:thread-456:full:hello world", + ) + }) + + it("normalizes and collapses whitespace in message", () => { + expect( + makeTurnKey("user-123", "thread-456", "full", " hello world \n "), + ).toBe("user-123:thread-456:full:hello world") + }) + + it("handles undefined threadId cleanly", () => { + expect(makeTurnKey("user-123", undefined, "profile", "test")).toBe( + "user-123::profile:test", + ) + }) + + it("escapes colons to prevent cache key collisions between tag and threadId", () => { + const keyA = makeTurnKey("user:123", "456", "profile", "hi") + const keyB = makeTurnKey("user", "123:456", "profile", "hi") + expect(keyA).toBe("user%3A123:456:profile:hi") + expect(keyB).toBe("user:123%3A456:profile:hi") + expect(keyA).not.toBe(keyB) + }) + + it("escapes percent signs to avoid ambiguity with encoded sequences", () => { + const keyA = makeTurnKey("user%3A123", "456", "profile", "hi") + const keyB = makeTurnKey("user:123", "456", "profile", "hi") + expect(keyA).toBe("user%253A123:456:profile:hi") + expect(keyB).toBe("user%3A123:456:profile:hi") + expect(keyA).not.toBe(keyB) + }) +})