@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.
- package/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- 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
|
+
});
|