@vellumai/assistant 0.5.2 → 0.5.3

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 (108) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/skills.md +100 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  5. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  6. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  7. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  8. package/src/__tests__/conversation-wipe.test.ts +226 -0
  9. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  10. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  11. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  12. package/src/__tests__/inline-command-runner.test.ts +311 -0
  13. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  14. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  16. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  17. package/src/__tests__/memory-brief-time.test.ts +285 -0
  18. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  19. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  20. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  21. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  22. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  23. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  24. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  25. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  26. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  27. package/src/__tests__/memory-reducer-types.test.ts +699 -0
  28. package/src/__tests__/memory-reducer.test.ts +698 -0
  29. package/src/__tests__/memory-regressions.test.ts +6 -4
  30. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  31. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  32. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  33. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  34. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  35. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  36. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  37. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  38. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  39. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  40. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  41. package/src/config/feature-flag-registry.json +16 -0
  42. package/src/config/loader.ts +1 -0
  43. package/src/config/raw-config-utils.ts +28 -0
  44. package/src/config/schema.ts +12 -0
  45. package/src/config/schemas/memory-simplified.ts +101 -0
  46. package/src/config/schemas/memory.ts +4 -0
  47. package/src/config/skills.ts +50 -4
  48. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  49. package/src/daemon/conversation-agent-loop.ts +71 -1
  50. package/src/daemon/conversation-lifecycle.ts +11 -1
  51. package/src/daemon/conversation-runtime-assembly.ts +2 -1
  52. package/src/daemon/conversation-surfaces.ts +31 -8
  53. package/src/daemon/conversation.ts +40 -23
  54. package/src/daemon/handlers/config-embeddings.ts +10 -2
  55. package/src/daemon/handlers/config-model.ts +0 -9
  56. package/src/daemon/handlers/identity.ts +12 -1
  57. package/src/daemon/lifecycle.ts +9 -1
  58. package/src/daemon/message-types/conversations.ts +0 -1
  59. package/src/daemon/server.ts +1 -1
  60. package/src/followups/followup-store.ts +47 -1
  61. package/src/memory/archive-store.ts +400 -0
  62. package/src/memory/brief-formatting.ts +33 -0
  63. package/src/memory/brief-open-loops.ts +266 -0
  64. package/src/memory/brief-time.ts +161 -0
  65. package/src/memory/brief.ts +75 -0
  66. package/src/memory/conversation-crud.ts +245 -101
  67. package/src/memory/db-init.ts +12 -0
  68. package/src/memory/indexer.ts +106 -15
  69. package/src/memory/job-handlers/embedding.test.ts +1 -0
  70. package/src/memory/job-handlers/embedding.ts +83 -0
  71. package/src/memory/job-utils.ts +1 -1
  72. package/src/memory/jobs-store.ts +6 -0
  73. package/src/memory/jobs-worker.ts +12 -0
  74. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  75. package/src/memory/migrations/186-memory-archive.ts +109 -0
  76. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  77. package/src/memory/migrations/index.ts +3 -0
  78. package/src/memory/qdrant-client.ts +23 -4
  79. package/src/memory/reducer-store.ts +271 -0
  80. package/src/memory/reducer-types.ts +99 -0
  81. package/src/memory/reducer.ts +453 -0
  82. package/src/memory/schema/conversations.ts +3 -0
  83. package/src/memory/schema/index.ts +2 -0
  84. package/src/memory/schema/memory-archive.ts +121 -0
  85. package/src/memory/schema/memory-brief.ts +55 -0
  86. package/src/memory/search/semantic.ts +17 -4
  87. package/src/oauth/oauth-store.ts +3 -1
  88. package/src/permissions/checker.ts +89 -6
  89. package/src/permissions/defaults.ts +14 -0
  90. package/src/runtime/routes/conversation-management-routes.ts +6 -0
  91. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  92. package/src/runtime/routes/conversation-routes.ts +52 -5
  93. package/src/runtime/routes/identity-routes.ts +2 -35
  94. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  95. package/src/runtime/routes/memory-item-routes.ts +90 -5
  96. package/src/runtime/routes/secret-routes.ts +2 -0
  97. package/src/runtime/routes/surface-action-routes.ts +68 -1
  98. package/src/schedule/schedule-store.ts +21 -0
  99. package/src/skills/inline-command-expansions.ts +204 -0
  100. package/src/skills/inline-command-render.ts +127 -0
  101. package/src/skills/inline-command-runner.ts +242 -0
  102. package/src/skills/transitive-version-hash.ts +88 -0
  103. package/src/tasks/task-store.ts +43 -1
  104. package/src/tools/permission-checker.ts +8 -1
  105. package/src/tools/skills/load.ts +140 -6
  106. package/src/util/platform.ts +18 -0
  107. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  108. package/src/workspace/migrations/registry.ts +1 -1
@@ -0,0 +1,699 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — declared before imports that depend on logger
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function makeLoggerStub(): Record<string, unknown> {
8
+ const stub: Record<string, unknown> = {};
9
+ for (const m of [
10
+ "info",
11
+ "warn",
12
+ "error",
13
+ "debug",
14
+ "trace",
15
+ "fatal",
16
+ "silent",
17
+ "child",
18
+ ]) {
19
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
20
+ }
21
+ return stub;
22
+ }
23
+
24
+ mock.module("../util/logger.js", () => ({
25
+ getLogger: () => makeLoggerStub(),
26
+ }));
27
+
28
+ import { parseReducerOutput } from "../memory/reducer.js";
29
+ import { EMPTY_REDUCER_RESULT } from "../memory/reducer-types.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Wrap a JS object through JSON.stringify so parseReducerOutput can consume it. */
36
+ function toRaw(obj: unknown): string {
37
+ return JSON.stringify(obj);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Tests: EMPTY_REDUCER_RESULT
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe("EMPTY_REDUCER_RESULT", () => {
45
+ test("has all four arrays empty", () => {
46
+ expect(EMPTY_REDUCER_RESULT.timeContexts).toEqual([]);
47
+ expect(EMPTY_REDUCER_RESULT.openLoops).toEqual([]);
48
+ expect(EMPTY_REDUCER_RESULT.archiveObservations).toEqual([]);
49
+ expect(EMPTY_REDUCER_RESULT.archiveEpisodes).toEqual([]);
50
+ });
51
+
52
+ test("is frozen (immutable)", () => {
53
+ expect(Object.isFrozen(EMPTY_REDUCER_RESULT)).toBe(true);
54
+ });
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tests: parseReducerOutput — invalid inputs
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe("parseReducerOutput — invalid inputs", () => {
62
+ test("returns empty result for non-JSON string", () => {
63
+ expect(parseReducerOutput("not json at all")).toBe(EMPTY_REDUCER_RESULT);
64
+ });
65
+
66
+ test("returns empty result for empty string", () => {
67
+ expect(parseReducerOutput("")).toBe(EMPTY_REDUCER_RESULT);
68
+ });
69
+
70
+ test("returns empty result for JSON array", () => {
71
+ expect(parseReducerOutput("[]")).toBe(EMPTY_REDUCER_RESULT);
72
+ });
73
+
74
+ test("returns empty result for JSON null", () => {
75
+ expect(parseReducerOutput("null")).toBe(EMPTY_REDUCER_RESULT);
76
+ });
77
+
78
+ test("returns empty result for JSON number", () => {
79
+ expect(parseReducerOutput("42")).toBe(EMPTY_REDUCER_RESULT);
80
+ });
81
+
82
+ test("returns empty result for JSON string", () => {
83
+ expect(parseReducerOutput('"hello"')).toBe(EMPTY_REDUCER_RESULT);
84
+ });
85
+
86
+ test("returns empty result for object with no recognized arrays", () => {
87
+ expect(parseReducerOutput(toRaw({ foo: "bar" }))).toBe(
88
+ EMPTY_REDUCER_RESULT,
89
+ );
90
+ });
91
+
92
+ test("returns empty result when all recognized keys are not arrays", () => {
93
+ expect(
94
+ parseReducerOutput(
95
+ toRaw({
96
+ timeContexts: "not an array",
97
+ openLoops: 42,
98
+ archiveObservations: null,
99
+ archiveEpisodes: true,
100
+ }),
101
+ ),
102
+ ).toBe(EMPTY_REDUCER_RESULT);
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Tests: parseReducerOutput — valid full output
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("parseReducerOutput — valid full output", () => {
111
+ test("parses a complete valid reducer output", () => {
112
+ const raw: Record<string, unknown> = {
113
+ timeContexts: [
114
+ {
115
+ action: "create",
116
+ summary: "User traveling next week",
117
+ source: "conversation",
118
+ activeFrom: 1700000000000,
119
+ activeUntil: 1700604800000,
120
+ },
121
+ {
122
+ action: "update",
123
+ id: "tc-1",
124
+ summary: "Updated travel dates",
125
+ },
126
+ {
127
+ action: "resolve",
128
+ id: "tc-2",
129
+ },
130
+ ],
131
+ openLoops: [
132
+ {
133
+ action: "create",
134
+ summary: "Follow up with Bob",
135
+ source: "conversation",
136
+ dueAt: 1700172800000,
137
+ },
138
+ {
139
+ action: "update",
140
+ id: "ol-1",
141
+ summary: "Bob replied — need to review",
142
+ },
143
+ {
144
+ action: "resolve",
145
+ id: "ol-2",
146
+ status: "resolved",
147
+ },
148
+ ],
149
+ archiveObservations: [
150
+ {
151
+ content: "User prefers dark mode",
152
+ role: "user",
153
+ modality: "text",
154
+ source: "vellum",
155
+ },
156
+ ],
157
+ archiveEpisodes: [
158
+ {
159
+ title: "Setup discussion",
160
+ summary: "User configured their workspace preferences",
161
+ source: "vellum",
162
+ },
163
+ ],
164
+ };
165
+
166
+ const result = parseReducerOutput(toRaw(raw));
167
+
168
+ expect(result.timeContexts).toHaveLength(3);
169
+ expect(result.timeContexts[0]).toEqual({
170
+ action: "create",
171
+ summary: "User traveling next week",
172
+ source: "conversation",
173
+ activeFrom: 1700000000000,
174
+ activeUntil: 1700604800000,
175
+ });
176
+ expect(result.timeContexts[1]).toEqual({
177
+ action: "update",
178
+ id: "tc-1",
179
+ summary: "Updated travel dates",
180
+ });
181
+ expect(result.timeContexts[2]).toEqual({
182
+ action: "resolve",
183
+ id: "tc-2",
184
+ });
185
+
186
+ expect(result.openLoops).toHaveLength(3);
187
+ expect(result.openLoops[0]).toEqual({
188
+ action: "create",
189
+ summary: "Follow up with Bob",
190
+ source: "conversation",
191
+ dueAt: 1700172800000,
192
+ });
193
+ expect(result.openLoops[1]).toEqual({
194
+ action: "update",
195
+ id: "ol-1",
196
+ summary: "Bob replied — need to review",
197
+ });
198
+ expect(result.openLoops[2]).toEqual({
199
+ action: "resolve",
200
+ id: "ol-2",
201
+ status: "resolved",
202
+ });
203
+
204
+ expect(result.archiveObservations).toHaveLength(1);
205
+ expect(result.archiveObservations[0]).toEqual({
206
+ content: "User prefers dark mode",
207
+ role: "user",
208
+ modality: "text",
209
+ source: "vellum",
210
+ });
211
+
212
+ expect(result.archiveEpisodes).toHaveLength(1);
213
+ expect(result.archiveEpisodes[0]).toEqual({
214
+ title: "Setup discussion",
215
+ summary: "User configured their workspace preferences",
216
+ source: "vellum",
217
+ });
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Tests: parseReducerOutput — partial outputs
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe("parseReducerOutput — partial outputs", () => {
226
+ test("accepts output with only timeContexts", () => {
227
+ const result = parseReducerOutput(
228
+ toRaw({
229
+ timeContexts: [
230
+ {
231
+ action: "create",
232
+ summary: "Deadline Friday",
233
+ source: "conversation",
234
+ activeFrom: 1700000000000,
235
+ activeUntil: 1700604800000,
236
+ },
237
+ ],
238
+ }),
239
+ );
240
+ expect(result.timeContexts).toHaveLength(1);
241
+ expect(result.openLoops).toHaveLength(0);
242
+ expect(result.archiveObservations).toHaveLength(0);
243
+ expect(result.archiveEpisodes).toHaveLength(0);
244
+ });
245
+
246
+ test("accepts output with only openLoops", () => {
247
+ const result = parseReducerOutput(
248
+ toRaw({
249
+ openLoops: [
250
+ {
251
+ action: "create",
252
+ summary: "Need to reply to Alice",
253
+ source: "conversation",
254
+ },
255
+ ],
256
+ }),
257
+ );
258
+ expect(result.openLoops).toHaveLength(1);
259
+ expect(result.timeContexts).toHaveLength(0);
260
+ });
261
+
262
+ test("accepts output with only archiveObservations", () => {
263
+ const result = parseReducerOutput(
264
+ toRaw({
265
+ archiveObservations: [{ content: "User likes coffee", role: "user" }],
266
+ }),
267
+ );
268
+ expect(result.archiveObservations).toHaveLength(1);
269
+ expect(result.archiveObservations[0]).toEqual({
270
+ content: "User likes coffee",
271
+ role: "user",
272
+ });
273
+ });
274
+
275
+ test("accepts output with only archiveEpisodes", () => {
276
+ const result = parseReducerOutput(
277
+ toRaw({
278
+ archiveEpisodes: [{ title: "Onboarding", summary: "First session" }],
279
+ }),
280
+ );
281
+ expect(result.archiveEpisodes).toHaveLength(1);
282
+ expect(result.archiveEpisodes[0]).toEqual({
283
+ title: "Onboarding",
284
+ summary: "First session",
285
+ });
286
+ });
287
+
288
+ test("accepts empty arrays for all keys", () => {
289
+ const result = parseReducerOutput(
290
+ toRaw({
291
+ timeContexts: [],
292
+ openLoops: [],
293
+ archiveObservations: [],
294
+ archiveEpisodes: [],
295
+ }),
296
+ );
297
+ expect(result.timeContexts).toHaveLength(0);
298
+ expect(result.openLoops).toHaveLength(0);
299
+ expect(result.archiveObservations).toHaveLength(0);
300
+ expect(result.archiveEpisodes).toHaveLength(0);
301
+ // Should be a fresh object, not EMPTY_REDUCER_RESULT reference
302
+ expect(result).not.toBe(EMPTY_REDUCER_RESULT);
303
+ });
304
+
305
+ test("drops invalid individual operations while keeping valid ones", () => {
306
+ const result = parseReducerOutput(
307
+ toRaw({
308
+ timeContexts: [
309
+ // valid create
310
+ {
311
+ action: "create",
312
+ summary: "Valid",
313
+ source: "conversation",
314
+ activeFrom: 1000,
315
+ activeUntil: 2000,
316
+ },
317
+ // invalid — missing summary
318
+ {
319
+ action: "create",
320
+ source: "conversation",
321
+ activeFrom: 1000,
322
+ activeUntil: 2000,
323
+ },
324
+ // invalid — unknown action
325
+ { action: "delete", id: "tc-1" },
326
+ // valid resolve
327
+ { action: "resolve", id: "tc-3" },
328
+ ],
329
+ openLoops: [
330
+ // valid create
331
+ {
332
+ action: "create",
333
+ summary: "Valid loop",
334
+ source: "conversation",
335
+ },
336
+ // invalid resolve — missing status
337
+ { action: "resolve", id: "ol-1" },
338
+ // invalid resolve — invalid status
339
+ { action: "resolve", id: "ol-2", status: "cancelled" },
340
+ // null entry
341
+ null,
342
+ ],
343
+ }),
344
+ );
345
+
346
+ expect(result.timeContexts).toHaveLength(2);
347
+ expect(result.timeContexts[0].action).toBe("create");
348
+ expect(result.timeContexts[1].action).toBe("resolve");
349
+
350
+ expect(result.openLoops).toHaveLength(1);
351
+ expect(result.openLoops[0].action).toBe("create");
352
+ });
353
+ });
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Tests: parseReducerOutput — time-context validation edge cases
357
+ // ---------------------------------------------------------------------------
358
+
359
+ describe("parseReducerOutput — time-context validation", () => {
360
+ test("rejects create with empty summary", () => {
361
+ const result = parseReducerOutput(
362
+ toRaw({
363
+ timeContexts: [
364
+ {
365
+ action: "create",
366
+ summary: "",
367
+ source: "conversation",
368
+ activeFrom: 1000,
369
+ activeUntil: 2000,
370
+ },
371
+ ],
372
+ }),
373
+ );
374
+ expect(result.timeContexts).toHaveLength(0);
375
+ });
376
+
377
+ test("rejects create with non-string summary", () => {
378
+ const result = parseReducerOutput(
379
+ toRaw({
380
+ timeContexts: [
381
+ {
382
+ action: "create",
383
+ summary: 123,
384
+ source: "conversation",
385
+ activeFrom: 1000,
386
+ activeUntil: 2000,
387
+ },
388
+ ],
389
+ }),
390
+ );
391
+ expect(result.timeContexts).toHaveLength(0);
392
+ });
393
+
394
+ test("rejects create with negative activeUntil", () => {
395
+ const result = parseReducerOutput(
396
+ toRaw({
397
+ timeContexts: [
398
+ {
399
+ action: "create",
400
+ summary: "Test",
401
+ source: "conversation",
402
+ activeFrom: 1000,
403
+ activeUntil: -1,
404
+ },
405
+ ],
406
+ }),
407
+ );
408
+ expect(result.timeContexts).toHaveLength(0);
409
+ });
410
+
411
+ test("accepts create with activeFrom of 0 (epoch)", () => {
412
+ const result = parseReducerOutput(
413
+ toRaw({
414
+ timeContexts: [
415
+ {
416
+ action: "create",
417
+ summary: "From epoch",
418
+ source: "conversation",
419
+ activeFrom: 0,
420
+ activeUntil: 1000,
421
+ },
422
+ ],
423
+ }),
424
+ );
425
+ expect(result.timeContexts).toHaveLength(1);
426
+ });
427
+
428
+ test("rejects update with no updatable fields", () => {
429
+ const result = parseReducerOutput(
430
+ toRaw({
431
+ timeContexts: [{ action: "update", id: "tc-1" }],
432
+ }),
433
+ );
434
+ expect(result.timeContexts).toHaveLength(0);
435
+ });
436
+
437
+ test("accepts update with only summary", () => {
438
+ const result = parseReducerOutput(
439
+ toRaw({
440
+ timeContexts: [
441
+ { action: "update", id: "tc-1", summary: "New summary" },
442
+ ],
443
+ }),
444
+ );
445
+ expect(result.timeContexts).toHaveLength(1);
446
+ const op = result.timeContexts[0];
447
+ expect(op.action).toBe("update");
448
+ if (op.action === "update") {
449
+ expect(op.summary).toBe("New summary");
450
+ expect(op.activeFrom).toBeUndefined();
451
+ expect(op.activeUntil).toBeUndefined();
452
+ }
453
+ });
454
+
455
+ test("rejects resolve with missing id", () => {
456
+ const result = parseReducerOutput(
457
+ toRaw({
458
+ timeContexts: [{ action: "resolve" }],
459
+ }),
460
+ );
461
+ expect(result.timeContexts).toHaveLength(0);
462
+ });
463
+ });
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Tests: parseReducerOutput — open-loop validation edge cases
467
+ // ---------------------------------------------------------------------------
468
+
469
+ describe("parseReducerOutput — open-loop validation", () => {
470
+ test("accepts create without optional dueAt", () => {
471
+ const result = parseReducerOutput(
472
+ toRaw({
473
+ openLoops: [
474
+ {
475
+ action: "create",
476
+ summary: "No deadline",
477
+ source: "conversation",
478
+ },
479
+ ],
480
+ }),
481
+ );
482
+ expect(result.openLoops).toHaveLength(1);
483
+ if (result.openLoops[0].action === "create") {
484
+ expect(result.openLoops[0].dueAt).toBeUndefined();
485
+ }
486
+ });
487
+
488
+ test("accepts create with dueAt", () => {
489
+ const result = parseReducerOutput(
490
+ toRaw({
491
+ openLoops: [
492
+ {
493
+ action: "create",
494
+ summary: "With deadline",
495
+ source: "conversation",
496
+ dueAt: 1700000000000,
497
+ },
498
+ ],
499
+ }),
500
+ );
501
+ expect(result.openLoops).toHaveLength(1);
502
+ if (result.openLoops[0].action === "create") {
503
+ expect(result.openLoops[0].dueAt).toBe(1700000000000);
504
+ }
505
+ });
506
+
507
+ test("rejects create with missing source", () => {
508
+ const result = parseReducerOutput(
509
+ toRaw({
510
+ openLoops: [{ action: "create", summary: "Missing source" }],
511
+ }),
512
+ );
513
+ expect(result.openLoops).toHaveLength(0);
514
+ });
515
+
516
+ test("rejects update with no updatable fields", () => {
517
+ const result = parseReducerOutput(
518
+ toRaw({
519
+ openLoops: [{ action: "update", id: "ol-1" }],
520
+ }),
521
+ );
522
+ expect(result.openLoops).toHaveLength(0);
523
+ });
524
+
525
+ test("accepts update with only dueAt", () => {
526
+ const result = parseReducerOutput(
527
+ toRaw({
528
+ openLoops: [{ action: "update", id: "ol-1", dueAt: 1700000000000 }],
529
+ }),
530
+ );
531
+ expect(result.openLoops).toHaveLength(1);
532
+ });
533
+
534
+ test("accepts resolve with status 'expired'", () => {
535
+ const result = parseReducerOutput(
536
+ toRaw({
537
+ openLoops: [{ action: "resolve", id: "ol-1", status: "expired" }],
538
+ }),
539
+ );
540
+ expect(result.openLoops).toHaveLength(1);
541
+ if (result.openLoops[0].action === "resolve") {
542
+ expect(result.openLoops[0].status).toBe("expired");
543
+ }
544
+ });
545
+
546
+ test("rejects resolve with invalid status value", () => {
547
+ const result = parseReducerOutput(
548
+ toRaw({
549
+ openLoops: [{ action: "resolve", id: "ol-1", status: "deleted" }],
550
+ }),
551
+ );
552
+ expect(result.openLoops).toHaveLength(0);
553
+ });
554
+ });
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // Tests: parseReducerOutput — archive observation validation
558
+ // ---------------------------------------------------------------------------
559
+
560
+ describe("parseReducerOutput — archive observation validation", () => {
561
+ test("rejects observation with missing content", () => {
562
+ const result = parseReducerOutput(
563
+ toRaw({
564
+ archiveObservations: [{ role: "user" }],
565
+ }),
566
+ );
567
+ expect(result.archiveObservations).toHaveLength(0);
568
+ });
569
+
570
+ test("rejects observation with missing role", () => {
571
+ const result = parseReducerOutput(
572
+ toRaw({
573
+ archiveObservations: [{ content: "Something" }],
574
+ }),
575
+ );
576
+ expect(result.archiveObservations).toHaveLength(0);
577
+ });
578
+
579
+ test("includes optional modality and source when present", () => {
580
+ const result = parseReducerOutput(
581
+ toRaw({
582
+ archiveObservations: [
583
+ {
584
+ content: "User likes tea",
585
+ role: "user",
586
+ modality: "voice",
587
+ source: "phone",
588
+ },
589
+ ],
590
+ }),
591
+ );
592
+ expect(result.archiveObservations).toHaveLength(1);
593
+ expect(result.archiveObservations[0].modality).toBe("voice");
594
+ expect(result.archiveObservations[0].source).toBe("phone");
595
+ });
596
+
597
+ test("omits modality and source when not valid strings", () => {
598
+ const result = parseReducerOutput(
599
+ toRaw({
600
+ archiveObservations: [
601
+ {
602
+ content: "Fact",
603
+ role: "user",
604
+ modality: 123,
605
+ source: false,
606
+ },
607
+ ],
608
+ }),
609
+ );
610
+ expect(result.archiveObservations).toHaveLength(1);
611
+ expect(result.archiveObservations[0].modality).toBeUndefined();
612
+ expect(result.archiveObservations[0].source).toBeUndefined();
613
+ });
614
+ });
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Tests: parseReducerOutput — archive episode validation
618
+ // ---------------------------------------------------------------------------
619
+
620
+ describe("parseReducerOutput — archive episode validation", () => {
621
+ test("rejects episode with missing title", () => {
622
+ const result = parseReducerOutput(
623
+ toRaw({
624
+ archiveEpisodes: [{ summary: "Some summary" }],
625
+ }),
626
+ );
627
+ expect(result.archiveEpisodes).toHaveLength(0);
628
+ });
629
+
630
+ test("rejects episode with missing summary", () => {
631
+ const result = parseReducerOutput(
632
+ toRaw({
633
+ archiveEpisodes: [{ title: "Some title" }],
634
+ }),
635
+ );
636
+ expect(result.archiveEpisodes).toHaveLength(0);
637
+ });
638
+
639
+ test("includes optional source when present", () => {
640
+ const result = parseReducerOutput(
641
+ toRaw({
642
+ archiveEpisodes: [
643
+ { title: "Chat", summary: "A chat happened", source: "telegram" },
644
+ ],
645
+ }),
646
+ );
647
+ expect(result.archiveEpisodes).toHaveLength(1);
648
+ expect(result.archiveEpisodes[0].source).toBe("telegram");
649
+ });
650
+
651
+ test("omits source when not a valid string", () => {
652
+ const result = parseReducerOutput(
653
+ toRaw({
654
+ archiveEpisodes: [
655
+ { title: "Chat", summary: "A chat happened", source: 42 },
656
+ ],
657
+ }),
658
+ );
659
+ expect(result.archiveEpisodes).toHaveLength(1);
660
+ expect(result.archiveEpisodes[0].source).toBeUndefined();
661
+ });
662
+ });
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Tests: parseReducerOutput — extra/unknown keys are tolerated
666
+ // ---------------------------------------------------------------------------
667
+
668
+ describe("parseReducerOutput — tolerates extra keys", () => {
669
+ test("ignores unknown top-level keys", () => {
670
+ const result = parseReducerOutput(
671
+ toRaw({
672
+ timeContexts: [],
673
+ unknownKey: "whatever",
674
+ anotherOne: [1, 2, 3],
675
+ }),
676
+ );
677
+ expect(result).not.toBe(EMPTY_REDUCER_RESULT);
678
+ expect(result.timeContexts).toHaveLength(0);
679
+ });
680
+
681
+ test("ignores extra fields on individual operations", () => {
682
+ const result = parseReducerOutput(
683
+ toRaw({
684
+ openLoops: [
685
+ {
686
+ action: "create",
687
+ summary: "Valid",
688
+ source: "conversation",
689
+ extraField: true,
690
+ nested: { deep: 1 },
691
+ },
692
+ ],
693
+ }),
694
+ );
695
+ expect(result.openLoops).toHaveLength(1);
696
+ // Extra fields should NOT be present on the validated result
697
+ expect((result.openLoops[0] as any).extraField).toBeUndefined();
698
+ });
699
+ });