@vellumai/assistant 0.5.4 → 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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. 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
- });