@vellumai/assistant 0.5.5 → 0.5.6

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 (102) hide show
  1. package/Dockerfile +3 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +113 -0
  4. package/src/__tests__/config-schema.test.ts +2 -2
  5. package/src/__tests__/context-window-manager.test.ts +78 -0
  6. package/src/__tests__/conversation-title-service.test.ts +30 -1
  7. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  8. package/src/__tests__/memory-regressions.test.ts +8 -30
  9. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  10. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  11. package/src/__tests__/tool-executor.test.ts +4 -0
  12. package/src/cli/commands/conversations.ts +0 -18
  13. package/src/config/env.ts +8 -2
  14. package/src/config/feature-flag-registry.json +0 -8
  15. package/src/config/schema.ts +0 -12
  16. package/src/config/schemas/memory.ts +0 -4
  17. package/src/config/schemas/platform.ts +1 -1
  18. package/src/config/schemas/security.ts +4 -0
  19. package/src/context/window-manager.ts +53 -2
  20. package/src/daemon/config-watcher.ts +1 -4
  21. package/src/daemon/conversation-agent-loop.ts +0 -60
  22. package/src/daemon/conversation-memory.ts +0 -117
  23. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  24. package/src/daemon/handlers/conversations.ts +0 -11
  25. package/src/daemon/lifecycle.ts +3 -46
  26. package/src/followups/followup-store.ts +5 -2
  27. package/src/memory/conversation-crud.ts +0 -236
  28. package/src/memory/conversation-title-service.ts +26 -10
  29. package/src/memory/db-init.ts +5 -13
  30. package/src/memory/indexer.ts +15 -106
  31. package/src/memory/job-handlers/embedding.ts +0 -79
  32. package/src/memory/job-utils.ts +1 -1
  33. package/src/memory/jobs-store.ts +0 -8
  34. package/src/memory/jobs-worker.ts +0 -20
  35. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  36. package/src/memory/migrations/index.ts +1 -3
  37. package/src/memory/qdrant-client.ts +4 -6
  38. package/src/memory/schema/conversations.ts +0 -3
  39. package/src/memory/schema/index.ts +0 -2
  40. package/src/messaging/draft-store.ts +2 -2
  41. package/src/permissions/defaults.ts +3 -3
  42. package/src/permissions/trust-client.ts +2 -13
  43. package/src/permissions/trust-store.ts +8 -3
  44. package/src/runtime/auth/route-policy.ts +14 -0
  45. package/src/runtime/auth/token-service.ts +133 -0
  46. package/src/runtime/http-server.ts +2 -0
  47. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  48. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  49. package/src/runtime/routes/conversation-routes.ts +2 -1
  50. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  51. package/src/runtime/routes/memory-item-routes.ts +124 -2
  52. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  53. package/src/schedule/schedule-store.ts +0 -21
  54. package/src/skills/inline-command-render.ts +5 -1
  55. package/src/skills/inline-command-runner.ts +30 -2
  56. package/src/tools/memory/handlers.ts +1 -129
  57. package/src/tools/permission-checker.ts +18 -0
  58. package/src/tools/skills/load.ts +9 -2
  59. package/src/util/platform.ts +5 -5
  60. package/src/util/xml.ts +8 -0
  61. package/src/workspace/heartbeat-service.ts +5 -24
  62. package/src/__tests__/archive-recall.test.ts +0 -560
  63. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  64. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  65. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  66. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  67. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  68. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  69. package/src/__tests__/memory-brief-time.test.ts +0 -285
  70. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  71. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  72. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  73. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  74. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  75. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  76. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  77. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  78. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  79. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  80. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  81. package/src/__tests__/memory-reducer.test.ts +0 -704
  82. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  83. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  84. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  85. package/src/config/schemas/memory-simplified.ts +0 -101
  86. package/src/memory/archive-recall.ts +0 -516
  87. package/src/memory/archive-store.ts +0 -400
  88. package/src/memory/brief-formatting.ts +0 -33
  89. package/src/memory/brief-open-loops.ts +0 -266
  90. package/src/memory/brief-time.ts +0 -162
  91. package/src/memory/brief.ts +0 -75
  92. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  93. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  94. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  95. package/src/memory/migrations/186-memory-archive.ts +0 -109
  96. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  97. package/src/memory/reducer-scheduler.ts +0 -242
  98. package/src/memory/reducer-store.ts +0 -271
  99. package/src/memory/reducer-types.ts +0 -106
  100. package/src/memory/reducer.ts +0 -467
  101. package/src/memory/schema/memory-archive.ts +0 -121
  102. package/src/memory/schema/memory-brief.ts +0 -55
@@ -1,530 +0,0 @@
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
- const testDir = mkdtempSync(join(tmpdir(), "brief-open-loops-test-"));
7
-
8
- mock.module("../util/platform.js", () => ({
9
- getDataDir: () => testDir,
10
- getRootDir: () => testDir,
11
- isMacOS: () => process.platform === "darwin",
12
- isLinux: () => process.platform === "linux",
13
- isWindows: () => process.platform === "win32",
14
- getPidPath: () => join(testDir, "test.pid"),
15
- getDbPath: () => join(testDir, "test.db"),
16
- getLogPath: () => join(testDir, "test.log"),
17
- ensureDataDir: () => {},
18
- }));
19
-
20
- mock.module("../util/logger.js", () => ({
21
- getLogger: () =>
22
- new Proxy({} as Record<string, unknown>, {
23
- get: () => () => {},
24
- }),
25
- }));
26
-
27
- import { createFollowUp } from "../followups/followup-store.js";
28
- import { compileOpenLoopBrief } from "../memory/brief-open-loops.js";
29
- import { initializeDb, resetDb } from "../memory/db.js";
30
- import { getSqlite } from "../memory/db-connection.js";
31
- import { resetTestTables } from "../memory/raw-query.js";
32
- import { createTask } from "../tasks/task-store.js";
33
-
34
- initializeDb();
35
-
36
- // ── Helpers ──────────────────────────────────────────────────────────
37
-
38
- const SCOPE = "test-scope";
39
- const MS_HOUR = 60 * 60 * 1000;
40
- const MS_DAY = 24 * MS_HOUR;
41
-
42
- /** Get the raw bun:sqlite Database for parameterized inserts. */
43
- function getRawDb(): import("bun:sqlite").Database {
44
- return getSqlite();
45
- }
46
-
47
- function insertOpenLoop(opts: {
48
- id: string;
49
- summary: string;
50
- dueAt?: number | null;
51
- surfacedAt?: number | null;
52
- status?: string;
53
- updatedAt?: number;
54
- createdAt?: number;
55
- }): void {
56
- const raw = getRawDb();
57
- const now = Date.now();
58
- raw.run(
59
- `INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, surfaced_at, created_at, updated_at)
60
- VALUES (?, ?, ?, ?, 'conversation', ?, ?, ?, ?)`,
61
- [
62
- opts.id,
63
- SCOPE,
64
- opts.summary,
65
- opts.status ?? "open",
66
- opts.dueAt ?? null,
67
- opts.surfacedAt ?? null,
68
- opts.createdAt ?? now,
69
- opts.updatedAt ?? now,
70
- ],
71
- );
72
- }
73
-
74
- function insertWorkItem(opts: {
75
- id: string;
76
- taskId: string;
77
- title: string;
78
- status?: string;
79
- priorityTier?: number;
80
- updatedAt?: number;
81
- }): void {
82
- const raw = getRawDb();
83
- const now = Date.now();
84
- raw.run(
85
- `INSERT INTO work_items (id, task_id, title, status, priority_tier, sort_index, created_at, updated_at)
86
- VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
87
- [
88
- opts.id,
89
- opts.taskId,
90
- opts.title,
91
- opts.status ?? "queued",
92
- opts.priorityTier ?? 1,
93
- now,
94
- opts.updatedAt ?? now,
95
- ],
96
- );
97
- }
98
-
99
- // ── Teardown ─────────────────────────────────────────────────────────
100
-
101
- afterAll(() => {
102
- resetDb();
103
- try {
104
- rmSync(testDir, { recursive: true });
105
- } catch {
106
- /* best effort */
107
- }
108
- });
109
-
110
- beforeEach(() => {
111
- resetTestTables(
112
- "open_loops",
113
- "work_items",
114
- "tasks",
115
- "task_runs",
116
- "followups",
117
- );
118
- });
119
-
120
- // ── Tests ────────────────────────────────────────────────────────────
121
-
122
- describe("compileOpenLoopBrief", () => {
123
- test("returns empty when no data exists", () => {
124
- const result = compileOpenLoopBrief(SCOPE, "msg-1");
125
- expect(result.bullets).toEqual([]);
126
- expect(result.resurfacedLoopId).toBeNull();
127
- });
128
-
129
- // ── Tier ranking ──────────────────────────────────────────────────
130
-
131
- describe("tier ranking", () => {
132
- test("overdue loops are tier 1", () => {
133
- const now = Date.now();
134
- insertOpenLoop({
135
- id: "ol-overdue",
136
- summary: "Overdue task",
137
- dueAt: now - MS_HOUR,
138
- updatedAt: now - MS_DAY * 10,
139
- });
140
-
141
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
142
- expect(result.bullets).toHaveLength(1);
143
- expect(result.bullets[0].tier).toBe(1);
144
- expect(result.bullets[0].summary).toBe("Overdue task");
145
- });
146
-
147
- test("loops due within 24h are tier 2", () => {
148
- const now = Date.now();
149
- insertOpenLoop({
150
- id: "ol-24h",
151
- summary: "Due soon",
152
- dueAt: now + 12 * MS_HOUR,
153
- updatedAt: now - MS_DAY * 10,
154
- });
155
-
156
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
157
- expect(result.bullets).toHaveLength(1);
158
- expect(result.bullets[0].tier).toBe(2);
159
- });
160
-
161
- test("loops due within 7d are tier 3", () => {
162
- const now = Date.now();
163
- insertOpenLoop({
164
- id: "ol-7d",
165
- summary: "Due this week",
166
- dueAt: now + 3 * MS_DAY,
167
- updatedAt: now - MS_DAY * 10,
168
- });
169
-
170
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
171
- expect(result.bullets).toHaveLength(1);
172
- expect(result.bullets[0].tier).toBe(3);
173
- });
174
-
175
- test("high-priority work items are tier 4", () => {
176
- const now = Date.now();
177
- const task = createTask({ title: "t", template: "t" });
178
- insertWorkItem({
179
- id: "wi-high",
180
- taskId: task.id,
181
- title: "High priority item",
182
- priorityTier: 0,
183
- updatedAt: now - MS_DAY * 10,
184
- });
185
-
186
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
187
- const wiBullet = result.bullets.find((b) => b.source === "work_item");
188
- expect(wiBullet).toBeDefined();
189
- expect(wiBullet!.tier).toBe(4);
190
- });
191
-
192
- test("recently touched loops are tier 5", () => {
193
- const now = Date.now();
194
- insertOpenLoop({
195
- id: "ol-recent",
196
- summary: "Just updated",
197
- updatedAt: now - MS_HOUR,
198
- });
199
-
200
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
201
- expect(result.bullets).toHaveLength(1);
202
- expect(result.bullets[0].tier).toBe(5);
203
- });
204
-
205
- test("bullets are sorted by tier ascending", () => {
206
- const now = Date.now();
207
- insertOpenLoop({
208
- id: "ol-tier3",
209
- summary: "Due this week",
210
- dueAt: now + 3 * MS_DAY,
211
- updatedAt: now - MS_DAY * 10,
212
- });
213
- insertOpenLoop({
214
- id: "ol-tier1",
215
- summary: "Overdue",
216
- dueAt: now - MS_HOUR,
217
- updatedAt: now - MS_DAY * 10,
218
- });
219
- insertOpenLoop({
220
- id: "ol-tier5",
221
- summary: "Recent",
222
- updatedAt: now - MS_HOUR,
223
- });
224
-
225
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
226
- const tiers = result.bullets.map((b) => b.tier);
227
- expect(tiers).toEqual([...tiers].sort((a, b) => a - b));
228
- expect(tiers[0]).toBe(1);
229
- });
230
- });
231
-
232
- // ── Deduplication ─────────────────────────────────────────────────
233
-
234
- describe("deduplication", () => {
235
- test("work items with the same summary as an open loop are deduplicated", () => {
236
- const now = Date.now();
237
- const task = createTask({ title: "t", template: "t" });
238
-
239
- insertOpenLoop({
240
- id: "ol-dup",
241
- summary: "Review PR",
242
- dueAt: now + MS_HOUR,
243
- updatedAt: now,
244
- });
245
- insertWorkItem({
246
- id: "wi-dup",
247
- taskId: task.id,
248
- title: "Review PR",
249
- updatedAt: now,
250
- });
251
-
252
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
253
- const summaries = result.bullets.map((b) => b.summary);
254
- // Should only appear once (from the loop)
255
- expect(summaries.filter((s) => s === "Review PR")).toHaveLength(1);
256
- expect(
257
- result.bullets.find((b) => b.summary === "Review PR")!.source,
258
- ).toBe("loop");
259
- });
260
-
261
- test("case-insensitive deduplication", () => {
262
- const now = Date.now();
263
- const task = createTask({ title: "t", template: "t" });
264
-
265
- insertOpenLoop({
266
- id: "ol-case",
267
- summary: "deploy release",
268
- dueAt: now + MS_HOUR,
269
- updatedAt: now,
270
- });
271
- insertWorkItem({
272
- id: "wi-case",
273
- taskId: task.id,
274
- title: "Deploy Release",
275
- updatedAt: now,
276
- });
277
-
278
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
279
- const matching = result.bullets.filter(
280
- (b) => b.summary.toLowerCase() === "deploy release",
281
- );
282
- expect(matching).toHaveLength(1);
283
- });
284
-
285
- test("unique keys are not deduplicated", () => {
286
- const now = Date.now();
287
- const task = createTask({ title: "t", template: "t" });
288
-
289
- insertOpenLoop({
290
- id: "ol-a",
291
- summary: "Fix bug",
292
- dueAt: now + MS_HOUR,
293
- updatedAt: now,
294
- });
295
- insertWorkItem({
296
- id: "wi-b",
297
- taskId: task.id,
298
- title: "Write tests",
299
- updatedAt: now,
300
- });
301
-
302
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
303
- expect(result.bullets).toHaveLength(2);
304
- });
305
- });
306
-
307
- // ── Follow-up integration ────────────────────────────────────────
308
-
309
- describe("follow-ups", () => {
310
- test("overdue follow-ups appear as tier 1", () => {
311
- const now = Date.now();
312
- createFollowUp({
313
- channel: "email",
314
- conversationId: "conv-1",
315
- expectedResponseBy: now - MS_HOUR,
316
- });
317
-
318
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
319
- const fuBullet = result.bullets.find((b) => b.source === "followup");
320
- expect(fuBullet).toBeDefined();
321
- expect(fuBullet!.tier).toBe(1);
322
- });
323
-
324
- test("pending follow-ups due within 24h are tier 2", () => {
325
- const now = Date.now();
326
- createFollowUp({
327
- channel: "slack",
328
- conversationId: "conv-2",
329
- expectedResponseBy: now + 12 * MS_HOUR,
330
- });
331
-
332
- const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
333
- const fuBullet = result.bullets.find((b) => b.source === "followup");
334
- expect(fuBullet).toBeDefined();
335
- expect(fuBullet!.tier).toBe(2);
336
- });
337
- });
338
-
339
- // ── Deterministic resurfacing ────────────────────────────────────
340
-
341
- describe("deterministic resurfacing", () => {
342
- test("resurfaces one low-salience loop from open_loops", () => {
343
- const now = Date.now();
344
- // Create loops that are old and have no due date → tier 6 (low salience)
345
- insertOpenLoop({
346
- id: "ol-old-1",
347
- summary: "Old loop 1",
348
- updatedAt: now - MS_DAY * 30,
349
- createdAt: now - MS_DAY * 30,
350
- });
351
- insertOpenLoop({
352
- id: "ol-old-2",
353
- summary: "Old loop 2",
354
- updatedAt: now - MS_DAY * 30,
355
- createdAt: now - MS_DAY * 30,
356
- });
357
- insertOpenLoop({
358
- id: "ol-old-3",
359
- summary: "Old loop 3",
360
- updatedAt: now - MS_DAY * 30,
361
- createdAt: now - MS_DAY * 30,
362
- });
363
-
364
- const result = compileOpenLoopBrief(SCOPE, "msg-resurface", now);
365
-
366
- // Exactly one should be resurfaced
367
- expect(result.resurfacedLoopId).not.toBeNull();
368
- expect(result.bullets).toHaveLength(1);
369
- expect(result.bullets[0].tier).toBe(5);
370
- });
371
-
372
- test("resurfacing is deterministic for the same seed", () => {
373
- const now = Date.now();
374
- for (let i = 1; i <= 5; i++) {
375
- insertOpenLoop({
376
- id: `ol-det-${i}`,
377
- summary: `Deterministic loop ${i}`,
378
- updatedAt: now - MS_DAY * 30,
379
- createdAt: now - MS_DAY * 30,
380
- });
381
- }
382
-
383
- const r1 = compileOpenLoopBrief(SCOPE, "msg-det", now);
384
-
385
- // Reset surfacedAt and updatedAt so the second call has same candidates
386
- // (updateLastSurfacedAt also writes updatedAt, which would change tier)
387
- const oldUpdatedAt = now - MS_DAY * 30;
388
- getRawDb().run(
389
- `UPDATE open_loops SET surfaced_at = NULL, updated_at = ?`,
390
- [oldUpdatedAt],
391
- );
392
-
393
- const r2 = compileOpenLoopBrief(SCOPE, "msg-det", now);
394
-
395
- expect(r1.resurfacedLoopId).toBe(r2.resurfacedLoopId);
396
- });
397
-
398
- test("different userMessageId produces different selection", () => {
399
- const now = Date.now();
400
- // Need enough loops that different seeds are likely to pick different ones
401
- for (let i = 1; i <= 20; i++) {
402
- insertOpenLoop({
403
- id: `ol-vary-${i}`,
404
- summary: `Varying loop ${i}`,
405
- updatedAt: now - MS_DAY * 30,
406
- createdAt: now - MS_DAY * 30,
407
- });
408
- }
409
-
410
- const selections = new Set<string | null>();
411
- const oldUpdatedAt = now - MS_DAY * 30;
412
- for (let i = 0; i < 10; i++) {
413
- // Reset surfacedAt and updatedAt between calls so all loops stay low-salience
414
- getRawDb().run(
415
- `UPDATE open_loops SET surfaced_at = NULL, updated_at = ?`,
416
- [oldUpdatedAt],
417
- );
418
- const r = compileOpenLoopBrief(SCOPE, `msg-vary-${i}`, now);
419
- selections.add(r.resurfacedLoopId);
420
- }
421
-
422
- // With 20 candidates and 10 different seeds, we should see at least 2
423
- // different selections (overwhelmingly likely)
424
- expect(selections.size).toBeGreaterThanOrEqual(2);
425
- });
426
-
427
- test("updates surfacedAt on the resurfaced loop", () => {
428
- const now = Date.now();
429
- insertOpenLoop({
430
- id: "ol-surf",
431
- summary: "Will be surfaced",
432
- updatedAt: now - MS_DAY * 30,
433
- createdAt: now - MS_DAY * 30,
434
- });
435
-
436
- const result = compileOpenLoopBrief(SCOPE, "msg-surf", now);
437
- expect(result.resurfacedLoopId).toBe("ol-surf");
438
-
439
- // Verify surfacedAt was written
440
- const surfaced = getRawDb()
441
- .query(`SELECT surfaced_at FROM open_loops WHERE id = 'ol-surf'`)
442
- .get() as { surfaced_at: number } | null;
443
- expect(surfaced).not.toBeNull();
444
- expect(surfaced!.surfaced_at).toBe(now);
445
- });
446
-
447
- test("low-salience work items are not resurfaced", () => {
448
- const now = Date.now();
449
- const task = createTask({ title: "t", template: "t" });
450
-
451
- // Only work items, no loops — should not resurface
452
- insertWorkItem({
453
- id: "wi-old",
454
- taskId: task.id,
455
- title: "Old work item",
456
- updatedAt: now - MS_DAY * 30,
457
- });
458
-
459
- const result = compileOpenLoopBrief(SCOPE, "msg-no-resurface", now);
460
- expect(result.resurfacedLoopId).toBeNull();
461
- // Work item is tier 6, so it should not appear in ranked output
462
- expect(result.bullets).toHaveLength(0);
463
- });
464
- });
465
-
466
- // ── Scope isolation ──────────────────────────────────────────────
467
-
468
- describe("scope isolation", () => {
469
- test("only includes loops from the specified scope", () => {
470
- const now = Date.now();
471
- insertOpenLoop({
472
- id: "ol-scope-a",
473
- summary: "In scope",
474
- dueAt: now + MS_HOUR,
475
- updatedAt: now,
476
- });
477
-
478
- // Insert loop for a different scope directly
479
- getRawDb().run(
480
- `INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, created_at, updated_at)
481
- VALUES ('ol-scope-b', 'other-scope', 'Out of scope', 'open', 'conversation', ?, ?, ?)`,
482
- [now + MS_HOUR, now, now],
483
- );
484
-
485
- const result = compileOpenLoopBrief(SCOPE, "msg-scope", now);
486
- expect(result.bullets).toHaveLength(1);
487
- expect(result.bullets[0].summary).toBe("In scope");
488
- });
489
- });
490
-
491
- // ── Mixed sources ────────────────────────────────────────────────
492
-
493
- describe("mixed sources", () => {
494
- test("merges loops, work items, and follow-ups without duplicates", () => {
495
- const now = Date.now();
496
- const task = createTask({ title: "t", template: "t" });
497
-
498
- insertOpenLoop({
499
- id: "ol-mix",
500
- summary: "Loop item",
501
- dueAt: now - MS_HOUR,
502
- updatedAt: now,
503
- });
504
- insertWorkItem({
505
- id: "wi-mix",
506
- taskId: task.id,
507
- title: "Work item",
508
- priorityTier: 0,
509
- updatedAt: now,
510
- });
511
- createFollowUp({
512
- channel: "email",
513
- conversationId: "conv-mix",
514
- expectedResponseBy: now + 6 * MS_HOUR,
515
- });
516
-
517
- const result = compileOpenLoopBrief(SCOPE, "msg-mix", now);
518
- expect(result.bullets).toHaveLength(3);
519
-
520
- const sources = result.bullets.map((b) => b.source);
521
- expect(sources).toContain("loop");
522
- expect(sources).toContain("work_item");
523
- expect(sources).toContain("followup");
524
-
525
- // All keys are unique
526
- const keys = result.bullets.map((b) => b.key);
527
- expect(new Set(keys).size).toBe(keys.length);
528
- });
529
- });
530
- });