vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,169 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { _resetGitServiceRegistry } from '../workspace/git-service.js';
7
+ import { commitAppChange, _resetAppGitState } from '../memory/app-git-service.js';
8
+
9
+ // Mock getDataDir to use a temp directory
10
+ let testDataDir: string;
11
+
12
+ mock.module('../util/platform.js', () => ({
13
+ getDataDir: () => testDataDir,
14
+ getProjectDir: () => testDataDir,
15
+ }));
16
+
17
+ // Re-import app-store after mocking so it uses our temp dir
18
+ const { createApp, updateApp, deleteApp, writeAppFile, editAppFile, getAppsDir } = await import('../memory/app-store.js');
19
+
20
+ describe('App Git Service', () => {
21
+ beforeEach(() => {
22
+ testDataDir = join(tmpdir(), `vellum-app-git-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
23
+ mkdirSync(join(testDataDir, 'apps'), { recursive: true });
24
+ _resetGitServiceRegistry();
25
+ _resetAppGitState();
26
+ });
27
+
28
+ afterEach(() => {
29
+ if (existsSync(testDataDir)) {
30
+ rmSync(testDataDir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ function getGitLog(dir: string): string[] {
35
+ try {
36
+ const output = execFileSync('git', ['log', '--oneline', '--format=%s'], {
37
+ cwd: dir,
38
+ encoding: 'utf-8',
39
+ });
40
+ return output.trim().split('\n').filter(Boolean);
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ test('initializes git repo in apps directory on first commit', async () => {
47
+ const appsDir = getAppsDir();
48
+ expect(existsSync(join(appsDir, '.git'))).toBe(false);
49
+
50
+ await commitAppChange('test commit');
51
+
52
+ expect(existsSync(join(appsDir, '.git'))).toBe(true);
53
+ });
54
+
55
+ test('.gitignore excludes preview files and records', async () => {
56
+ const appsDir = getAppsDir();
57
+ await commitAppChange('test commit');
58
+
59
+ const gitignore = readFileSync(join(appsDir, '.gitignore'), 'utf-8');
60
+ expect(gitignore).toContain('*.preview');
61
+ expect(gitignore).toContain('*/records/');
62
+ });
63
+
64
+ test('createApp produces a commit', async () => {
65
+ createApp({
66
+ name: 'Test App',
67
+ schemaJson: '{}',
68
+ htmlDefinition: '<h1>Hello</h1>',
69
+ });
70
+
71
+ // Give the fire-and-forget commit time to complete
72
+ await new Promise(resolve => setTimeout(resolve, 500));
73
+
74
+ const appsDir = getAppsDir();
75
+ const commits = getGitLog(appsDir);
76
+ expect(commits.some(c => c.includes('Create app: Test App'))).toBe(true);
77
+ });
78
+
79
+ test('updateApp produces a commit with changed fields', async () => {
80
+ const app = createApp({
81
+ name: 'My App',
82
+ schemaJson: '{}',
83
+ htmlDefinition: '<p>v1</p>',
84
+ });
85
+ await new Promise(resolve => setTimeout(resolve, 500));
86
+
87
+ updateApp(app.id, { name: 'My App v2', htmlDefinition: '<p>v2</p>' });
88
+ await new Promise(resolve => setTimeout(resolve, 500));
89
+
90
+ const appsDir = getAppsDir();
91
+ const commits = getGitLog(appsDir);
92
+ expect(commits.some(c => c.includes('Update app: My App v2'))).toBe(true);
93
+ });
94
+
95
+ test('deleteApp produces a commit with app name', async () => {
96
+ const app = createApp({
97
+ name: 'Doomed App',
98
+ schemaJson: '{}',
99
+ htmlDefinition: '<p>bye</p>',
100
+ });
101
+ await new Promise(resolve => setTimeout(resolve, 500));
102
+
103
+ deleteApp(app.id);
104
+ await new Promise(resolve => setTimeout(resolve, 500));
105
+
106
+ const appsDir = getAppsDir();
107
+ const commits = getGitLog(appsDir);
108
+ expect(commits.some(c => c.includes('Delete app: Doomed App'))).toBe(true);
109
+ });
110
+
111
+ test('writeAppFile produces a commit', async () => {
112
+ const app = createApp({
113
+ name: 'File App',
114
+ schemaJson: '{}',
115
+ htmlDefinition: '<p>hi</p>',
116
+ });
117
+ await new Promise(resolve => setTimeout(resolve, 500));
118
+
119
+ writeAppFile(app.id, 'styles.css', 'body { color: red; }');
120
+ await new Promise(resolve => setTimeout(resolve, 500));
121
+
122
+ const appsDir = getAppsDir();
123
+ const commits = getGitLog(appsDir);
124
+ expect(commits.some(c => c.includes('Write styles.css in app'))).toBe(true);
125
+ });
126
+
127
+ test('editAppFile produces a commit on success', async () => {
128
+ const app = createApp({
129
+ name: 'Edit App',
130
+ schemaJson: '{}',
131
+ htmlDefinition: '<p>old text</p>',
132
+ });
133
+ await new Promise(resolve => setTimeout(resolve, 500));
134
+
135
+ const result = editAppFile(app.id, 'index.html', 'old text', 'new text');
136
+ expect(result.ok).toBe(true);
137
+ await new Promise(resolve => setTimeout(resolve, 500));
138
+
139
+ const appsDir = getAppsDir();
140
+ const commits = getGitLog(appsDir);
141
+ expect(commits.some(c => c.includes('Edit index.html in app'))).toBe(true);
142
+ });
143
+
144
+ test('editAppFile does not commit on failure', async () => {
145
+ const app = createApp({
146
+ name: 'No Edit App',
147
+ schemaJson: '{}',
148
+ htmlDefinition: '<p>content</p>',
149
+ });
150
+ await new Promise(resolve => setTimeout(resolve, 500));
151
+
152
+ const commitsBefore = getGitLog(getAppsDir());
153
+
154
+ const result = editAppFile(app.id, 'index.html', 'nonexistent string', 'replacement');
155
+ expect(result.ok).toBe(false);
156
+ await new Promise(resolve => setTimeout(resolve, 500));
157
+
158
+ const commitsAfter = getGitLog(getAppsDir());
159
+ // No new commits should have been created for the failed edit
160
+ expect(commitsAfter.length).toBe(commitsBefore.length);
161
+ });
162
+
163
+ test('commitAppChange swallows errors gracefully', async () => {
164
+ _resetAppGitState();
165
+
166
+ // This should not throw
167
+ await commitAppChange('test');
168
+ });
169
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Hardening tests for the SSE assistant-events endpoint (PR 7).
3
+ *
4
+ * Covers:
5
+ * - Hub evicts oldest subscriber when cap is reached.
6
+ * - SSE route closes evicted subscriber's stream.
7
+ * - Idle heartbeat comment emission.
8
+ * - Subscription cleanup on request abort.
9
+ * - Subscription cleanup on reader cancel.
10
+ */
11
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
12
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'sse-hardening-')));
17
+
18
+ mock.module('../util/platform.js', () => ({
19
+ getRootDir: () => testDir,
20
+ getDataDir: () => testDir,
21
+ isMacOS: () => process.platform === 'darwin',
22
+ isLinux: () => process.platform === 'linux',
23
+ isWindows: () => process.platform === 'win32',
24
+ getSocketPath: () => join(testDir, 'test.sock'),
25
+ getPidPath: () => join(testDir, 'test.pid'),
26
+ getDbPath: () => join(testDir, 'test.db'),
27
+ getLogPath: () => join(testDir, 'test.log'),
28
+ ensureDataDir: () => {},
29
+ }));
30
+
31
+ mock.module('../util/logger.js', () => ({
32
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
33
+ get: () => () => {},
34
+ }),
35
+ }));
36
+
37
+ mock.module('../config/loader.js', () => ({
38
+ getConfig: () => ({
39
+ model: 'test',
40
+ provider: 'test',
41
+ apiKeys: {},
42
+ memory: { enabled: false },
43
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
44
+ secretDetection: { enabled: false },
45
+ }),
46
+ }));
47
+
48
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
49
+ import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
50
+ import { handleSubscribeAssistantEvents } from '../runtime/routes/events-routes.js';
51
+
52
+ initializeDb();
53
+
54
+ afterAll(() => {
55
+ resetDb();
56
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
57
+ });
58
+
59
+ // ── Helpers ───────────────────────────────────────────────────────────────────
60
+
61
+ function clearTables() {
62
+ const db = getDb();
63
+ db.run('DELETE FROM conversation_keys');
64
+ db.run('DELETE FROM conversations');
65
+ }
66
+
67
+ // ── Hub subscriber cap — eviction ─────────────────────────────────────────────
68
+
69
+ describe('AssistantEventHub — subscriber cap', () => {
70
+ test('evicts oldest subscriber when cap is reached', () => {
71
+ const hub = new AssistantEventHub({ maxSubscribers: 1 });
72
+ const evicted: string[] = [];
73
+
74
+ const sub1 = hub.subscribe({ assistantId: 'ast_1' }, () => {}, {
75
+ onEvict: () => evicted.push('sub1'),
76
+ });
77
+ expect(hub.subscriberCount()).toBe(1);
78
+ expect(sub1.active).toBe(true);
79
+
80
+ // Adding sub2 evicts sub1 to make room.
81
+ const sub2 = hub.subscribe({ assistantId: 'ast_1' }, () => {}, {
82
+ onEvict: () => evicted.push('sub2'),
83
+ });
84
+
85
+ expect(hub.subscriberCount()).toBe(1);
86
+ expect(sub1.active).toBe(false);
87
+ expect(sub2.active).toBe(true);
88
+ expect(evicted).toEqual(['sub1']);
89
+
90
+ sub2.dispose();
91
+ });
92
+
93
+ test('evicts in FIFO order across multiple overflows', () => {
94
+ const hub = new AssistantEventHub({ maxSubscribers: 2 });
95
+ const evicted: number[] = [];
96
+
97
+ const sub1 = hub.subscribe({ assistantId: 'ast_1' }, () => {}, { onEvict: () => evicted.push(1) });
98
+ const sub2 = hub.subscribe({ assistantId: 'ast_1' }, () => {}, { onEvict: () => evicted.push(2) });
99
+
100
+ // 3rd subscriber evicts oldest (sub1)
101
+ const sub3 = hub.subscribe({ assistantId: 'ast_1' }, () => {}, { onEvict: () => evicted.push(3) });
102
+ expect(evicted).toEqual([1]);
103
+ expect(sub1.active).toBe(false);
104
+ expect(sub2.active).toBe(true);
105
+
106
+ // 4th subscriber evicts next oldest (sub2)
107
+ const sub4 = hub.subscribe({ assistantId: 'ast_1' }, () => {}, { onEvict: () => evicted.push(4) });
108
+ expect(evicted).toEqual([1, 2]);
109
+ expect(sub2.active).toBe(false);
110
+ expect(sub3.active).toBe(true);
111
+ expect(hub.subscriberCount()).toBe(2);
112
+
113
+ sub3.dispose();
114
+ sub4.dispose();
115
+ });
116
+
117
+ test('maxSubscribers: 0 throws RangeError (nothing to evict)', () => {
118
+ const hub = new AssistantEventHub({ maxSubscribers: 0 });
119
+ expect(() => hub.subscribe({ assistantId: 'ast_1' }, () => {})).toThrow(RangeError);
120
+ });
121
+
122
+ test('subscribe succeeds after disposal frees a slot', () => {
123
+ const hub = new AssistantEventHub({ maxSubscribers: 1 });
124
+ const sub = hub.subscribe({ assistantId: 'ast_1' }, () => {});
125
+ sub.dispose();
126
+
127
+ // Should not throw now that the slot is free.
128
+ expect(() => hub.subscribe({ assistantId: 'ast_1' }, () => {})).not.toThrow();
129
+ });
130
+
131
+ test('default hub accepts many subscribers without eviction', () => {
132
+ const hub = new AssistantEventHub();
133
+ const N = 50;
134
+ const subs = Array.from({ length: N }, () =>
135
+ hub.subscribe({ assistantId: 'ast_1' }, () => {}),
136
+ );
137
+ expect(hub.subscriberCount()).toBe(N);
138
+ subs.forEach((s) => s.dispose());
139
+ expect(hub.subscriberCount()).toBe(0);
140
+ });
141
+ });
142
+
143
+ // ── SSE route — eviction on capacity overflow ──────────────────────────────────
144
+
145
+ describe('SSE route — capacity limit', () => {
146
+ beforeEach(clearTables);
147
+
148
+ test('new connection evicts oldest and returns 200', async () => {
149
+ const hub = new AssistantEventHub({ maxSubscribers: 1 });
150
+ const opts = { hub, heartbeatIntervalMs: 60_000 };
151
+
152
+ const ac1 = new AbortController();
153
+ const req1 = new Request('http://localhost/v1/events?conversationKey=evict-a', { signal: ac1.signal });
154
+ const res1 = handleSubscribeAssistantEvents(req1, new URL(req1.url), opts);
155
+ expect(res1.status).toBe(200);
156
+ expect(hub.subscriberCount()).toBe(1);
157
+
158
+ const reader1 = res1.body!.getReader();
159
+
160
+ // Second connection evicts first.
161
+ const ac2 = new AbortController();
162
+ const req2 = new Request('http://localhost/v1/events?conversationKey=evict-b', { signal: ac2.signal });
163
+ const res2 = handleSubscribeAssistantEvents(req2, new URL(req2.url), opts);
164
+ expect(res2.status).toBe(200);
165
+ expect(hub.subscriberCount()).toBe(1); // evicted 1, added 1
166
+
167
+ // First stream should be closed by onEvict.
168
+ const { done } = await reader1.read();
169
+ expect(done).toBe(true);
170
+
171
+ ac2.abort();
172
+ });
173
+
174
+ test('returns 503 only when maxSubscribers is 0', async () => {
175
+ const hub = new AssistantEventHub({ maxSubscribers: 0 });
176
+ const req = new Request(
177
+ 'http://localhost/v1/events?conversationKey=cap-zero-test',
178
+ { signal: new AbortController().signal },
179
+ );
180
+
181
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
182
+ expect(response.status).toBe(503);
183
+ const body = await response.json() as { error: string };
184
+ expect(body.error).toMatch(/Too many concurrent connections/);
185
+ });
186
+
187
+ test('returns 200 when hub has remaining capacity', () => {
188
+ const hub = new AssistantEventHub({ maxSubscribers: 2 });
189
+ const ac = new AbortController();
190
+ const req = new Request(
191
+ 'http://localhost/v1/events?conversationKey=cap-ok-test',
192
+ { signal: ac.signal },
193
+ );
194
+
195
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
196
+
197
+ expect(response.status).toBe(200);
198
+ ac.abort(); // clean up the subscription
199
+ });
200
+ });
201
+
202
+ // ── SSE route — heartbeat ────────────────────────────────────────────────────
203
+
204
+ describe('SSE route — heartbeat', () => {
205
+ beforeEach(clearTables);
206
+
207
+ test('emits SSE comment frames on the configured interval', async () => {
208
+ const hub = new AssistantEventHub();
209
+ const ac = new AbortController();
210
+ const req = new Request(
211
+ 'http://localhost/v1/events?conversationKey=hb-emit-test',
212
+ { signal: ac.signal },
213
+ );
214
+
215
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
216
+ hub,
217
+ heartbeatIntervalMs: 10,
218
+ });
219
+
220
+ // Wait for at least one heartbeat interval to fire.
221
+ await new Promise((r) => setTimeout(r, 30));
222
+
223
+ const reader = response.body!.getReader();
224
+ const { value } = await reader.read();
225
+ ac.abort();
226
+ reader.cancel();
227
+
228
+ const text = new TextDecoder().decode(value);
229
+ expect(text).toBe(': heartbeat\n\n');
230
+ });
231
+
232
+ test('emits multiple heartbeats over time', async () => {
233
+ const hub = new AssistantEventHub();
234
+ const ac = new AbortController();
235
+ const req = new Request(
236
+ 'http://localhost/v1/events?conversationKey=hb-multi-test',
237
+ { signal: ac.signal },
238
+ );
239
+
240
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
241
+ hub,
242
+ heartbeatIntervalMs: 10,
243
+ });
244
+
245
+ // Wait for several intervals.
246
+ await new Promise((r) => setTimeout(r, 50));
247
+
248
+ const chunks: string[] = [];
249
+ const reader = response.body!.getReader();
250
+ // Drain without blocking by reading with a short deadline.
251
+ for (let i = 0; i < 3; i++) {
252
+ const { value, done } = await Promise.race([
253
+ reader.read(),
254
+ new Promise<{ value: undefined; done: true }>((r) =>
255
+ setTimeout(() => r({ value: undefined, done: true }), 20),
256
+ ),
257
+ ]);
258
+ if (done || !value) break;
259
+ chunks.push(new TextDecoder().decode(value));
260
+ }
261
+
262
+ ac.abort();
263
+ reader.cancel();
264
+
265
+ expect(chunks.length).toBeGreaterThan(0);
266
+ expect(chunks.every((c) => c === ': heartbeat\n\n')).toBe(true);
267
+ });
268
+ });
269
+
270
+ // ── SSE route — disconnect cleanup ───────────────────────────────────────────
271
+
272
+ describe('SSE route — disconnect cleanup', () => {
273
+ beforeEach(clearTables);
274
+
275
+ test('aborting the request disposes the subscription', async () => {
276
+ const hub = new AssistantEventHub();
277
+ const ac = new AbortController();
278
+ const req = new Request(
279
+ 'http://localhost/v1/events?conversationKey=abort-cleanup-test',
280
+ { signal: ac.signal },
281
+ );
282
+
283
+ handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
284
+
285
+ expect(hub.subscriberCount()).toBe(1);
286
+
287
+ ac.abort();
288
+
289
+ // Give the abort listener a tick to run.
290
+ await new Promise((r) => setTimeout(r, 0));
291
+
292
+ expect(hub.subscriberCount()).toBe(0);
293
+ });
294
+
295
+ test('cancelling the reader disposes the subscription', async () => {
296
+ const hub = new AssistantEventHub();
297
+ const ac = new AbortController();
298
+ const req = new Request(
299
+ 'http://localhost/v1/events?conversationKey=cancel-cleanup-test',
300
+ { signal: ac.signal },
301
+ );
302
+
303
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
304
+
305
+ expect(hub.subscriberCount()).toBe(1);
306
+
307
+ const reader = response.body!.getReader();
308
+ await reader.cancel();
309
+
310
+ await new Promise((r) => setTimeout(r, 0));
311
+
312
+ expect(hub.subscriberCount()).toBe(0);
313
+ ac.abort();
314
+ });
315
+ });
@@ -40,22 +40,22 @@ describe('browser skill cutover — startup tool payload', () => {
40
40
 
41
41
  test('total tool definition count reflects removal of 10 browser tools', () => {
42
42
  const definitions = getAllToolDefinitions();
43
- // Startup has exactly 48 definitions (no browser tools).
43
+ // Startup has ~31 definitions (no browser tools).
44
44
  // Allow wider drift for unrelated tool additions while still failing if
45
45
  // browser tools are reintroduced at startup (+10 definitions).
46
- expect(definitions.length).toBeGreaterThanOrEqual(46);
47
- expect(definitions.length).toBeLessThanOrEqual(65);
46
+ expect(definitions.length).toBeGreaterThanOrEqual(25);
47
+ expect(definitions.length).toBeLessThanOrEqual(50);
48
48
  });
49
49
 
50
50
  test('serialized tool definitions payload still exceeds a reasonable floor', () => {
51
51
  const definitions = getAllToolDefinitions();
52
52
  const serialized = JSON.stringify(definitions);
53
- // Startup payload is ~45 034 chars without browser tools.
54
- // Floor at 30 000 catches accidental wholesale removal; ceiling at 47 000
55
- // gives ~2 000 char headroom while still catching browser tool leakage
53
+ // Startup payload is ~22 000 chars without browser tools.
54
+ // Floor at 15 000 catches accidental wholesale removal; ceiling at 35 000
55
+ // gives headroom while still catching browser tool leakage
56
56
  // (~4 640 chars would push it past the ceiling).
57
- expect(serialized.length).toBeGreaterThan(30_000);
58
- expect(serialized.length).toBeLessThan(47_000);
57
+ expect(serialized.length).toBeGreaterThan(15_000);
58
+ expect(serialized.length).toBeLessThan(35_000);
59
59
  });
60
60
 
61
61
  test('no browser-categorised tools remain in startup registry', () => {
@@ -60,22 +60,22 @@ describe('browser skill migration end-state', () => {
60
60
 
61
61
  test('startup tool definition count is reduced (no browser tools)', () => {
62
62
  const definitions = getAllToolDefinitions();
63
- // Startup has exactly 48 definitions (no browser tools).
63
+ // Startup has ~31 definitions (no browser tools).
64
64
  // Allow wider drift for unrelated tool additions while still failing if
65
65
  // browser tools are reintroduced at startup (+10 definitions).
66
- expect(definitions.length).toBeGreaterThanOrEqual(46);
67
- expect(definitions.length).toBeLessThanOrEqual(65);
66
+ expect(definitions.length).toBeGreaterThanOrEqual(25);
67
+ expect(definitions.length).toBeLessThanOrEqual(50);
68
68
 
69
69
  const defNames = definitions.map((d) => d.name);
70
70
  for (const name of BROWSER_TOOLS) {
71
71
  expect(defNames).not.toContain(name);
72
72
  }
73
73
 
74
- // Payload ceiling: startup payload is ~45 034 chars. Browser tools
74
+ // Payload ceiling: startup payload is ~22 000 chars. Browser tools
75
75
  // contribute ~4 640 chars — if they leak back in, the total would exceed
76
- // 47 000. The 2 000-char margin absorbs minor tool additions.
76
+ // 35 000. The margin absorbs minor tool additions.
77
77
  const payloadSize = JSON.stringify(definitions).length;
78
- expect(payloadSize).toBeLessThan(47_000);
78
+ expect(payloadSize).toBeLessThan(35_000);
79
79
  });
80
80
 
81
81
  // ── 2. Browser skill exists and is active ──────────────────────────