summaryrefslogtreecommitdiffhomepage
path: root/src/features/conversation-cache/logic.test.ts
blob: 858460a14a75b968e5a8e61d31a46d61cf936a8d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import type { StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationCacheIndexEntry } from "./types";

const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({
	seq,
	role,
	chunk: { type: "text", text: `chunk-${seq}` },
});

describe("reconcileCache", () => {
	it("merges and dedupes by seq", () => {
		const cached = [chunk(1), chunk(2)];
		const incoming = [chunk(2), chunk(3)];
		const result = reconcileCache(cached, incoming);
		expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]);
	});

	it("toAppend excludes already-cached seqs", () => {
		const cached = [chunk(1), chunk(2)];
		const incoming = [chunk(2), chunk(3)];
		const result = reconcileCache(cached, incoming);
		expect(result.toAppend).toEqual([chunk(3)]);
	});

	it("tolerates out-of-order incoming", () => {
		const cached = [chunk(1)];
		const incoming = [chunk(5), chunk(3), chunk(2)];
		const result = reconcileCache(cached, incoming);
		expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]);
		expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]);
	});

	it("returns empty merged and toAppend when both inputs are empty", () => {
		const result = reconcileCache([], []);
		expect(result.merged).toEqual([]);
		expect(result.toAppend).toEqual([]);
	});

	it("handles empty cached with incoming", () => {
		const incoming = [chunk(3), chunk(1)];
		const result = reconcileCache([], incoming);
		expect(result.merged).toEqual([chunk(1), chunk(3)]);
		expect(result.toAppend).toEqual([chunk(3), chunk(1)]);
	});

	it("handles cached with empty incoming", () => {
		const cached = [chunk(1), chunk(2)];
		const result = reconcileCache(cached, []);
		expect(result.merged).toEqual([chunk(1), chunk(2)]);
		expect(result.toAppend).toEqual([]);
	});

	it("is idempotent — re-reconciling same incoming produces same result", () => {
		const cached = [chunk(1)];
		const incoming = [chunk(2), chunk(3)];
		const first = reconcileCache(cached, incoming);
		const second = reconcileCache(first.merged, incoming);
		expect(second.merged).toEqual(first.merged);
		expect(second.toAppend).toEqual([]);
	});
});

describe("nextSinceSeq", () => {
	it("returns max seq", () => {
		const cached = [chunk(1), chunk(5), chunk(3)];
		expect(nextSinceSeq(cached)).toBe(5);
	});

	it("returns 0 when empty", () => {
		expect(nextSinceSeq([])).toBe(0);
	});

	it("returns single seq for single chunk", () => {
		expect(nextSinceSeq([chunk(42)])).toBe(42);
	});
});

describe("selectEvictions", () => {
	it("never evicts the active conversation", () => {
		const index: ConversationCacheIndexEntry[] = [
			{ conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 },
			{ conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 },
		];
		const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" });
		expect(result).not.toContain("active");
		expect(result).toContain("other");
	});

	it("evicts LRU until under budget", () => {
		const index: ConversationCacheIndexEntry[] = [
			{ conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
			{ conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 },
			{ conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 },
			{ conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 },
		];
		// Total = 120, max = 60, need to evict 60+ chunks
		// LRU order: d(10), b(50), a(100), c(200)
		const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
		expect(result).toEqual(["d", "b"]);
	});

	it("is a no-op under budget", () => {
		const index: ConversationCacheIndexEntry[] = [
			{ conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 },
			{ conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 },
		];
		const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null });
		expect(result).toEqual([]);
	});

	it("returns empty for empty index", () => {
		const result = selectEvictions([], { maxChunks: 100, activeConversationId: null });
		expect(result).toEqual([]);
	});

	it("tie-breaks by smaller maxSeq when lastAccess is equal", () => {
		const index: ConversationCacheIndexEntry[] = [
			{ conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 },
			{ conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 },
			{ conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 },
		];
		// Total = 90, max = 60, need to evict 30+ chunks
		// All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200)
		const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
		expect(result).toEqual(["b"]);
	});

	it("handles missing lastAccess (treated as 0)", () => {
		const index: ConversationCacheIndexEntry[] = [
			{ conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
			{ conversationId: "b", chunkCount: 30, maxSeq: 30 },
		];
		// Total = 60, max = 30, need to evict 30+ chunks
		// b has no lastAccess (0), a has 100
		const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null });
		expect(result).toEqual(["b"]);
	});
});