@vellumai/assistant 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/architecture/memory.md +105 -0
  3. package/docs/skills.md +100 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/archive-recall.test.ts +560 -0
  6. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  7. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  8. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  9. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  10. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  11. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  12. package/src/__tests__/conversation-wipe.test.ts +226 -0
  13. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  14. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  15. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  16. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  17. package/src/__tests__/inline-command-runner.test.ts +311 -0
  18. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  19. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  20. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  21. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  22. package/src/__tests__/memory-brief-time.test.ts +285 -0
  23. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  24. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  25. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  26. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  27. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  28. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  29. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  30. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  31. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  32. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  33. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  34. package/src/__tests__/memory-reducer-types.test.ts +707 -0
  35. package/src/__tests__/memory-reducer.test.ts +704 -0
  36. package/src/__tests__/memory-regressions.test.ts +30 -8
  37. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  38. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  39. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  40. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  41. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  42. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  43. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  44. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  45. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  46. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  47. package/src/cli/commands/conversations.ts +18 -0
  48. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  49. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  50. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  51. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  52. package/src/config/feature-flag-registry.json +16 -0
  53. package/src/config/raw-config-utils.ts +28 -0
  54. package/src/config/schema.ts +12 -0
  55. package/src/config/schemas/memory-simplified.ts +101 -0
  56. package/src/config/schemas/memory.ts +4 -0
  57. package/src/config/skills.ts +50 -4
  58. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  59. package/src/daemon/conversation-agent-loop.ts +71 -1
  60. package/src/daemon/conversation-lifecycle.ts +11 -1
  61. package/src/daemon/conversation-memory.ts +117 -0
  62. package/src/daemon/conversation-runtime-assembly.ts +3 -1
  63. package/src/daemon/conversation-surfaces.ts +31 -8
  64. package/src/daemon/conversation.ts +40 -23
  65. package/src/daemon/handlers/config-embeddings.ts +10 -2
  66. package/src/daemon/handlers/config-model.ts +0 -9
  67. package/src/daemon/handlers/conversations.ts +11 -0
  68. package/src/daemon/handlers/identity.ts +12 -1
  69. package/src/daemon/lifecycle.ts +52 -1
  70. package/src/daemon/message-types/conversations.ts +0 -1
  71. package/src/daemon/server.ts +1 -1
  72. package/src/followups/followup-store.ts +47 -1
  73. package/src/memory/archive-recall.ts +516 -0
  74. package/src/memory/archive-store.ts +400 -0
  75. package/src/memory/brief-formatting.ts +33 -0
  76. package/src/memory/brief-open-loops.ts +266 -0
  77. package/src/memory/brief-time.ts +162 -0
  78. package/src/memory/brief.ts +75 -0
  79. package/src/memory/conversation-crud.ts +455 -101
  80. package/src/memory/conversation-key-store.ts +33 -4
  81. package/src/memory/db-init.ts +16 -0
  82. package/src/memory/indexer.ts +106 -15
  83. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  84. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  85. package/src/memory/job-handlers/embedding.test.ts +1 -0
  86. package/src/memory/job-handlers/embedding.ts +83 -0
  87. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  88. package/src/memory/job-utils.ts +1 -1
  89. package/src/memory/jobs-store.ts +8 -0
  90. package/src/memory/jobs-worker.ts +20 -0
  91. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  92. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  93. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  94. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  95. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  96. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  97. package/src/memory/migrations/186-memory-archive.ts +109 -0
  98. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  99. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  100. package/src/memory/migrations/index.ts +4 -0
  101. package/src/memory/qdrant-client.ts +23 -4
  102. package/src/memory/reducer-scheduler.ts +242 -0
  103. package/src/memory/reducer-store.ts +271 -0
  104. package/src/memory/reducer-types.ts +106 -0
  105. package/src/memory/reducer.ts +467 -0
  106. package/src/memory/schema/conversations.ts +3 -0
  107. package/src/memory/schema/index.ts +2 -0
  108. package/src/memory/schema/infrastructure.ts +1 -0
  109. package/src/memory/schema/memory-archive.ts +121 -0
  110. package/src/memory/schema/memory-brief.ts +55 -0
  111. package/src/memory/search/semantic.ts +17 -4
  112. package/src/oauth/oauth-store.ts +3 -1
  113. package/src/permissions/checker.ts +89 -6
  114. package/src/permissions/defaults.ts +14 -0
  115. package/src/runtime/auth/route-policy.ts +10 -1
  116. package/src/runtime/routes/conversation-management-routes.ts +94 -2
  117. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  118. package/src/runtime/routes/conversation-routes.ts +52 -5
  119. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  120. package/src/runtime/routes/identity-routes.ts +2 -35
  121. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  122. package/src/runtime/routes/memory-item-routes.ts +90 -5
  123. package/src/runtime/routes/secret-routes.ts +3 -0
  124. package/src/runtime/routes/surface-action-routes.ts +68 -1
  125. package/src/schedule/schedule-store.ts +28 -0
  126. package/src/schedule/scheduler.ts +6 -2
  127. package/src/skills/inline-command-expansions.ts +204 -0
  128. package/src/skills/inline-command-render.ts +127 -0
  129. package/src/skills/inline-command-runner.ts +242 -0
  130. package/src/skills/transitive-version-hash.ts +88 -0
  131. package/src/tasks/task-store.ts +43 -1
  132. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  133. package/src/tools/filesystem/edit.ts +6 -1
  134. package/src/tools/filesystem/read.ts +6 -1
  135. package/src/tools/filesystem/write.ts +6 -1
  136. package/src/tools/memory/handlers.ts +129 -1
  137. package/src/tools/permission-checker.ts +8 -1
  138. package/src/tools/schedule/create.ts +3 -0
  139. package/src/tools/schedule/list.ts +5 -1
  140. package/src/tools/schedule/update.ts +6 -0
  141. package/src/tools/skills/load.ts +140 -6
  142. package/src/util/platform.ts +18 -0
  143. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  144. package/src/workspace/migrations/registry.ts +1 -1
@@ -0,0 +1,473 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ // ── Test directory & platform mocks ───────────────────────────────
7
+
8
+ const testDir = mkdtempSync(join(tmpdir(), "reducer-scheduling-test-"));
9
+
10
+ mock.module("../util/platform.js", () => ({
11
+ getDataDir: () => testDir,
12
+ getRootDir: () => testDir,
13
+ getWorkspaceDir: () => join(testDir, "workspace"),
14
+ getConversationsDir: () => join(testDir, "workspace", "conversations"),
15
+ isMacOS: () => process.platform === "darwin",
16
+ isLinux: () => process.platform === "linux",
17
+ isWindows: () => process.platform === "win32",
18
+ getPidPath: () => join(testDir, "test.pid"),
19
+ getDbPath: () => join(testDir, "test.db"),
20
+ getLogPath: () => join(testDir, "test.log"),
21
+ ensureDataDir: () => {},
22
+ }));
23
+
24
+ mock.module("../util/logger.js", () => ({
25
+ getLogger: () =>
26
+ new Proxy({} as Record<string, unknown>, {
27
+ get: () => () => {},
28
+ }),
29
+ }));
30
+
31
+ // ── Config mock — controllable idleDelayMs ───────────────────────
32
+
33
+ let mockIdleDelayMs = 30_000;
34
+
35
+ mock.module("../config/loader.js", () => ({
36
+ getConfig: () => ({
37
+ memory: {
38
+ simplified: {
39
+ reducer: {
40
+ idleDelayMs: mockIdleDelayMs,
41
+ switchWaitMs: 5_000,
42
+ },
43
+ },
44
+ },
45
+ }),
46
+ loadConfig: () => ({
47
+ memory: {
48
+ simplified: {
49
+ reducer: {
50
+ idleDelayMs: mockIdleDelayMs,
51
+ switchWaitMs: 5_000,
52
+ },
53
+ },
54
+ },
55
+ }),
56
+ }));
57
+
58
+ // ── Suppress disk-view side effects ──────────────────────────────
59
+
60
+ mock.module("../memory/conversation-disk-view.js", () => ({
61
+ initConversationDir: () => {},
62
+ removeConversationDir: () => {},
63
+ syncMessageToDisk: () => {},
64
+ updateMetaFile: () => {},
65
+ }));
66
+
67
+ // ── Suppress indexer side effects ────────────────────────────────
68
+
69
+ mock.module("../memory/indexer.js", () => ({
70
+ indexMessageNow: async () => {},
71
+ }));
72
+
73
+ // ── Suppress attention side effects ──────────────────────────────
74
+
75
+ mock.module("../memory/conversation-attention-store.js", () => ({
76
+ projectAssistantMessage: () => {},
77
+ seedForkedConversationAttention: () => {},
78
+ }));
79
+
80
+ // ── Imports (after mocks) ─────────────────────────────────────────
81
+
82
+ import {
83
+ markConversationMemoryDirty,
84
+ scheduleReducerJob,
85
+ sweepStaleReducerJobs,
86
+ } from "../memory/conversation-crud.js";
87
+ import { initializeDb, resetDb } from "../memory/db.js";
88
+ import { getSqlite } from "../memory/db-connection.js";
89
+ import { resetTestTables } from "../memory/raw-query.js";
90
+
91
+ initializeDb();
92
+
93
+ // ── Helpers ───────────────────────────────────────────────────────
94
+
95
+ const NOW = 1_700_000_000_000;
96
+
97
+ function insertConversation(
98
+ id: string,
99
+ opts?: {
100
+ dirtyTailMessageId?: string | null;
101
+ createdAt?: number;
102
+ },
103
+ ): void {
104
+ const raw = getSqlite();
105
+ raw.run(
106
+ `INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title,
107
+ memory_dirty_tail_since_message_id)
108
+ VALUES (?, 'Test', ?, ?, 'standard', 'user', 'default', 1, ?)`,
109
+ [
110
+ id,
111
+ opts?.createdAt ?? NOW,
112
+ opts?.createdAt ?? NOW,
113
+ opts?.dirtyTailMessageId ?? null,
114
+ ],
115
+ );
116
+ }
117
+
118
+ function insertMessage(opts: {
119
+ id: string;
120
+ conversationId: string;
121
+ role?: string;
122
+ content?: string;
123
+ createdAt?: number;
124
+ }): void {
125
+ const raw = getSqlite();
126
+ raw.run(
127
+ `INSERT INTO messages (id, conversation_id, role, content, created_at)
128
+ VALUES (?, ?, ?, ?, ?)`,
129
+ [
130
+ opts.id,
131
+ opts.conversationId,
132
+ opts.role ?? "user",
133
+ opts.content ?? "test message",
134
+ opts.createdAt ?? NOW,
135
+ ],
136
+ );
137
+ }
138
+
139
+ function getReducerJobs(
140
+ conversationId: string,
141
+ ): Array<Record<string, unknown>> {
142
+ const raw = getSqlite();
143
+ return raw
144
+ .query(
145
+ `SELECT * FROM memory_jobs
146
+ WHERE type = 'reduce_conversation_memory'
147
+ AND json_extract(payload, '$.conversationId') = ?
148
+ ORDER BY created_at ASC`,
149
+ )
150
+ .all(conversationId) as Array<Record<string, unknown>>;
151
+ }
152
+
153
+ function insertReducerJob(
154
+ conversationId: string,
155
+ opts?: { status?: string; runAfter?: number },
156
+ ): void {
157
+ const raw = getSqlite();
158
+ const now = Date.now();
159
+ raw.run(
160
+ `INSERT INTO memory_jobs (id, type, payload, status, attempts, deferrals, run_after, created_at, updated_at)
161
+ VALUES (?, 'reduce_conversation_memory', ?, ?, 0, 0, ?, ?, ?)`,
162
+ [
163
+ `job-${conversationId}-${now}`,
164
+ JSON.stringify({ conversationId }),
165
+ opts?.status ?? "pending",
166
+ opts?.runAfter ?? now + 30_000,
167
+ now,
168
+ now,
169
+ ],
170
+ );
171
+ }
172
+
173
+ // ── Teardown ──────────────────────────────────────────────────────
174
+
175
+ afterAll(() => {
176
+ resetDb();
177
+ try {
178
+ rmSync(testDir, { recursive: true });
179
+ } catch {
180
+ /* best effort */
181
+ }
182
+ });
183
+
184
+ beforeEach(() => {
185
+ resetTestTables("messages", "conversations", "memory_jobs");
186
+ mockIdleDelayMs = 30_000;
187
+ });
188
+
189
+ // ── Tests ─────────────────────────────────────────────────────────
190
+
191
+ describe("markConversationMemoryDirty — reducer job scheduling", () => {
192
+ test("creates a pending reducer job on first dirty mark", () => {
193
+ insertConversation("conv-1");
194
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
195
+
196
+ markConversationMemoryDirty("conv-1", "msg-1");
197
+
198
+ const jobs = getReducerJobs("conv-1");
199
+ expect(jobs).toHaveLength(1);
200
+ expect(jobs[0].status).toBe("pending");
201
+ expect(jobs[0].type).toBe("reduce_conversation_memory");
202
+ });
203
+
204
+ test("schedules reducer job with idleDelayMs offset from now", () => {
205
+ mockIdleDelayMs = 60_000;
206
+ insertConversation("conv-1");
207
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
208
+
209
+ const before = Date.now();
210
+ markConversationMemoryDirty("conv-1", "msg-1");
211
+ const after = Date.now();
212
+
213
+ const jobs = getReducerJobs("conv-1");
214
+ expect(jobs).toHaveLength(1);
215
+ const runAfter = jobs[0].run_after as number;
216
+ // runAfter should be approximately now + 60_000
217
+ expect(runAfter).toBeGreaterThanOrEqual(before + 60_000);
218
+ expect(runAfter).toBeLessThanOrEqual(after + 60_000);
219
+ });
220
+
221
+ test("deduplicates: second mark does not create a second job", () => {
222
+ insertConversation("conv-1");
223
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
224
+ insertMessage({
225
+ id: "msg-2",
226
+ conversationId: "conv-1",
227
+ createdAt: NOW + 1000,
228
+ });
229
+
230
+ markConversationMemoryDirty("conv-1", "msg-1");
231
+ markConversationMemoryDirty("conv-1", "msg-2");
232
+
233
+ const jobs = getReducerJobs("conv-1");
234
+ expect(jobs).toHaveLength(1);
235
+ });
236
+
237
+ test("reschedules: second mark pushes runAfter forward", () => {
238
+ mockIdleDelayMs = 10_000;
239
+ insertConversation("conv-1");
240
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
241
+
242
+ markConversationMemoryDirty("conv-1", "msg-1");
243
+ const jobs1 = getReducerJobs("conv-1");
244
+ const firstRunAfter = jobs1[0].run_after as number;
245
+
246
+ // Simulate a short delay before the next message
247
+ const pauseMs = 50;
248
+ Bun.sleepSync(pauseMs);
249
+
250
+ insertMessage({
251
+ id: "msg-2",
252
+ conversationId: "conv-1",
253
+ createdAt: NOW + 5000,
254
+ });
255
+ markConversationMemoryDirty("conv-1", "msg-2");
256
+
257
+ const jobs2 = getReducerJobs("conv-1");
258
+ expect(jobs2).toHaveLength(1);
259
+ const secondRunAfter = jobs2[0].run_after as number;
260
+ // The second runAfter should be later than the first
261
+ expect(secondRunAfter).toBeGreaterThan(firstRunAfter);
262
+ });
263
+
264
+ test("creates separate jobs for different conversations", () => {
265
+ insertConversation("conv-1");
266
+ insertConversation("conv-2");
267
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
268
+ insertMessage({ id: "msg-2", conversationId: "conv-2" });
269
+
270
+ markConversationMemoryDirty("conv-1", "msg-1");
271
+ markConversationMemoryDirty("conv-2", "msg-2");
272
+
273
+ const jobs1 = getReducerJobs("conv-1");
274
+ const jobs2 = getReducerJobs("conv-2");
275
+ expect(jobs1).toHaveLength(1);
276
+ expect(jobs2).toHaveLength(1);
277
+ // They should be different job rows
278
+ expect(jobs1[0].id).not.toBe(jobs2[0].id);
279
+ });
280
+
281
+ test("does not reschedule completed or failed jobs", () => {
282
+ insertConversation("conv-1");
283
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
284
+
285
+ // Insert a completed job for this conversation
286
+ insertReducerJob("conv-1", { status: "completed" });
287
+
288
+ markConversationMemoryDirty("conv-1", "msg-1");
289
+
290
+ // Should create a new pending job (not reuse the completed one)
291
+ const jobs = getReducerJobs("conv-1");
292
+ const pendingJobs = jobs.filter((j) => j.status === "pending");
293
+ expect(pendingJobs).toHaveLength(1);
294
+ });
295
+
296
+ test("does not reschedule running jobs", () => {
297
+ insertConversation("conv-1");
298
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
299
+
300
+ // Insert a running job for this conversation
301
+ insertReducerJob("conv-1", { status: "running" });
302
+
303
+ markConversationMemoryDirty("conv-1", "msg-1");
304
+
305
+ // Should create a new pending job since we only look at pending for rescheduling
306
+ const jobs = getReducerJobs("conv-1");
307
+ const pendingJobs = jobs.filter((j) => j.status === "pending");
308
+ expect(pendingJobs).toHaveLength(1);
309
+ });
310
+ });
311
+
312
+ describe("scheduleReducerJob — explicit runAfter override", () => {
313
+ test("accepts a custom runAfter timestamp", () => {
314
+ insertConversation("conv-1");
315
+
316
+ const customRunAfter = NOW + 999_999;
317
+ scheduleReducerJob("conv-1", customRunAfter);
318
+
319
+ const jobs = getReducerJobs("conv-1");
320
+ expect(jobs).toHaveLength(1);
321
+ expect(jobs[0].run_after).toBe(customRunAfter);
322
+ });
323
+ });
324
+
325
+ describe("sweepStaleReducerJobs — startup sweep", () => {
326
+ test("enqueues immediate jobs for stale dirty conversations", () => {
327
+ mockIdleDelayMs = 30_000;
328
+ const oldTime = Date.now() - 60_000; // Well past idle delay
329
+
330
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
331
+ insertMessage({
332
+ id: "msg-1",
333
+ conversationId: "conv-1",
334
+ createdAt: oldTime,
335
+ });
336
+
337
+ const count = sweepStaleReducerJobs();
338
+
339
+ expect(count).toBe(1);
340
+ const jobs = getReducerJobs("conv-1");
341
+ expect(jobs).toHaveLength(1);
342
+ expect(jobs[0].status).toBe("pending");
343
+ // Should be scheduled for immediate execution (runAfter <= now)
344
+ expect(jobs[0].run_after as number).toBeLessThanOrEqual(Date.now());
345
+ });
346
+
347
+ test("skips conversations that are not dirty", () => {
348
+ const oldTime = Date.now() - 60_000;
349
+
350
+ // Not dirty — no dirtyTailMessageId
351
+ insertConversation("conv-1");
352
+ insertMessage({
353
+ id: "msg-1",
354
+ conversationId: "conv-1",
355
+ createdAt: oldTime,
356
+ });
357
+
358
+ const count = sweepStaleReducerJobs();
359
+ expect(count).toBe(0);
360
+ expect(getReducerJobs("conv-1")).toHaveLength(0);
361
+ });
362
+
363
+ test("skips dirty conversations whose tail is within the idle window", () => {
364
+ mockIdleDelayMs = 30_000;
365
+ const recentTime = Date.now() - 5_000; // Only 5s ago, within idle delay
366
+
367
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
368
+ insertMessage({
369
+ id: "msg-1",
370
+ conversationId: "conv-1",
371
+ createdAt: recentTime,
372
+ });
373
+
374
+ const count = sweepStaleReducerJobs();
375
+ expect(count).toBe(0);
376
+ expect(getReducerJobs("conv-1")).toHaveLength(0);
377
+ });
378
+
379
+ test("skips conversations that already have a pending reducer job", () => {
380
+ mockIdleDelayMs = 30_000;
381
+ const oldTime = Date.now() - 60_000;
382
+
383
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
384
+ insertMessage({
385
+ id: "msg-1",
386
+ conversationId: "conv-1",
387
+ createdAt: oldTime,
388
+ });
389
+ insertReducerJob("conv-1", { status: "pending" });
390
+
391
+ const count = sweepStaleReducerJobs();
392
+ expect(count).toBe(0);
393
+ // Only the pre-existing job should be there
394
+ const jobs = getReducerJobs("conv-1");
395
+ expect(jobs).toHaveLength(1);
396
+ });
397
+
398
+ test("skips conversations that have a running reducer job", () => {
399
+ mockIdleDelayMs = 30_000;
400
+ const oldTime = Date.now() - 60_000;
401
+
402
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
403
+ insertMessage({
404
+ id: "msg-1",
405
+ conversationId: "conv-1",
406
+ createdAt: oldTime,
407
+ });
408
+ insertReducerJob("conv-1", { status: "running" });
409
+
410
+ const count = sweepStaleReducerJobs();
411
+ expect(count).toBe(0);
412
+ });
413
+
414
+ test("sweeps multiple stale conversations", () => {
415
+ mockIdleDelayMs = 30_000;
416
+ const oldTime = Date.now() - 60_000;
417
+
418
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
419
+ insertMessage({
420
+ id: "msg-1",
421
+ conversationId: "conv-1",
422
+ createdAt: oldTime,
423
+ });
424
+
425
+ insertConversation("conv-2", { dirtyTailMessageId: "msg-2" });
426
+ insertMessage({
427
+ id: "msg-2",
428
+ conversationId: "conv-2",
429
+ createdAt: oldTime - 1000,
430
+ });
431
+
432
+ const count = sweepStaleReducerJobs();
433
+ expect(count).toBe(2);
434
+ expect(getReducerJobs("conv-1")).toHaveLength(1);
435
+ expect(getReducerJobs("conv-2")).toHaveLength(1);
436
+ });
437
+
438
+ test("only enqueues for stale conversations in a mixed set", () => {
439
+ mockIdleDelayMs = 30_000;
440
+ const oldTime = Date.now() - 60_000;
441
+ const recentTime = Date.now() - 5_000;
442
+
443
+ // Stale dirty conversation
444
+ insertConversation("conv-stale", { dirtyTailMessageId: "msg-1" });
445
+ insertMessage({
446
+ id: "msg-1",
447
+ conversationId: "conv-stale",
448
+ createdAt: oldTime,
449
+ });
450
+
451
+ // Recent dirty conversation (within idle window)
452
+ insertConversation("conv-recent", { dirtyTailMessageId: "msg-2" });
453
+ insertMessage({
454
+ id: "msg-2",
455
+ conversationId: "conv-recent",
456
+ createdAt: recentTime,
457
+ });
458
+
459
+ // Clean conversation
460
+ insertConversation("conv-clean");
461
+ insertMessage({
462
+ id: "msg-3",
463
+ conversationId: "conv-clean",
464
+ createdAt: oldTime,
465
+ });
466
+
467
+ const count = sweepStaleReducerJobs();
468
+ expect(count).toBe(1);
469
+ expect(getReducerJobs("conv-stale")).toHaveLength(1);
470
+ expect(getReducerJobs("conv-recent")).toHaveLength(0);
471
+ expect(getReducerJobs("conv-clean")).toHaveLength(0);
472
+ });
473
+ });