@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,150 +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(), "conv-dirty-tail-test-"));
7
-
8
- mock.module("../util/platform.js", () => ({
9
- getDataDir: () => testDir,
10
- isMacOS: () => process.platform === "darwin",
11
- isLinux: () => process.platform === "linux",
12
- isWindows: () => process.platform === "win32",
13
- getPidPath: () => join(testDir, "test.pid"),
14
- getDbPath: () => join(testDir, "test.db"),
15
- getLogPath: () => join(testDir, "test.log"),
16
- ensureDataDir: () => {},
17
- }));
18
-
19
- mock.module("../util/logger.js", () => ({
20
- getLogger: () =>
21
- new Proxy({} as Record<string, unknown>, {
22
- get: () => () => {},
23
- }),
24
- }));
25
-
26
- import {
27
- addMessage,
28
- createConversation,
29
- getConversation,
30
- getMessages,
31
- markConversationMemoryDirty,
32
- } from "../memory/conversation-crud.js";
33
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
34
-
35
- initializeDb();
36
-
37
- afterAll(() => {
38
- resetDb();
39
- try {
40
- rmSync(testDir, { recursive: true });
41
- } catch {
42
- /* best effort */
43
- }
44
- });
45
-
46
- describe("markConversationMemoryDirty", () => {
47
- beforeEach(() => {
48
- const db = getDb();
49
- db.run(`DELETE FROM messages`);
50
- db.run(`DELETE FROM conversations`);
51
- });
52
-
53
- test("first message marks the conversation dirty with its message ID", async () => {
54
- const conv = createConversation("test");
55
- const msg = await addMessage(conv.id, "user", "hello world", undefined, {
56
- skipIndexing: true,
57
- });
58
-
59
- const updated = getConversation(conv.id);
60
- expect(updated).not.toBeNull();
61
- expect(updated!.memoryDirtyTailSinceMessageId).toBe(msg.id);
62
- });
63
-
64
- test("repeated messages preserve the original dirty boundary", async () => {
65
- const conv = createConversation("test");
66
- const msg1 = await addMessage(conv.id, "user", "first message", undefined, {
67
- skipIndexing: true,
68
- });
69
- const msg2 = await addMessage(
70
- conv.id,
71
- "assistant",
72
- "second message",
73
- undefined,
74
- { skipIndexing: true },
75
- );
76
-
77
- const updated = getConversation(conv.id);
78
- expect(updated).not.toBeNull();
79
- // The dirty tail should still point to msg1, not msg2.
80
- expect(updated!.memoryDirtyTailSinceMessageId).toBe(msg1.id);
81
- // msg2 should still be persisted normally.
82
- expect(msg2.id).not.toBe(msg1.id);
83
- });
84
-
85
- test("markConversationMemoryDirty is a no-op when already dirty", () => {
86
- const conv = createConversation("test");
87
- const firstMessageId = "first-msg-id";
88
- const secondMessageId = "second-msg-id";
89
-
90
- markConversationMemoryDirty(conv.id, firstMessageId);
91
- const after1 = getConversation(conv.id);
92
- expect(after1!.memoryDirtyTailSinceMessageId).toBe(firstMessageId);
93
-
94
- markConversationMemoryDirty(conv.id, secondMessageId);
95
- const after2 = getConversation(conv.id);
96
- // Still points to the first message — boundary preserved.
97
- expect(after2!.memoryDirtyTailSinceMessageId).toBe(firstMessageId);
98
- });
99
-
100
- test("message ordering and persistence semantics are unchanged", async () => {
101
- const conv = createConversation("test");
102
- const msg1 = await addMessage(conv.id, "user", "question", undefined, {
103
- skipIndexing: true,
104
- });
105
- const msg2 = await addMessage(conv.id, "assistant", "answer", undefined, {
106
- skipIndexing: true,
107
- });
108
- const msg3 = await addMessage(conv.id, "user", "follow-up", undefined, {
109
- skipIndexing: true,
110
- });
111
-
112
- const allMessages = getMessages(conv.id);
113
- expect(allMessages).toHaveLength(3);
114
- // Messages are ordered by createdAt ascending.
115
- expect(allMessages[0].id).toBe(msg1.id);
116
- expect(allMessages[1].id).toBe(msg2.id);
117
- expect(allMessages[2].id).toBe(msg3.id);
118
- expect(allMessages[0].content).toBe("question");
119
- expect(allMessages[1].content).toBe("answer");
120
- expect(allMessages[2].content).toBe("follow-up");
121
- // createdAt is monotonically increasing.
122
- expect(allMessages[1].createdAt).toBeGreaterThan(allMessages[0].createdAt);
123
- expect(allMessages[2].createdAt).toBeGreaterThan(allMessages[1].createdAt);
124
- });
125
-
126
- test("every persisted message marks the conversation dirty", async () => {
127
- const conv = createConversation("test");
128
-
129
- // Before any messages, the conversation is not dirty.
130
- const before = getConversation(conv.id);
131
- expect(before!.memoryDirtyTailSinceMessageId).toBeNull();
132
-
133
- // After the first message, it becomes dirty.
134
- const msg1 = await addMessage(conv.id, "user", "msg1", undefined, {
135
- skipIndexing: true,
136
- });
137
- const after1 = getConversation(conv.id);
138
- expect(after1!.memoryDirtyTailSinceMessageId).toBe(msg1.id);
139
-
140
- // After subsequent messages, the dirty boundary stays on msg1.
141
- await addMessage(conv.id, "assistant", "msg2", undefined, {
142
- skipIndexing: true,
143
- });
144
- await addMessage(conv.id, "user", "msg3", undefined, {
145
- skipIndexing: true,
146
- });
147
- const afterAll = getConversation(conv.id);
148
- expect(afterAll!.memoryDirtyTailSinceMessageId).toBe(msg1.id);
149
- });
150
- });
@@ -1,474 +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
- // ── Test directory & platform mocks ───────────────────────────────
7
-
8
- const testDir = mkdtempSync(
9
- join(tmpdir(), "conversation-switch-memory-reduction-test-"),
10
- );
11
-
12
- mock.module("../util/platform.js", () => ({
13
- getDataDir: () => testDir,
14
- getRootDir: () => testDir,
15
- getWorkspaceDir: () => join(testDir, "workspace"),
16
- getConversationsDir: () => join(testDir, "workspace", "conversations"),
17
- isMacOS: () => process.platform === "darwin",
18
- isLinux: () => process.platform === "linux",
19
- isWindows: () => process.platform === "win32",
20
- getPidPath: () => join(testDir, "test.pid"),
21
- getDbPath: () => join(testDir, "test.db"),
22
- getLogPath: () => join(testDir, "test.log"),
23
- ensureDataDir: () => {},
24
- }));
25
-
26
- mock.module("../util/logger.js", () => ({
27
- getLogger: () =>
28
- new Proxy({} as Record<string, unknown>, {
29
- get: () => () => {},
30
- }),
31
- }));
32
-
33
- // ── Config mock ───────────────────────────────────────────────────
34
-
35
- mock.module("../config/loader.js", () => ({
36
- getConfig: () => ({
37
- memory: {
38
- simplified: {
39
- reducer: {
40
- idleDelayMs: 30_000,
41
- switchWaitMs: 5_000,
42
- },
43
- },
44
- },
45
- }),
46
- loadConfig: () => ({
47
- memory: {
48
- simplified: {
49
- reducer: {
50
- idleDelayMs: 30_000,
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
- // ── Mock the reducer ──────────────────────────────────────────────
81
-
82
- import type { ReducerPromptInput } from "../memory/reducer.js";
83
- import type { ReducerResult } from "../memory/reducer-types.js";
84
- import { EMPTY_REDUCER_RESULT } from "../memory/reducer-types.js";
85
-
86
- let mockReducerResult: ReducerResult = EMPTY_REDUCER_RESULT;
87
- let lastReducerInput: ReducerPromptInput | null = null;
88
- let reducerCallCount = 0;
89
-
90
- mock.module("../memory/reducer.js", () => ({
91
- runReducer: async (input: ReducerPromptInput) => {
92
- lastReducerInput = input;
93
- reducerCallCount++;
94
- return mockReducerResult;
95
- },
96
- }));
97
-
98
- // ── Imports (after mocks) ─────────────────────────────────────────
99
-
100
- import { initializeDb, resetDb } from "../memory/db.js";
101
- import { getSqlite } from "../memory/db-connection.js";
102
- import { resetTestTables } from "../memory/raw-query.js";
103
- import {
104
- findMostRecentDirtyConversation,
105
- reduceBeforeSwitch,
106
- } from "../memory/reducer-scheduler.js";
107
-
108
- initializeDb();
109
-
110
- // ── Helpers ───────────────────────────────────────────────────────
111
-
112
- const NOW = 1_700_000_000_000;
113
- const SCOPE = "default";
114
-
115
- function insertConversation(
116
- id: string,
117
- opts?: {
118
- dirtyTailMessageId?: string | null;
119
- updatedAt?: number;
120
- memoryScopeId?: string;
121
- contextSummary?: string;
122
- },
123
- ): void {
124
- const raw = getSqlite();
125
- raw.run(
126
- `INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title,
127
- memory_dirty_tail_since_message_id, context_summary)
128
- VALUES (?, 'Test', ?, ?, 'standard', 'user', ?, 1, ?, ?)`,
129
- [
130
- id,
131
- NOW,
132
- opts?.updatedAt ?? NOW,
133
- opts?.memoryScopeId ?? SCOPE,
134
- opts?.dirtyTailMessageId ?? null,
135
- opts?.contextSummary ?? null,
136
- ],
137
- );
138
- }
139
-
140
- function insertMessage(opts: {
141
- id: string;
142
- conversationId: string;
143
- role?: string;
144
- content?: string;
145
- createdAt?: number;
146
- }): void {
147
- const raw = getSqlite();
148
- raw.run(
149
- `INSERT INTO messages (id, conversation_id, role, content, created_at)
150
- VALUES (?, ?, ?, ?, ?)`,
151
- [
152
- opts.id,
153
- opts.conversationId,
154
- opts.role ?? "user",
155
- opts.content ?? "test message",
156
- opts.createdAt ?? NOW,
157
- ],
158
- );
159
- }
160
-
161
- function getRawConversation(conversationId: string): Record<string, unknown> {
162
- const raw = getSqlite();
163
- return raw
164
- .query(`SELECT * FROM conversations WHERE id = ?`)
165
- .get(conversationId) as Record<string, unknown>;
166
- }
167
-
168
- function makeReducerResult(overrides?: Partial<ReducerResult>): ReducerResult {
169
- return {
170
- timeContexts: [],
171
- openLoops: [],
172
- archiveObservations: [],
173
- archiveEpisodes: [],
174
- ...overrides,
175
- };
176
- }
177
-
178
- // ── Teardown ──────────────────────────────────────────────────────
179
-
180
- afterAll(() => {
181
- resetDb();
182
- try {
183
- rmSync(testDir, { recursive: true });
184
- } catch {
185
- /* best effort */
186
- }
187
- });
188
-
189
- beforeEach(() => {
190
- resetTestTables(
191
- "messages",
192
- "conversations",
193
- "memory_jobs",
194
- "time_contexts",
195
- "open_loops",
196
- );
197
- mockReducerResult = EMPTY_REDUCER_RESULT;
198
- lastReducerInput = null;
199
- reducerCallCount = 0;
200
- });
201
-
202
- // ── Tests ─────────────────────────────────────────────────────────
203
-
204
- describe("findMostRecentDirtyConversation", () => {
205
- test("returns the most recently updated dirty conversation", () => {
206
- insertConversation("conv-old", {
207
- dirtyTailMessageId: "msg-old",
208
- updatedAt: NOW - 5000,
209
- });
210
- insertConversation("conv-recent", {
211
- dirtyTailMessageId: "msg-recent",
212
- updatedAt: NOW,
213
- });
214
- insertConversation("conv-target", { updatedAt: NOW + 1000 });
215
-
216
- const result = findMostRecentDirtyConversation("conv-target");
217
- // Should return the most recently updated dirty conversation (ordered by updatedAt DESC)
218
- expect(result).toBe("conv-recent");
219
- });
220
-
221
- test("excludes the target conversation", () => {
222
- insertConversation("conv-dirty", {
223
- dirtyTailMessageId: "msg-1",
224
- updatedAt: NOW,
225
- });
226
-
227
- const result = findMostRecentDirtyConversation("conv-dirty");
228
- expect(result).toBeNull();
229
- });
230
-
231
- test("returns null when no dirty conversations exist", () => {
232
- insertConversation("conv-clean", { updatedAt: NOW });
233
-
234
- const result = findMostRecentDirtyConversation("conv-target");
235
- expect(result).toBeNull();
236
- });
237
-
238
- test("returns null when only dirty conversation is the target", () => {
239
- insertConversation("conv-target", {
240
- dirtyTailMessageId: "msg-1",
241
- updatedAt: NOW,
242
- });
243
- insertConversation("conv-clean", { updatedAt: NOW + 1000 });
244
-
245
- const result = findMostRecentDirtyConversation("conv-target");
246
- expect(result).toBeNull();
247
- });
248
- });
249
-
250
- describe("reduceBeforeSwitch — conversation switch", () => {
251
- test("reduces the dirty conversation before switching", async () => {
252
- // Previous conversation with dirty messages
253
- insertConversation("conv-prev", {
254
- dirtyTailMessageId: "msg-1",
255
- updatedAt: NOW,
256
- });
257
- insertMessage({
258
- id: "msg-1",
259
- conversationId: "conv-prev",
260
- role: "user",
261
- content: "Hello",
262
- createdAt: NOW,
263
- });
264
- insertMessage({
265
- id: "msg-2",
266
- conversationId: "conv-prev",
267
- role: "assistant",
268
- content: "Hi there!",
269
- createdAt: NOW + 1000,
270
- });
271
-
272
- // Target conversation
273
- insertConversation("conv-target", { updatedAt: NOW + 5000 });
274
-
275
- mockReducerResult = makeReducerResult({
276
- openLoops: [
277
- {
278
- action: "create",
279
- summary: "User greeted the assistant",
280
- source: "conversation",
281
- },
282
- ],
283
- });
284
-
285
- const result = await reduceBeforeSwitch("conv-target");
286
-
287
- // Should have reduced conv-prev
288
- expect(result).toBe("conv-prev");
289
- expect(reducerCallCount).toBe(1);
290
-
291
- // Checkpoint should be advanced
292
- const conv = getRawConversation("conv-prev");
293
- expect(conv.memory_reduced_through_message_id).toBe("msg-2");
294
- expect(conv.memory_dirty_tail_since_message_id).toBeNull();
295
- });
296
-
297
- test("skips when no eligible dirty conversation exists", async () => {
298
- insertConversation("conv-clean", { updatedAt: NOW });
299
- insertConversation("conv-target", { updatedAt: NOW + 1000 });
300
-
301
- const result = await reduceBeforeSwitch("conv-target");
302
-
303
- expect(result).toBeNull();
304
- expect(reducerCallCount).toBe(0);
305
- });
306
-
307
- test("skips when the only dirty conversation is the target", async () => {
308
- insertConversation("conv-target", {
309
- dirtyTailMessageId: "msg-1",
310
- updatedAt: NOW,
311
- });
312
- insertMessage({ id: "msg-1", conversationId: "conv-target" });
313
-
314
- const result = await reduceBeforeSwitch("conv-target");
315
-
316
- expect(result).toBeNull();
317
- expect(reducerCallCount).toBe(0);
318
- });
319
-
320
- test("does not advance checkpoint when reducer returns empty result", async () => {
321
- insertConversation("conv-prev", {
322
- dirtyTailMessageId: "msg-1",
323
- updatedAt: NOW,
324
- });
325
- insertMessage({
326
- id: "msg-1",
327
- conversationId: "conv-prev",
328
- role: "user",
329
- content: "Hello",
330
- createdAt: NOW,
331
- });
332
- insertConversation("conv-target", { updatedAt: NOW + 5000 });
333
-
334
- mockReducerResult = EMPTY_REDUCER_RESULT;
335
-
336
- const result = await reduceBeforeSwitch("conv-target");
337
-
338
- // Returns null because empty result means nothing was reduced
339
- expect(result).toBeNull();
340
-
341
- // Checkpoint should NOT advance
342
- const conv = getRawConversation("conv-prev");
343
- expect(conv.memory_reduced_through_message_id).toBeNull();
344
- expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
345
- });
346
- });
347
-
348
- describe("reduceBeforeSwitch — new conversation", () => {
349
- test("reduces the previous dirty conversation when starting a new one", async () => {
350
- insertConversation("conv-prev", {
351
- dirtyTailMessageId: "msg-1",
352
- updatedAt: NOW,
353
- });
354
- insertMessage({
355
- id: "msg-1",
356
- conversationId: "conv-prev",
357
- role: "user",
358
- content: "Some prior work",
359
- createdAt: NOW,
360
- });
361
-
362
- // The new conversation ID (just created)
363
- const newConvId = "conv-new";
364
- insertConversation(newConvId, { updatedAt: NOW + 5000 });
365
-
366
- mockReducerResult = makeReducerResult({
367
- timeContexts: [
368
- {
369
- action: "create",
370
- summary: "Prior work in progress",
371
- source: "conversation",
372
- activeFrom: NOW,
373
- activeUntil: NOW + 7 * 24 * 60 * 60 * 1000,
374
- },
375
- ],
376
- });
377
-
378
- const result = await reduceBeforeSwitch(newConvId);
379
-
380
- expect(result).toBe("conv-prev");
381
- expect(reducerCallCount).toBe(1);
382
-
383
- // Verify the previous conversation's checkpoint was advanced
384
- const conv = getRawConversation("conv-prev");
385
- expect(conv.memory_reduced_through_message_id).toBe("msg-1");
386
- expect(conv.memory_dirty_tail_since_message_id).toBeNull();
387
- });
388
- });
389
-
390
- describe("reduceBeforeSwitch — most recent dirty selection", () => {
391
- test("selects the most recently updated dirty conversation when multiple exist", async () => {
392
- // Two dirty conversations — conv-newer is more recently updated
393
- insertConversation("conv-older", {
394
- dirtyTailMessageId: "msg-older",
395
- updatedAt: NOW - 10_000,
396
- });
397
- insertMessage({
398
- id: "msg-older",
399
- conversationId: "conv-older",
400
- role: "user",
401
- content: "Older conversation",
402
- createdAt: NOW - 10_000,
403
- });
404
-
405
- insertConversation("conv-newer", {
406
- dirtyTailMessageId: "msg-newer",
407
- updatedAt: NOW,
408
- });
409
- insertMessage({
410
- id: "msg-newer",
411
- conversationId: "conv-newer",
412
- role: "user",
413
- content: "Newer conversation",
414
- createdAt: NOW,
415
- });
416
-
417
- insertConversation("conv-target", { updatedAt: NOW + 5000 });
418
-
419
- mockReducerResult = makeReducerResult();
420
-
421
- // Even though two are dirty, we only reduce one per switch.
422
- // The function picks the most recently updated (by updatedAt DESC).
423
- const result = await reduceBeforeSwitch("conv-target");
424
-
425
- // Should pick the most recently updated dirty conversation
426
- expect(result).toBe("conv-newer");
427
- expect(reducerCallCount).toBe(1);
428
- expect(lastReducerInput?.conversationId).toBe("conv-newer");
429
- });
430
- });
431
-
432
- describe("reduceBeforeSwitch — error handling", () => {
433
- test("returns null and continues when reducer throws", async () => {
434
- insertConversation("conv-prev", {
435
- dirtyTailMessageId: "msg-1",
436
- updatedAt: NOW,
437
- });
438
- insertMessage({
439
- id: "msg-1",
440
- conversationId: "conv-prev",
441
- role: "user",
442
- content: "Hello",
443
- createdAt: NOW,
444
- });
445
- insertConversation("conv-target", { updatedAt: NOW + 5000 });
446
-
447
- // Override mock to throw
448
- mock.module("../memory/reducer.js", () => ({
449
- runReducer: async () => {
450
- reducerCallCount++;
451
- throw new Error("Provider timeout");
452
- },
453
- }));
454
-
455
- const result = await reduceBeforeSwitch("conv-target");
456
-
457
- // Should return null (graceful failure, don't block the switch)
458
- expect(result).toBeNull();
459
-
460
- // Checkpoint should NOT advance
461
- const conv = getRawConversation("conv-prev");
462
- expect(conv.memory_reduced_through_message_id).toBeNull();
463
- expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
464
-
465
- // Restore normal mock for subsequent tests
466
- mock.module("../memory/reducer.js", () => ({
467
- runReducer: async (input: ReducerPromptInput) => {
468
- lastReducerInput = input;
469
- reducerCallCount++;
470
- return mockReducerResult;
471
- },
472
- }));
473
- });
474
- });