@vellumai/assistant 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +82 -21
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -0,0 +1,635 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import type * as net from 'node:net';
3
+
4
+ // ── Mocks ────────────────────────────────────────────────────────────
5
+
6
+ let mockConfig = {
7
+ provider: 'mock-provider',
8
+ providerOrder: ['mock-provider'],
9
+ maxTokens: 4096,
10
+ thinking: false,
11
+ contextWindow: {
12
+ maxInputTokens: 100000,
13
+ thresholdTokens: 80000,
14
+ preserveRecentMessages: 6,
15
+ summaryModel: 'mock-model',
16
+ maxSummaryTokens: 512,
17
+ },
18
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
19
+ };
20
+
21
+ let initializeProvidersCalls = 0;
22
+
23
+ mock.module('node:child_process', () => ({
24
+ execSync: () => '1920x1080',
25
+ execFileSync: () => '',
26
+ }));
27
+
28
+ mock.module('../util/logger.js', () => ({
29
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
30
+ get: () => () => {},
31
+ }),
32
+ }));
33
+
34
+ mock.module('../util/platform.js', () => ({
35
+ getSocketPath: () => '/tmp/daemon-lifecycle-test.sock',
36
+ getSessionTokenPath: () => '/tmp/daemon-lifecycle-test-token',
37
+ getRootDir: () => '/tmp/daemon-lifecycle-test',
38
+ getWorkspaceDir: () => '/tmp/daemon-lifecycle-test/workspace',
39
+ getWorkspaceSkillsDir: () => '/tmp/daemon-lifecycle-test/workspace/skills',
40
+ getSandboxWorkingDir: () => '/tmp/workspace',
41
+ removeSocketFile: () => {},
42
+ getTCPPort: () => 0,
43
+ getTCPHost: () => '127.0.0.1',
44
+ isTCPEnabled: () => false,
45
+ isIOSPairingEnabled: () => false,
46
+ }));
47
+
48
+ mock.module('../providers/registry.js', () => ({
49
+ getProvider: () => ({ name: 'mock-provider' }),
50
+ getFailoverProvider: () => ({ name: 'mock-provider' }),
51
+ initializeProviders: () => { initializeProvidersCalls++; },
52
+ }));
53
+
54
+ mock.module('../providers/ratelimit.js', () => ({
55
+ RateLimitProvider: class {
56
+ constructor(..._args: unknown[]) {}
57
+ },
58
+ }));
59
+
60
+ mock.module('../config/loader.js', () => ({
61
+ getConfig: () => mockConfig,
62
+ loadRawConfig: () => ({}),
63
+ saveRawConfig: () => {},
64
+ invalidateConfigCache: () => {},
65
+ }));
66
+
67
+ mock.module('../config/system-prompt.js', () => ({
68
+ buildSystemPrompt: () => 'system prompt',
69
+ }));
70
+
71
+ mock.module('../permissions/trust-store.js', () => ({
72
+ clearCache: () => {},
73
+ }));
74
+
75
+ mock.module('../security/secret-allowlist.js', () => ({
76
+ resetAllowlist: () => {},
77
+ validateAllowlistFile: () => [],
78
+ }));
79
+
80
+ mock.module('../memory/external-conversation-store.js', () => ({
81
+ getBindingsForConversations: () => new Map(),
82
+ }));
83
+
84
+ const conversation = {
85
+ id: 'conv-1',
86
+ title: 'Test Conversation',
87
+ updatedAt: Date.now(),
88
+ totalInputTokens: 0,
89
+ totalOutputTokens: 0,
90
+ totalEstimatedCost: 0,
91
+ threadType: 'standard' as string,
92
+ memoryScopeId: 'default' as string,
93
+ };
94
+
95
+ mock.module('../memory/conversation-store.js', () => ({
96
+ getLatestConversation: () => conversation,
97
+ createConversation: () => conversation,
98
+ getConversation: (id: string) => (id === conversation.id ? conversation : null),
99
+ getConversationThreadType: () => 'standard',
100
+ getConversationMemoryScopeId: () => 'default',
101
+ getMessages: () => [],
102
+ listConversations: () => [conversation],
103
+ countConversations: () => 1,
104
+ }));
105
+
106
+ class MockSession {
107
+ public readonly conversationId: string;
108
+ public memoryPolicy: unknown;
109
+ private stale = false;
110
+ private processing = false;
111
+ public disposed = false;
112
+
113
+ constructor(conversationId: string, ..._args: unknown[]) {
114
+ this.conversationId = conversationId;
115
+ }
116
+
117
+ async loadFromDb(): Promise<void> {}
118
+ updateClient(): void {}
119
+ setSandboxOverride(): void {}
120
+ isProcessing(): boolean { return this.processing; }
121
+ isStale(): boolean { return this.stale; }
122
+ markStale(): void { this.stale = true; }
123
+ abort(): void {}
124
+ dispose(): void { this.disposed = true; }
125
+ hasEscalationHandler(): boolean { return true; }
126
+ setEscalationHandler(): void {}
127
+ handleConfirmationResponse(): void {}
128
+ async processMessage(): Promise<void> {}
129
+ undo(): number { return 1; }
130
+
131
+ // Test helpers
132
+ _setProcessing(v: boolean): void { this.processing = v; }
133
+ }
134
+
135
+ mock.module('../daemon/session.js', () => ({
136
+ Session: MockSession,
137
+ DEFAULT_MEMORY_POLICY: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
138
+ }));
139
+
140
+ // ── Imports (after mocks) ────────────────────────────────────────────
141
+
142
+ import { DaemonServer } from '../daemon/server.js';
143
+ import { SessionEvictor, type EvictableSession } from '../daemon/session-evictor.js';
144
+ import { createMessageParser, serialize, MAX_LINE_SIZE } from '../daemon/ipc-protocol.js';
145
+
146
+ // ── Test Helpers ─────────────────────────────────────────────────────
147
+
148
+ type DaemonServerInternals = {
149
+ sessions: Map<string, MockSession>;
150
+ connectedSockets: Set<net.Socket>;
151
+ authenticatedSockets: Set<net.Socket>;
152
+ socketToSession: Map<net.Socket, string>;
153
+ handleConnection: (socket: net.Socket) => void;
154
+ sendInitialSession: (socket: net.Socket) => Promise<void>;
155
+ dispatchMessage: (msg: { type: string; [key: string]: unknown }, socket: net.Socket) => void;
156
+ refreshConfigFromSources: () => boolean;
157
+ evictSessionsForReload: () => void;
158
+ lastConfigFingerprint: string;
159
+ evictor: SessionEvictor;
160
+ };
161
+
162
+ function internals(server: DaemonServer): DaemonServerInternals {
163
+ return server as unknown as DaemonServerInternals;
164
+ }
165
+
166
+ function createFakeSocket(overrides?: Partial<net.Socket>) {
167
+ const writes: string[] = [];
168
+ const socket = {
169
+ destroyed: false,
170
+ writable: true,
171
+ remoteAddress: '127.0.0.1',
172
+ write(chunk: string): boolean {
173
+ writes.push(chunk);
174
+ return true;
175
+ },
176
+ destroy(): void {
177
+ this.destroyed = true;
178
+ },
179
+ on(_event: string, _handler: (...args: unknown[]) => void): unknown {
180
+ return this;
181
+ },
182
+ once(_event: string, _handler: (...args: unknown[]) => void): unknown {
183
+ return this;
184
+ },
185
+ ...overrides,
186
+ } as unknown as net.Socket;
187
+ return { socket, writes };
188
+ }
189
+
190
+ function decodeMessages(writes: string[]): Array<Record<string, unknown>> {
191
+ return writes
192
+ .flatMap((chunk) => chunk.split('\n'))
193
+ .filter((line) => line.length > 0)
194
+ .map((line) => JSON.parse(line) as Record<string, unknown>);
195
+ }
196
+
197
+ function createMockEvictableSession(processing = false): EvictableSession & { disposed: boolean } {
198
+ return {
199
+ disposed: false,
200
+ isProcessing() { return processing; },
201
+ dispose() { this.disposed = true; },
202
+ };
203
+ }
204
+
205
+ // ── Tests ────────────────────────────────────────────────────────────
206
+
207
+ describe('DaemonServer lifecycle', () => {
208
+ beforeEach(() => {
209
+ initializeProvidersCalls = 0;
210
+ mockConfig = {
211
+ provider: 'mock-provider',
212
+ providerOrder: ['mock-provider'],
213
+ maxTokens: 4096,
214
+ thinking: false,
215
+ contextWindow: {
216
+ maxInputTokens: 100000,
217
+ thresholdTokens: 80000,
218
+ preserveRecentMessages: 6,
219
+ summaryModel: 'mock-model',
220
+ maxSummaryTokens: 512,
221
+ },
222
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
223
+ };
224
+ });
225
+
226
+ describe('server stop', () => {
227
+ test('stop disposes all sessions and clears state', async () => {
228
+ const server = new DaemonServer();
229
+ const int = internals(server);
230
+
231
+ // Manually inject some sessions
232
+ const s1 = new MockSession('sess-1');
233
+ const s2 = new MockSession('sess-2');
234
+ int.sessions.set('sess-1', s1);
235
+ int.sessions.set('sess-2', s2);
236
+
237
+ const { socket: sock1 } = createFakeSocket();
238
+ const { socket: sock2 } = createFakeSocket();
239
+ int.connectedSockets.add(sock1);
240
+ int.connectedSockets.add(sock2);
241
+
242
+ await server.stop();
243
+
244
+ expect(s1.disposed).toBe(true);
245
+ expect(s2.disposed).toBe(true);
246
+ expect(int.sessions.size).toBe(0);
247
+ expect(int.connectedSockets.size).toBe(0);
248
+ });
249
+
250
+ test('stop is idempotent when no server is listening', async () => {
251
+ const server = new DaemonServer();
252
+ // Should not throw even if start() was never called
253
+ await server.stop();
254
+ });
255
+ });
256
+
257
+ describe('config reload', () => {
258
+ test('refreshConfigFromSources is a no-op when fingerprint is unchanged', () => {
259
+ const server = new DaemonServer();
260
+ const int = internals(server);
261
+
262
+ // Set the fingerprint to match current config
263
+ int.lastConfigFingerprint = JSON.stringify(mockConfig);
264
+
265
+ const changed = int.refreshConfigFromSources();
266
+ expect(changed).toBe(false);
267
+ });
268
+
269
+ test('refreshConfigFromSources detects config change and reinitializes providers', () => {
270
+ const server = new DaemonServer();
271
+ const int = internals(server);
272
+
273
+ // Set to a different fingerprint to simulate previous config
274
+ int.lastConfigFingerprint = '{"different": "config"}';
275
+ const callsBefore = initializeProvidersCalls;
276
+
277
+ const changed = int.refreshConfigFromSources();
278
+
279
+ expect(changed).toBe(true);
280
+ expect(initializeProvidersCalls).toBe(callsBefore + 1);
281
+ });
282
+
283
+ test('config change evicts non-processing sessions', () => {
284
+ const server = new DaemonServer();
285
+ const int = internals(server);
286
+
287
+ const idle = new MockSession('idle');
288
+ const busy = new MockSession('busy');
289
+ busy._setProcessing(true);
290
+ int.sessions.set('idle', idle);
291
+ int.sessions.set('busy', busy);
292
+
293
+ // Force a fingerprint mismatch — first set initial fingerprint
294
+ int.lastConfigFingerprint = '{"old": true}';
295
+
296
+ int.refreshConfigFromSources();
297
+
298
+ // Idle session should be disposed and removed
299
+ expect(idle.disposed).toBe(true);
300
+ expect(int.sessions.has('idle')).toBe(false);
301
+
302
+ // Busy session should be marked stale but kept
303
+ expect(busy.disposed).toBe(false);
304
+ expect(int.sessions.has('busy')).toBe(true);
305
+ expect(busy.isStale()).toBe(true);
306
+ });
307
+
308
+ test('first config init does not evict sessions', () => {
309
+ const server = new DaemonServer();
310
+ const int = internals(server);
311
+
312
+ const s1 = new MockSession('s1');
313
+ int.sessions.set('s1', s1);
314
+
315
+ // Empty fingerprint = first init
316
+ int.lastConfigFingerprint = '';
317
+
318
+ int.refreshConfigFromSources();
319
+
320
+ // Should not evict on first init
321
+ expect(s1.disposed).toBe(false);
322
+ expect(int.sessions.has('s1')).toBe(true);
323
+ });
324
+ });
325
+
326
+ describe('session eviction for reload', () => {
327
+ test('evictSessionsForReload disposes idle and marks processing as stale', () => {
328
+ const server = new DaemonServer();
329
+ const int = internals(server);
330
+
331
+ const idle1 = new MockSession('idle1');
332
+ const idle2 = new MockSession('idle2');
333
+ const busy1 = new MockSession('busy1');
334
+ busy1._setProcessing(true);
335
+ int.sessions.set('idle1', idle1);
336
+ int.sessions.set('idle2', idle2);
337
+ int.sessions.set('busy1', busy1);
338
+
339
+ int.evictSessionsForReload();
340
+
341
+ expect(idle1.disposed).toBe(true);
342
+ expect(idle2.disposed).toBe(true);
343
+ expect(int.sessions.has('idle1')).toBe(false);
344
+ expect(int.sessions.has('idle2')).toBe(false);
345
+
346
+ expect(busy1.disposed).toBe(false);
347
+ expect(int.sessions.has('busy1')).toBe(true);
348
+ expect(busy1.isStale()).toBe(true);
349
+ });
350
+ });
351
+
352
+ describe('clearAllSessions', () => {
353
+ test('disposes and removes every session unconditionally', () => {
354
+ const server = new DaemonServer();
355
+ const int = internals(server);
356
+
357
+ const s1 = new MockSession('s1');
358
+ const s2 = new MockSession('s2');
359
+ s2._setProcessing(true);
360
+ int.sessions.set('s1', s1);
361
+ int.sessions.set('s2', s2);
362
+
363
+ const count = server.clearAllSessions();
364
+
365
+ expect(count).toBe(2);
366
+ expect(s1.disposed).toBe(true);
367
+ expect(s2.disposed).toBe(true);
368
+ expect(int.sessions.size).toBe(0);
369
+ });
370
+ });
371
+ });
372
+
373
+ describe('IPC connection limits', () => {
374
+ test('rejects connections when at MAX_CONNECTIONS', () => {
375
+ const server = new DaemonServer();
376
+ const int = internals(server);
377
+
378
+ // Fill up to 50 connections
379
+ for (let i = 0; i < 50; i++) {
380
+ const { socket } = createFakeSocket();
381
+ int.connectedSockets.add(socket);
382
+ }
383
+
384
+ // 51st connection should be rejected
385
+ const { socket: rejected, writes } = createFakeSocket();
386
+ int.handleConnection(rejected);
387
+
388
+ const messages = decodeMessages(writes);
389
+ const errorMsg = messages.find((m) => m.type === 'error');
390
+ expect(errorMsg).toBeDefined();
391
+ expect(errorMsg!.message).toContain('Connection limit reached');
392
+ expect(rejected.destroyed).toBe(true);
393
+ });
394
+
395
+ test('accepts connections when below MAX_CONNECTIONS', () => {
396
+ const server = new DaemonServer();
397
+ const int = internals(server);
398
+
399
+ // Fill to 49
400
+ for (let i = 0; i < 49; i++) {
401
+ const { socket } = createFakeSocket();
402
+ int.connectedSockets.add(socket);
403
+ }
404
+
405
+ // 50th should be accepted (at limit, not over)
406
+ const { socket: accepted } = createFakeSocket();
407
+ int.handleConnection(accepted);
408
+
409
+ expect(accepted.destroyed).toBeFalsy();
410
+ expect(int.connectedSockets.has(accepted)).toBe(true);
411
+ });
412
+ });
413
+
414
+ describe('SessionEvictor — advanced scenarios', () => {
415
+ let sessions: Map<string, EvictableSession & { disposed: boolean }>;
416
+
417
+ beforeEach(() => {
418
+ sessions = new Map();
419
+ });
420
+
421
+ describe('shouldProtect guard', () => {
422
+ test('protected sessions are never evicted by TTL', () => {
423
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
424
+ ttlMs: 100,
425
+ maxSessions: 100,
426
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
427
+ sweepIntervalMs: 60_000,
428
+ });
429
+ evictor.shouldProtect = (id) => id === 'protected';
430
+
431
+ const protectedSession = createMockEvictableSession();
432
+ const normalSession = createMockEvictableSession();
433
+ sessions.set('protected', protectedSession);
434
+ sessions.set('normal', normalSession);
435
+
436
+ // Both are never touched, so both exceed TTL
437
+
438
+ const result = evictor.sweep();
439
+
440
+ expect(result.ttlEvicted).toBe(1);
441
+ expect(result.skipped).toBe(1);
442
+ expect(protectedSession.disposed).toBe(false);
443
+ expect(normalSession.disposed).toBe(true);
444
+ expect(sessions.has('protected')).toBe(true);
445
+ expect(sessions.has('normal')).toBe(false);
446
+ });
447
+
448
+ test('protected sessions are never evicted by LRU', () => {
449
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
450
+ ttlMs: Number.MAX_SAFE_INTEGER,
451
+ maxSessions: 1,
452
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
453
+ sweepIntervalMs: 60_000,
454
+ });
455
+ evictor.shouldProtect = (id) => id === 'protected';
456
+
457
+ const protectedSession = createMockEvictableSession();
458
+ const normalSession = createMockEvictableSession();
459
+ sessions.set('protected', protectedSession);
460
+ sessions.set('normal', normalSession);
461
+ evictor.touch('protected');
462
+ evictor.touch('normal');
463
+
464
+ // Make protected the oldest
465
+ const lastAccess = (evictor as unknown as { lastAccess: Map<string, number> }).lastAccess;
466
+ lastAccess.set('protected', Date.now() - 10000);
467
+
468
+ const result = evictor.sweep();
469
+
470
+ // Normal should be evicted even though protected is older
471
+ expect(result.lruEvicted).toBe(1);
472
+ expect(protectedSession.disposed).toBe(false);
473
+ expect(normalSession.disposed).toBe(true);
474
+ });
475
+ });
476
+
477
+ describe('combined phases', () => {
478
+ test('TTL and LRU phases combine correctly', () => {
479
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
480
+ ttlMs: 500,
481
+ maxSessions: 2,
482
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
483
+ sweepIntervalMs: 60_000,
484
+ });
485
+
486
+ // Create 4 sessions, 2 expired and 2 fresh
487
+ for (let i = 0; i < 4; i++) {
488
+ const s = createMockEvictableSession();
489
+ sessions.set(`s${i}`, s);
490
+ evictor.touch(`s${i}`);
491
+ }
492
+
493
+ const lastAccess = (evictor as unknown as { lastAccess: Map<string, number> }).lastAccess;
494
+ const now = Date.now();
495
+ // s0 and s1 are expired
496
+ lastAccess.set('s0', now - 1000);
497
+ lastAccess.set('s1', now - 900);
498
+ // s2 and s3 are fresh
499
+ lastAccess.set('s2', now);
500
+ lastAccess.set('s3', now);
501
+
502
+ const result = evictor.sweep();
503
+
504
+ // s0, s1 evicted by TTL; s2, s3 remain (at maxSessions=2, no LRU needed)
505
+ expect(result.ttlEvicted).toBe(2);
506
+ expect(result.lruEvicted).toBe(0);
507
+ expect(sessions.size).toBe(2);
508
+ expect(sessions.has('s2')).toBe(true);
509
+ expect(sessions.has('s3')).toBe(true);
510
+ });
511
+
512
+ test('all processing sessions are fully skipped across phases', () => {
513
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
514
+ ttlMs: 100,
515
+ maxSessions: 1,
516
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
517
+ sweepIntervalMs: 60_000,
518
+ });
519
+
520
+ // 3 processing sessions, all expired
521
+ for (let i = 0; i < 3; i++) {
522
+ const s = createMockEvictableSession(true);
523
+ sessions.set(`s${i}`, s);
524
+ }
525
+
526
+ const result = evictor.sweep();
527
+
528
+ expect(result.ttlEvicted).toBe(0);
529
+ expect(result.lruEvicted).toBe(0);
530
+ expect(result.skipped).toBe(3);
531
+ expect(sessions.size).toBe(3);
532
+ });
533
+ });
534
+
535
+ describe('evictor start/stop lifecycle', () => {
536
+ test('start begins periodic sweeps, stop clears them', async () => {
537
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
538
+ ttlMs: 10,
539
+ maxSessions: 100,
540
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
541
+ sweepIntervalMs: 50,
542
+ });
543
+
544
+ const s1 = createMockEvictableSession();
545
+ sessions.set('s1', s1);
546
+ // Never touched — will be expired immediately
547
+
548
+ evictor.start();
549
+
550
+ // Wait for at least one sweep
551
+ await new Promise((r) => setTimeout(r, 120));
552
+
553
+ expect(s1.disposed).toBe(true);
554
+ expect(sessions.has('s1')).toBe(false);
555
+
556
+ evictor.stop();
557
+ });
558
+
559
+ test('start is idempotent (calling twice does not create duplicate timers)', () => {
560
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
561
+ sweepIntervalMs: 60_000,
562
+ });
563
+
564
+ evictor.start();
565
+ evictor.start(); // should be no-op
566
+
567
+ // Verify by accessing internals — only one timer should exist
568
+ const timer = (evictor as unknown as { sweepTimer: unknown }).sweepTimer;
569
+ expect(timer).toBeDefined();
570
+
571
+ evictor.stop();
572
+ });
573
+ });
574
+ });
575
+
576
+ describe('IPC protocol', () => {
577
+ describe('serialize', () => {
578
+ test('appends newline to JSON', () => {
579
+ const result = serialize({ type: 'ping' } as never);
580
+ expect(result).toBe('{"type":"ping"}\n');
581
+ });
582
+ });
583
+
584
+ describe('createMessageParser', () => {
585
+ test('parses complete messages terminated by newline', () => {
586
+ const parser = createMessageParser();
587
+ const messages = parser.feed('{"type":"ping"}\n');
588
+ expect(messages).toHaveLength(1);
589
+ expect((messages[0] as Record<string, unknown>).type).toBe('ping');
590
+ });
591
+
592
+ test('buffers partial messages until newline arrives', () => {
593
+ const parser = createMessageParser();
594
+
595
+ const partial1 = parser.feed('{"type":');
596
+ expect(partial1).toHaveLength(0);
597
+
598
+ const partial2 = parser.feed('"ping"}\n');
599
+ expect(partial2).toHaveLength(1);
600
+ expect((partial2[0] as Record<string, unknown>).type).toBe('ping');
601
+ });
602
+
603
+ test('handles multiple messages in a single chunk', () => {
604
+ const parser = createMessageParser();
605
+ const messages = parser.feed('{"type":"ping"}\n{"type":"pong"}\n');
606
+ expect(messages).toHaveLength(2);
607
+ expect((messages[0] as Record<string, unknown>).type).toBe('ping');
608
+ expect((messages[1] as Record<string, unknown>).type).toBe('pong');
609
+ });
610
+
611
+ test('skips malformed JSON lines gracefully', () => {
612
+ const parser = createMessageParser();
613
+ const messages = parser.feed('not json\n{"type":"valid"}\n');
614
+ expect(messages).toHaveLength(1);
615
+ expect((messages[0] as Record<string, unknown>).type).toBe('valid');
616
+ });
617
+
618
+ test('throws when line exceeds maxLineSize', () => {
619
+ const parser = createMessageParser({ maxLineSize: 50 });
620
+
621
+ expect(() => {
622
+ // Feed a partial message that exceeds the limit without a newline
623
+ parser.feed('a'.repeat(51));
624
+ // Trigger the size check by feeding more data
625
+ parser.feed('\n');
626
+ }).toThrow(/maximum line size/);
627
+ });
628
+
629
+ test('handles empty lines between messages', () => {
630
+ const parser = createMessageParser();
631
+ const messages = parser.feed('{"type":"a"}\n\n\n{"type":"b"}\n');
632
+ expect(messages).toHaveLength(2);
633
+ });
634
+ });
635
+ });
@@ -159,6 +159,10 @@ mock.module('../security/secret-allowlist.js', () => ({
159
159
  resetAllowlist: () => {},
160
160
  }));
161
161
 
162
+ mock.module('../memory/external-conversation-store.js', () => ({
163
+ getBindingsForConversations: () => new Map(),
164
+ }));
165
+
162
166
  mock.module('../memory/conversation-store.js', () => ({
163
167
  getLatestConversation: () => conversation,
164
168
  createConversation: (titleOrOpts?: string | { title?: string; threadType?: string }) => {
@@ -181,6 +185,7 @@ mock.module('../memory/conversation-store.js', () => ({
181
185
  },
182
186
  getMessages: () => [],
183
187
  listConversations: () => [conversation],
188
+ countConversations: () => 1,
184
189
  }));
185
190
 
186
191
  mock.module('../daemon/session.js', () => ({