@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -0,0 +1,636 @@
1
+ import { 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 } 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 base: Record<string, unknown> = {
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
+ base.destroyed = true;
178
+ },
179
+ on(_event: string, _handler: (...args: unknown[]) => void): unknown {
180
+ return socket;
181
+ },
182
+ once(_event: string, _handler: (...args: unknown[]) => void): unknown {
183
+ return socket;
184
+ },
185
+ ...overrides,
186
+ };
187
+ const socket = base as unknown as net.Socket;
188
+ return { socket, writes };
189
+ }
190
+
191
+ function decodeMessages(writes: string[]): Array<Record<string, unknown>> {
192
+ return writes
193
+ .flatMap((chunk) => chunk.split('\n'))
194
+ .filter((line) => line.length > 0)
195
+ .map((line) => JSON.parse(line) as Record<string, unknown>);
196
+ }
197
+
198
+ function createMockEvictableSession(processing = false): EvictableSession & { disposed: boolean } {
199
+ return {
200
+ disposed: false,
201
+ isProcessing() { return processing; },
202
+ dispose() { this.disposed = true; },
203
+ };
204
+ }
205
+
206
+ // ── Tests ────────────────────────────────────────────────────────────
207
+
208
+ describe('DaemonServer lifecycle', () => {
209
+ beforeEach(() => {
210
+ initializeProvidersCalls = 0;
211
+ mockConfig = {
212
+ provider: 'mock-provider',
213
+ providerOrder: ['mock-provider'],
214
+ maxTokens: 4096,
215
+ thinking: false,
216
+ contextWindow: {
217
+ maxInputTokens: 100000,
218
+ thresholdTokens: 80000,
219
+ preserveRecentMessages: 6,
220
+ summaryModel: 'mock-model',
221
+ maxSummaryTokens: 512,
222
+ },
223
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
224
+ };
225
+ });
226
+
227
+ describe('server stop', () => {
228
+ test('stop disposes all sessions and clears state', async () => {
229
+ const server = new DaemonServer();
230
+ const int = internals(server);
231
+
232
+ // Manually inject some sessions
233
+ const s1 = new MockSession('sess-1');
234
+ const s2 = new MockSession('sess-2');
235
+ int.sessions.set('sess-1', s1);
236
+ int.sessions.set('sess-2', s2);
237
+
238
+ const { socket: sock1 } = createFakeSocket();
239
+ const { socket: sock2 } = createFakeSocket();
240
+ int.connectedSockets.add(sock1);
241
+ int.connectedSockets.add(sock2);
242
+
243
+ await server.stop();
244
+
245
+ expect(s1.disposed).toBe(true);
246
+ expect(s2.disposed).toBe(true);
247
+ expect(int.sessions.size).toBe(0);
248
+ expect(int.connectedSockets.size).toBe(0);
249
+ });
250
+
251
+ test('stop is idempotent when no server is listening', async () => {
252
+ const server = new DaemonServer();
253
+ // Should not throw even if start() was never called
254
+ await server.stop();
255
+ });
256
+ });
257
+
258
+ describe('config reload', () => {
259
+ test('refreshConfigFromSources is a no-op when fingerprint is unchanged', () => {
260
+ const server = new DaemonServer();
261
+ const int = internals(server);
262
+
263
+ // Set the fingerprint to match current config
264
+ int.lastConfigFingerprint = JSON.stringify(mockConfig);
265
+
266
+ const changed = int.refreshConfigFromSources();
267
+ expect(changed).toBe(false);
268
+ });
269
+
270
+ test('refreshConfigFromSources detects config change and reinitializes providers', () => {
271
+ const server = new DaemonServer();
272
+ const int = internals(server);
273
+
274
+ // Set to a different fingerprint to simulate previous config
275
+ int.lastConfigFingerprint = '{"different": "config"}';
276
+ const callsBefore = initializeProvidersCalls;
277
+
278
+ const changed = int.refreshConfigFromSources();
279
+
280
+ expect(changed).toBe(true);
281
+ expect(initializeProvidersCalls).toBe(callsBefore + 1);
282
+ });
283
+
284
+ test('config change evicts non-processing sessions', () => {
285
+ const server = new DaemonServer();
286
+ const int = internals(server);
287
+
288
+ const idle = new MockSession('idle');
289
+ const busy = new MockSession('busy');
290
+ busy._setProcessing(true);
291
+ int.sessions.set('idle', idle);
292
+ int.sessions.set('busy', busy);
293
+
294
+ // Force a fingerprint mismatch — first set initial fingerprint
295
+ int.lastConfigFingerprint = '{"old": true}';
296
+
297
+ int.refreshConfigFromSources();
298
+
299
+ // Idle session should be disposed and removed
300
+ expect(idle.disposed).toBe(true);
301
+ expect(int.sessions.has('idle')).toBe(false);
302
+
303
+ // Busy session should be marked stale but kept
304
+ expect(busy.disposed).toBe(false);
305
+ expect(int.sessions.has('busy')).toBe(true);
306
+ expect(busy.isStale()).toBe(true);
307
+ });
308
+
309
+ test('first config init does not evict sessions', () => {
310
+ const server = new DaemonServer();
311
+ const int = internals(server);
312
+
313
+ const s1 = new MockSession('s1');
314
+ int.sessions.set('s1', s1);
315
+
316
+ // Empty fingerprint = first init
317
+ int.lastConfigFingerprint = '';
318
+
319
+ int.refreshConfigFromSources();
320
+
321
+ // Should not evict on first init
322
+ expect(s1.disposed).toBe(false);
323
+ expect(int.sessions.has('s1')).toBe(true);
324
+ });
325
+ });
326
+
327
+ describe('session eviction for reload', () => {
328
+ test('evictSessionsForReload disposes idle and marks processing as stale', () => {
329
+ const server = new DaemonServer();
330
+ const int = internals(server);
331
+
332
+ const idle1 = new MockSession('idle1');
333
+ const idle2 = new MockSession('idle2');
334
+ const busy1 = new MockSession('busy1');
335
+ busy1._setProcessing(true);
336
+ int.sessions.set('idle1', idle1);
337
+ int.sessions.set('idle2', idle2);
338
+ int.sessions.set('busy1', busy1);
339
+
340
+ int.evictSessionsForReload();
341
+
342
+ expect(idle1.disposed).toBe(true);
343
+ expect(idle2.disposed).toBe(true);
344
+ expect(int.sessions.has('idle1')).toBe(false);
345
+ expect(int.sessions.has('idle2')).toBe(false);
346
+
347
+ expect(busy1.disposed).toBe(false);
348
+ expect(int.sessions.has('busy1')).toBe(true);
349
+ expect(busy1.isStale()).toBe(true);
350
+ });
351
+ });
352
+
353
+ describe('clearAllSessions', () => {
354
+ test('disposes and removes every session unconditionally', () => {
355
+ const server = new DaemonServer();
356
+ const int = internals(server);
357
+
358
+ const s1 = new MockSession('s1');
359
+ const s2 = new MockSession('s2');
360
+ s2._setProcessing(true);
361
+ int.sessions.set('s1', s1);
362
+ int.sessions.set('s2', s2);
363
+
364
+ const count = server.clearAllSessions();
365
+
366
+ expect(count).toBe(2);
367
+ expect(s1.disposed).toBe(true);
368
+ expect(s2.disposed).toBe(true);
369
+ expect(int.sessions.size).toBe(0);
370
+ });
371
+ });
372
+ });
373
+
374
+ describe('IPC connection limits', () => {
375
+ test('rejects connections when at MAX_CONNECTIONS', () => {
376
+ const server = new DaemonServer();
377
+ const int = internals(server);
378
+
379
+ // Fill up to 50 connections
380
+ for (let i = 0; i < 50; i++) {
381
+ const { socket } = createFakeSocket();
382
+ int.connectedSockets.add(socket);
383
+ }
384
+
385
+ // 51st connection should be rejected
386
+ const { socket: rejected, writes } = createFakeSocket();
387
+ int.handleConnection(rejected);
388
+
389
+ const messages = decodeMessages(writes);
390
+ const errorMsg = messages.find((m) => m.type === 'error');
391
+ expect(errorMsg).toBeDefined();
392
+ expect(errorMsg!.message).toContain('Connection limit reached');
393
+ expect(rejected.destroyed).toBe(true);
394
+ });
395
+
396
+ test('accepts connections when below MAX_CONNECTIONS', () => {
397
+ const server = new DaemonServer();
398
+ const int = internals(server);
399
+
400
+ // Fill to 49
401
+ for (let i = 0; i < 49; i++) {
402
+ const { socket } = createFakeSocket();
403
+ int.connectedSockets.add(socket);
404
+ }
405
+
406
+ // 50th should be accepted (at limit, not over)
407
+ const { socket: accepted } = createFakeSocket();
408
+ int.handleConnection(accepted);
409
+
410
+ expect(accepted.destroyed).toBeFalsy();
411
+ expect(int.connectedSockets.has(accepted)).toBe(true);
412
+ });
413
+ });
414
+
415
+ describe('SessionEvictor — advanced scenarios', () => {
416
+ let sessions: Map<string, EvictableSession & { disposed: boolean }>;
417
+
418
+ beforeEach(() => {
419
+ sessions = new Map();
420
+ });
421
+
422
+ describe('shouldProtect guard', () => {
423
+ test('protected sessions are never evicted by TTL', () => {
424
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
425
+ ttlMs: 100,
426
+ maxSessions: 100,
427
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
428
+ sweepIntervalMs: 60_000,
429
+ });
430
+ evictor.shouldProtect = (id) => id === 'protected';
431
+
432
+ const protectedSession = createMockEvictableSession();
433
+ const normalSession = createMockEvictableSession();
434
+ sessions.set('protected', protectedSession);
435
+ sessions.set('normal', normalSession);
436
+
437
+ // Both are never touched, so both exceed TTL
438
+
439
+ const result = evictor.sweep();
440
+
441
+ expect(result.ttlEvicted).toBe(1);
442
+ expect(result.skipped).toBe(1);
443
+ expect(protectedSession.disposed).toBe(false);
444
+ expect(normalSession.disposed).toBe(true);
445
+ expect(sessions.has('protected')).toBe(true);
446
+ expect(sessions.has('normal')).toBe(false);
447
+ });
448
+
449
+ test('protected sessions are never evicted by LRU', () => {
450
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
451
+ ttlMs: Number.MAX_SAFE_INTEGER,
452
+ maxSessions: 1,
453
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
454
+ sweepIntervalMs: 60_000,
455
+ });
456
+ evictor.shouldProtect = (id) => id === 'protected';
457
+
458
+ const protectedSession = createMockEvictableSession();
459
+ const normalSession = createMockEvictableSession();
460
+ sessions.set('protected', protectedSession);
461
+ sessions.set('normal', normalSession);
462
+ evictor.touch('protected');
463
+ evictor.touch('normal');
464
+
465
+ // Make protected the oldest
466
+ const lastAccess = (evictor as unknown as { lastAccess: Map<string, number> }).lastAccess;
467
+ lastAccess.set('protected', Date.now() - 10000);
468
+
469
+ const result = evictor.sweep();
470
+
471
+ // Normal should be evicted even though protected is older
472
+ expect(result.lruEvicted).toBe(1);
473
+ expect(protectedSession.disposed).toBe(false);
474
+ expect(normalSession.disposed).toBe(true);
475
+ });
476
+ });
477
+
478
+ describe('combined phases', () => {
479
+ test('TTL and LRU phases combine correctly', () => {
480
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
481
+ ttlMs: 500,
482
+ maxSessions: 2,
483
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
484
+ sweepIntervalMs: 60_000,
485
+ });
486
+
487
+ // Create 4 sessions, 2 expired and 2 fresh
488
+ for (let i = 0; i < 4; i++) {
489
+ const s = createMockEvictableSession();
490
+ sessions.set(`s${i}`, s);
491
+ evictor.touch(`s${i}`);
492
+ }
493
+
494
+ const lastAccess = (evictor as unknown as { lastAccess: Map<string, number> }).lastAccess;
495
+ const now = Date.now();
496
+ // s0 and s1 are expired
497
+ lastAccess.set('s0', now - 1000);
498
+ lastAccess.set('s1', now - 900);
499
+ // s2 and s3 are fresh
500
+ lastAccess.set('s2', now);
501
+ lastAccess.set('s3', now);
502
+
503
+ const result = evictor.sweep();
504
+
505
+ // s0, s1 evicted by TTL; s2, s3 remain (at maxSessions=2, no LRU needed)
506
+ expect(result.ttlEvicted).toBe(2);
507
+ expect(result.lruEvicted).toBe(0);
508
+ expect(sessions.size).toBe(2);
509
+ expect(sessions.has('s2')).toBe(true);
510
+ expect(sessions.has('s3')).toBe(true);
511
+ });
512
+
513
+ test('all processing sessions are fully skipped across phases', () => {
514
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
515
+ ttlMs: 100,
516
+ maxSessions: 1,
517
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
518
+ sweepIntervalMs: 60_000,
519
+ });
520
+
521
+ // 3 processing sessions, all expired
522
+ for (let i = 0; i < 3; i++) {
523
+ const s = createMockEvictableSession(true);
524
+ sessions.set(`s${i}`, s);
525
+ }
526
+
527
+ const result = evictor.sweep();
528
+
529
+ expect(result.ttlEvicted).toBe(0);
530
+ expect(result.lruEvicted).toBe(0);
531
+ expect(result.skipped).toBe(3);
532
+ expect(sessions.size).toBe(3);
533
+ });
534
+ });
535
+
536
+ describe('evictor start/stop lifecycle', () => {
537
+ test('start begins periodic sweeps, stop clears them', async () => {
538
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
539
+ ttlMs: 10,
540
+ maxSessions: 100,
541
+ memoryThresholdBytes: Number.MAX_SAFE_INTEGER,
542
+ sweepIntervalMs: 50,
543
+ });
544
+
545
+ const s1 = createMockEvictableSession();
546
+ sessions.set('s1', s1);
547
+ // Never touched — will be expired immediately
548
+
549
+ evictor.start();
550
+
551
+ // Wait for at least one sweep
552
+ await new Promise((r) => setTimeout(r, 120));
553
+
554
+ expect(s1.disposed).toBe(true);
555
+ expect(sessions.has('s1')).toBe(false);
556
+
557
+ evictor.stop();
558
+ });
559
+
560
+ test('start is idempotent (calling twice does not create duplicate timers)', () => {
561
+ const evictor = new SessionEvictor(sessions as Map<string, EvictableSession>, {
562
+ sweepIntervalMs: 60_000,
563
+ });
564
+
565
+ evictor.start();
566
+ evictor.start(); // should be no-op
567
+
568
+ // Verify by accessing internals — only one timer should exist
569
+ const timer = (evictor as unknown as { sweepTimer: unknown }).sweepTimer;
570
+ expect(timer).toBeDefined();
571
+
572
+ evictor.stop();
573
+ });
574
+ });
575
+ });
576
+
577
+ describe('IPC protocol', () => {
578
+ describe('serialize', () => {
579
+ test('appends newline to JSON', () => {
580
+ const result = serialize({ type: 'ping' } as never);
581
+ expect(result).toBe('{"type":"ping"}\n');
582
+ });
583
+ });
584
+
585
+ describe('createMessageParser', () => {
586
+ test('parses complete messages terminated by newline', () => {
587
+ const parser = createMessageParser();
588
+ const messages = parser.feed('{"type":"ping"}\n');
589
+ expect(messages).toHaveLength(1);
590
+ expect((messages[0] as unknown as Record<string, unknown>).type).toBe('ping');
591
+ });
592
+
593
+ test('buffers partial messages until newline arrives', () => {
594
+ const parser = createMessageParser();
595
+
596
+ const partial1 = parser.feed('{"type":');
597
+ expect(partial1).toHaveLength(0);
598
+
599
+ const partial2 = parser.feed('"ping"}\n');
600
+ expect(partial2).toHaveLength(1);
601
+ expect((partial2[0] as unknown as Record<string, unknown>).type).toBe('ping');
602
+ });
603
+
604
+ test('handles multiple messages in a single chunk', () => {
605
+ const parser = createMessageParser();
606
+ const messages = parser.feed('{"type":"ping"}\n{"type":"pong"}\n');
607
+ expect(messages).toHaveLength(2);
608
+ expect((messages[0] as unknown as Record<string, unknown>).type).toBe('ping');
609
+ expect((messages[1] as unknown as Record<string, unknown>).type).toBe('pong');
610
+ });
611
+
612
+ test('skips malformed JSON lines gracefully', () => {
613
+ const parser = createMessageParser();
614
+ const messages = parser.feed('not json\n{"type":"valid"}\n');
615
+ expect(messages).toHaveLength(1);
616
+ expect((messages[0] as unknown as Record<string, unknown>).type).toBe('valid');
617
+ });
618
+
619
+ test('throws when line exceeds maxLineSize', () => {
620
+ const parser = createMessageParser({ maxLineSize: 50 });
621
+
622
+ expect(() => {
623
+ // Feed a partial message that exceeds the limit without a newline
624
+ parser.feed('a'.repeat(51));
625
+ // Trigger the size check by feeding more data
626
+ parser.feed('\n');
627
+ }).toThrow(/maximum line size/);
628
+ });
629
+
630
+ test('handles empty lines between messages', () => {
631
+ const parser = createMessageParser();
632
+ const messages = parser.feed('{"type":"a"}\n\n\n{"type":"b"}\n');
633
+ expect(messages).toHaveLength(2);
634
+ });
635
+ });
636
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { DictationRequest } from '../daemon/ipc-protocol.js';
3
+ import { detectDictationMode } from '../daemon/handlers/dictation.js';
4
+
5
+ type DictationRequestOverrides = Omit<Partial<DictationRequest>, 'context'> & {
6
+ context?: Partial<DictationRequest['context']>;
7
+ };
8
+
9
+ function makeRequest(overrides: DictationRequestOverrides = {}): DictationRequest {
10
+ const base: DictationRequest = {
11
+ type: 'dictation_request',
12
+ transcription: 'hello there',
13
+ context: {
14
+ bundleIdentifier: 'com.google.Chrome',
15
+ appName: 'Google Chrome',
16
+ windowTitle: 'Inbox - Gmail',
17
+ selectedText: undefined,
18
+ cursorInTextField: false,
19
+ },
20
+ };
21
+ return {
22
+ ...base,
23
+ ...overrides,
24
+ context: {
25
+ ...base.context,
26
+ ...(overrides.context ?? {}),
27
+ },
28
+ };
29
+ }
30
+
31
+ describe('detectDictationMode', () => {
32
+ test('uses command mode when selected text exists', () => {
33
+ const mode = detectDictationMode(makeRequest({
34
+ transcription: 'make this friendlier',
35
+ context: { selectedText: 'Please send me the files.', cursorInTextField: true },
36
+ }));
37
+ expect(mode).toBe('command');
38
+ });
39
+
40
+ test('uses action mode for action-verb utterances', () => {
41
+ const mode = detectDictationMode(makeRequest({
42
+ transcription: 'send Alex a follow up',
43
+ context: { selectedText: undefined, cursorInTextField: false },
44
+ }));
45
+ expect(mode).toBe('action');
46
+ });
47
+
48
+ test('uses dictation mode when cursor is in a text field', () => {
49
+ const mode = detectDictationMode(makeRequest({
50
+ transcription: 'quick update on status',
51
+ context: { cursorInTextField: true },
52
+ }));
53
+ expect(mode).toBe('dictation');
54
+ });
55
+
56
+ test('defaults to dictation when context is ambiguous', () => {
57
+ const mode = detectDictationMode(makeRequest({
58
+ transcription: 'just checking in about tomorrow',
59
+ context: { selectedText: undefined, cursorInTextField: false },
60
+ }));
61
+ expect(mode).toBe('dictation');
62
+ });
63
+ });