vellum 0.2.0 → 0.2.1

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vellum",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -87,6 +87,13 @@ exports[`IPC message snapshots ClientMessage types model_set serializes to expec
87
87
  }
88
88
  `;
89
89
 
90
+ exports[`IPC message snapshots ClientMessage types image_gen_model_set serializes to expected JSON 1`] = `
91
+ {
92
+ "model": "gemini-2.5-flash-image",
93
+ "type": "image_gen_model_set",
94
+ }
95
+ `;
96
+
90
97
  exports[`IPC message snapshots ClientMessage types history_request serializes to expected JSON 1`] = `
91
98
  {
92
99
  "sessionId": "sess-001",
@@ -688,6 +695,13 @@ exports[`IPC message snapshots ClientMessage types work_item_complete serializes
688
695
  }
689
696
  `;
690
697
 
698
+ exports[`IPC message snapshots ClientMessage types work_item_delete serializes to expected JSON 1`] = `
699
+ {
700
+ "id": "wi-001",
701
+ "type": "work_item_delete",
702
+ }
703
+ `;
704
+
691
705
  exports[`IPC message snapshots ClientMessage types work_item_run_task serializes to expected JSON 1`] = `
692
706
  {
693
707
  "id": "wi-001",
@@ -2017,6 +2031,14 @@ exports[`IPC message snapshots ServerMessage types work_item_update_response ser
2017
2031
  }
2018
2032
  `;
2019
2033
 
2034
+ exports[`IPC message snapshots ServerMessage types work_item_delete_response serializes to expected JSON 1`] = `
2035
+ {
2036
+ "id": "wi-001",
2037
+ "success": true,
2038
+ "type": "work_item_delete_response",
2039
+ }
2040
+ `;
2041
+
2020
2042
  exports[`IPC message snapshots ServerMessage types work_item_run_task_response serializes to expected JSON 1`] = `
2021
2043
  {
2022
2044
  "id": "wi-001",
@@ -2042,6 +2064,12 @@ exports[`IPC message snapshots ServerMessage types work_item_status_changed seri
2042
2064
  }
2043
2065
  `;
2044
2066
 
2067
+ exports[`IPC message snapshots ServerMessage types tasks_changed serializes to expected JSON 1`] = `
2068
+ {
2069
+ "type": "tasks_changed",
2070
+ }
2071
+ `;
2072
+
2045
2073
  exports[`IPC message snapshots ServerMessage types open_tasks_window serializes to expected JSON 1`] = `
2046
2074
  {
2047
2075
  "type": "open_tasks_window",
@@ -1,5 +1,4 @@
1
1
  import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
2
- import { createHash } from 'node:crypto';
3
2
 
4
3
  // Mock the logger before importing the module under test
5
4
  mock.module('../util/logger.js', () => ({
@@ -11,27 +10,6 @@ mock.module('../util/logger.js', () => ({
11
10
 
12
11
  import { extractRemoteUrls, materializeAssets } from '../bundler/app-bundler.js';
13
12
 
14
- // ---------------------------------------------------------------------------
15
- // Helpers
16
- // ---------------------------------------------------------------------------
17
-
18
- /** Compute expected asset filename for a URL (mirrors the production logic). */
19
- function expectedFilename(url: string): string {
20
- const hash = createHash('sha256').update(url).digest('hex').slice(0, 12);
21
- let ext = '';
22
- try {
23
- const parsed = new URL(url);
24
- const match = parsed.pathname.match(/\.\w+$/);
25
- ext = match ? match[0] : '';
26
- } catch {
27
- // no extension
28
- }
29
- if (!ext || ext.length > 10 || !/^\.\w+$/.test(ext)) {
30
- ext = '';
31
- }
32
- return `${hash}${ext}`;
33
- }
34
-
35
13
  // ---------------------------------------------------------------------------
36
14
  // extractRemoteUrls
37
15
  // ---------------------------------------------------------------------------
@@ -167,10 +145,9 @@ describe('materializeAssets', () => {
167
145
  const html = `<img src="${imageUrl}">`;
168
146
  const result = await materializeAssets(html);
169
147
 
170
- const filename = expectedFilename(imageUrl);
171
- expect(result.rewrittenHtml).toBe(`<img src="assets/${filename}">`);
148
+ expect(result.rewrittenHtml).toBe('<img src="assets/e724846245db.png">');
172
149
  expect(result.assets).toHaveLength(1);
173
- expect(result.assets[0].archivePath).toBe(`assets/${filename}`);
150
+ expect(result.assets[0].archivePath).toBe('assets/e724846245db.png');
174
151
  expect(result.assets[0].data).toEqual(imageData);
175
152
  });
176
153
 
@@ -193,9 +170,14 @@ describe('materializeAssets', () => {
193
170
  const result = await materializeAssets(html);
194
171
 
195
172
  expect(result.assets).toHaveLength(3);
173
+
174
+ const expectedFilenames: Record<string, string> = {
175
+ 'https://cdn.example.com/a.png': '6155f67efa62.png',
176
+ 'https://cdn.example.com/b.css': '5e6d8d571910.css',
177
+ 'https://cdn.example.com/c.js': '20fb1ea9b4c9.js',
178
+ };
196
179
  for (const url of urls) {
197
- const filename = expectedFilename(url);
198
- expect(result.rewrittenHtml).toContain(`assets/${filename}`);
180
+ expect(result.rewrittenHtml).toContain(`assets/${expectedFilenames[url]}`);
199
181
  expect(result.rewrittenHtml).not.toContain(url);
200
182
  }
201
183
  });
@@ -242,8 +224,7 @@ describe('materializeAssets', () => {
242
224
  const html = `<img src="${goodUrl}"><img src="${badUrl}">`;
243
225
  const result = await materializeAssets(html);
244
226
 
245
- const goodFilename = expectedFilename(goodUrl);
246
- expect(result.rewrittenHtml).toContain(`assets/${goodFilename}`);
227
+ expect(result.rewrittenHtml).toContain('assets/691e2a787421.png');
247
228
  expect(result.rewrittenHtml).toContain(badUrl);
248
229
  expect(result.assets).toHaveLength(1);
249
230
  });
@@ -265,9 +246,8 @@ describe('materializeAssets', () => {
265
246
  expect(result.assets).toHaveLength(1);
266
247
 
267
248
  // Both occurrences should be rewritten
268
- const filename = expectedFilename(imageUrl);
269
249
  expect(result.rewrittenHtml).not.toContain(imageUrl);
270
- const matches = result.rewrittenHtml.match(new RegExp(`assets/${filename}`, 'g'));
250
+ const matches = result.rewrittenHtml.match(/assets\/2f7fc0f99275\.png/g);
271
251
  expect(matches).toHaveLength(2);
272
252
  });
273
253
 
@@ -306,8 +286,7 @@ describe('materializeAssets', () => {
306
286
  const html = '<style>body { background: url("https://cdn.example.com/bg.jpg"); }</style>';
307
287
  const result = await materializeAssets(html);
308
288
 
309
- const filename = expectedFilename(cssUrl);
310
- expect(result.rewrittenHtml).toContain(`assets/${filename}`);
289
+ expect(result.rewrittenHtml).toContain('assets/8550eecd4975.jpg');
311
290
  expect(result.rewrittenHtml).not.toContain(cssUrl);
312
291
  });
313
292
  });
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
8
8
 
9
- import { eagerModules, eagerModuleToolNames } from '../tools/tool-manifest.js';
9
+ import { eagerModuleToolNames } from '../tools/tool-manifest.js';
10
10
  import {
11
11
  initializeTools,
12
12
  getAllTools,
@@ -52,10 +52,6 @@ describe('browser skill migration end-state', () => {
52
52
  }
53
53
  });
54
54
 
55
- test('browser module is NOT in eagerModules', () => {
56
- expect(eagerModules).not.toContain('./browser/headless-browser.js');
57
- });
58
-
59
55
  test('browser tool names are NOT in eagerModuleToolNames', () => {
60
56
  for (const name of BROWSER_TOOLS) {
61
57
  expect(eagerModuleToolNames).not.toContain(name);
@@ -0,0 +1,328 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock, type Mock } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { EventEmitter } from 'node:events';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'call-orchestrator-test-'));
8
+
9
+ // ── Platform + logger mocks (must come before any source imports) ────
10
+
11
+ mock.module('../util/platform.js', () => ({
12
+ getDataDir: () => testDir,
13
+ isMacOS: () => process.platform === 'darwin',
14
+ isLinux: () => process.platform === 'linux',
15
+ isWindows: () => process.platform === 'win32',
16
+ getSocketPath: () => join(testDir, 'test.sock'),
17
+ getPidPath: () => join(testDir, 'test.pid'),
18
+ getDbPath: () => join(testDir, 'test.db'),
19
+ getLogPath: () => join(testDir, 'test.log'),
20
+ ensureDataDir: () => {},
21
+ }));
22
+
23
+ mock.module('../util/logger.js', () => ({
24
+ getLogger: () =>
25
+ new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ }));
29
+
30
+ // ── Config mock ─────────────────────────────────────────────────────
31
+
32
+ mock.module('../config/loader.js', () => ({
33
+ getConfig: () => ({ apiKeys: { anthropic: 'test-key' } }),
34
+ }));
35
+
36
+ // ── Helpers for building mock streaming responses ───────────────────
37
+
38
+ /**
39
+ * Creates a mock Anthropic stream object that emits 'text' events
40
+ * for each token and resolves `finalMessage()` with the full response.
41
+ */
42
+ function createMockStream(tokens: string[]) {
43
+ const emitter = new EventEmitter();
44
+ const fullText = tokens.join('');
45
+
46
+ const stream = {
47
+ on: (event: string, handler: (...args: unknown[]) => void) => {
48
+ emitter.on(event, handler);
49
+ return stream;
50
+ },
51
+ finalMessage: () => {
52
+ // Emit tokens synchronously so the on('text') handler has fired
53
+ // before finalMessage resolves.
54
+ for (const token of tokens) {
55
+ emitter.emit('text', token);
56
+ }
57
+ return Promise.resolve({
58
+ content: [{ type: 'text', text: fullText }],
59
+ });
60
+ },
61
+ };
62
+
63
+ return stream;
64
+ }
65
+
66
+ // ── Anthropic SDK mock ──────────────────────────────────────────────
67
+
68
+ let mockStreamFn: Mock<(...args: unknown[]) => unknown>;
69
+
70
+ mock.module('@anthropic-ai/sdk', () => {
71
+ mockStreamFn = mock((..._args: unknown[]) => createMockStream(['Hello', ' there']));
72
+ return {
73
+ default: class MockAnthropic {
74
+ messages = {
75
+ stream: (...args: unknown[]) => mockStreamFn(...args),
76
+ };
77
+ },
78
+ };
79
+ });
80
+
81
+ // ── Import source modules after all mocks are registered ────────────
82
+
83
+ import { initializeDb, getDb } from '../memory/db.js';
84
+ import { conversations } from '../memory/schema.js';
85
+ import {
86
+ createCallSession,
87
+ getCallSession,
88
+ getPendingQuestion,
89
+ } from '../calls/call-store.js';
90
+ import {
91
+ getCallOrchestrator,
92
+ } from '../calls/call-state.js';
93
+ import { CallOrchestrator } from '../calls/call-orchestrator.js';
94
+ import type { RelayConnection } from '../calls/relay-server.js';
95
+
96
+ initializeDb();
97
+
98
+ afterAll(() => {
99
+ try {
100
+ rmSync(testDir, { recursive: true });
101
+ } catch {
102
+ /* best effort */
103
+ }
104
+ });
105
+
106
+ // ── RelayConnection mock factory ────────────────────────────────────
107
+
108
+ interface MockRelay extends RelayConnection {
109
+ sentTokens: Array<{ token: string; last: boolean }>;
110
+ endCalled: boolean;
111
+ endReason: string | undefined;
112
+ }
113
+
114
+ function createMockRelay(): MockRelay {
115
+ const state = {
116
+ sentTokens: [] as Array<{ token: string; last: boolean }>,
117
+ _endCalled: false,
118
+ _endReason: undefined as string | undefined,
119
+ };
120
+
121
+ return {
122
+ get sentTokens() { return state.sentTokens; },
123
+ get endCalled() { return state._endCalled; },
124
+ get endReason() { return state._endReason; },
125
+ sendTextToken(token: string, last: boolean) {
126
+ state.sentTokens.push({ token, last });
127
+ },
128
+ endSession(reason?: string) {
129
+ state._endCalled = true;
130
+ state._endReason = reason;
131
+ },
132
+ } as unknown as MockRelay;
133
+ }
134
+
135
+ // ── Helpers ─────────────────────────────────────────────────────────
136
+
137
+ let ensuredConvIds = new Set<string>();
138
+ function ensureConversation(id: string): void {
139
+ if (ensuredConvIds.has(id)) return;
140
+ const db = getDb();
141
+ const now = Date.now();
142
+ db.insert(conversations).values({
143
+ id,
144
+ title: `Test conversation ${id}`,
145
+ createdAt: now,
146
+ updatedAt: now,
147
+ }).run();
148
+ ensuredConvIds.add(id);
149
+ }
150
+
151
+ function resetTables() {
152
+ const db = getDb();
153
+ db.run('DELETE FROM call_pending_questions');
154
+ db.run('DELETE FROM call_events');
155
+ db.run('DELETE FROM call_sessions');
156
+ db.run('DELETE FROM conversations');
157
+ ensuredConvIds = new Set();
158
+ }
159
+
160
+ /**
161
+ * Create a call session and an orchestrator wired to a mock relay.
162
+ */
163
+ function setupOrchestrator(task?: string) {
164
+ ensureConversation('conv-orch-test');
165
+ const session = createCallSession({
166
+ conversationId: 'conv-orch-test',
167
+ provider: 'twilio',
168
+ fromNumber: '+15551111111',
169
+ toNumber: '+15552222222',
170
+ task,
171
+ });
172
+ const relay = createMockRelay();
173
+ const orchestrator = new CallOrchestrator(session.id, relay as unknown as RelayConnection, task ?? null);
174
+ return { session, relay, orchestrator };
175
+ }
176
+
177
+ describe('call-orchestrator', () => {
178
+ beforeEach(() => {
179
+ resetTables();
180
+ // Reset the stream mock to default behaviour
181
+ mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
182
+ });
183
+
184
+ // ── handleCallerUtterance ─────────────────────────────────────────
185
+
186
+ test('handleCallerUtterance: streams tokens via sendTextToken', async () => {
187
+ mockStreamFn.mockImplementation(() => createMockStream(['Hi', ', how', ' are you?']));
188
+ const { relay, orchestrator } = setupOrchestrator();
189
+
190
+ await orchestrator.handleCallerUtterance('Hello');
191
+
192
+ // Verify tokens were sent to the relay
193
+ const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
194
+ expect(nonEmptyTokens.length).toBeGreaterThan(0);
195
+ // The last token should have last=true (empty string token signaling end)
196
+ const lastToken = relay.sentTokens[relay.sentTokens.length - 1];
197
+ expect(lastToken.last).toBe(true);
198
+
199
+ orchestrator.destroy();
200
+ });
201
+
202
+ test('handleCallerUtterance: sends last=true at end of turn', async () => {
203
+ mockStreamFn.mockImplementation(() => createMockStream(['Simple response.']));
204
+ const { relay, orchestrator } = setupOrchestrator();
205
+
206
+ await orchestrator.handleCallerUtterance('Test');
207
+
208
+ // Find the final empty-string token that marks end of turn
209
+ const endMarkers = relay.sentTokens.filter((t) => t.last === true);
210
+ expect(endMarkers.length).toBeGreaterThanOrEqual(1);
211
+
212
+ orchestrator.destroy();
213
+ });
214
+
215
+ // ── ASK_USER pattern ──────────────────────────────────────────────
216
+
217
+ test('ASK_USER pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
218
+ mockStreamFn.mockImplementation(() =>
219
+ createMockStream(['Let me check on that. ', '[ASK_USER: What date works best?]']),
220
+ );
221
+ const { session, relay, orchestrator } = setupOrchestrator('Book appointment');
222
+
223
+ await orchestrator.handleCallerUtterance('I need to schedule something');
224
+
225
+ // Verify a pending question was created
226
+ const question = getPendingQuestion(session.id);
227
+ expect(question).not.toBeNull();
228
+ expect(question!.questionText).toBe('What date works best?');
229
+ expect(question!.status).toBe('pending');
230
+
231
+ // Verify session status was updated to waiting_on_user
232
+ const updatedSession = getCallSession(session.id);
233
+ expect(updatedSession!.status).toBe('waiting_on_user');
234
+
235
+ // The ASK_USER marker text should NOT appear in the relay tokens
236
+ const allText = relay.sentTokens.map((t) => t.token).join('');
237
+ expect(allText).not.toContain('[ASK_USER:');
238
+
239
+ orchestrator.destroy();
240
+ });
241
+
242
+ // ── END_CALL pattern ──────────────────────────────────────────────
243
+
244
+ test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
245
+ mockStreamFn.mockImplementation(() =>
246
+ createMockStream(['Thank you for calling, goodbye! ', '[END_CALL]']),
247
+ );
248
+ const { session, relay, orchestrator } = setupOrchestrator();
249
+
250
+ await orchestrator.handleCallerUtterance('That is all, thanks');
251
+
252
+ // endSession should have been called
253
+ expect(relay.endCalled).toBe(true);
254
+
255
+ // Session status should be completed
256
+ const updatedSession = getCallSession(session.id);
257
+ expect(updatedSession!.status).toBe('completed');
258
+ expect(updatedSession!.endedAt).not.toBeNull();
259
+
260
+ // The END_CALL marker text should NOT appear in the relay tokens
261
+ const allText = relay.sentTokens.map((t) => t.token).join('');
262
+ expect(allText).not.toContain('[END_CALL]');
263
+
264
+ orchestrator.destroy();
265
+ });
266
+
267
+ // ── handleUserAnswer ──────────────────────────────────────────────
268
+
269
+ test('handleUserAnswer: appends USER_ANSWERED to history and runs LLM', async () => {
270
+ // First utterance triggers ASK_USER
271
+ mockStreamFn.mockImplementation(() =>
272
+ createMockStream(['Hold on. [ASK_USER: Preferred time?]']),
273
+ );
274
+ const { relay, orchestrator } = setupOrchestrator();
275
+
276
+ await orchestrator.handleCallerUtterance('I need an appointment');
277
+
278
+ // Now provide the answer — reset mock for second LLM call
279
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
280
+ // Verify the messages include the USER_ANSWERED marker
281
+ const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
282
+ const lastUserMsg = firstArg.messages.filter((m: { role: string }) => m.role === 'user').pop();
283
+ expect(lastUserMsg?.content).toContain('[USER_ANSWERED: 3pm tomorrow]');
284
+ return createMockStream(['Great, I have scheduled for 3pm tomorrow.']);
285
+ });
286
+
287
+ await orchestrator.handleUserAnswer('3pm tomorrow');
288
+
289
+ // Should have streamed a response for the answer
290
+ const tokensAfterAnswer = relay.sentTokens.filter((t) => t.token.includes('3pm'));
291
+ expect(tokensAfterAnswer.length).toBeGreaterThan(0);
292
+
293
+ orchestrator.destroy();
294
+ });
295
+
296
+ // ── handleInterrupt ───────────────────────────────────────────────
297
+
298
+ test('handleInterrupt: resets state to idle', () => {
299
+ const { orchestrator } = setupOrchestrator();
300
+
301
+ // Calling handleInterrupt should not throw
302
+ orchestrator.handleInterrupt();
303
+
304
+ orchestrator.destroy();
305
+ });
306
+
307
+ // ── destroy ───────────────────────────────────────────────────────
308
+
309
+ test('destroy: unregisters orchestrator', () => {
310
+ const { session, orchestrator } = setupOrchestrator();
311
+
312
+ // Orchestrator should be registered
313
+ expect(getCallOrchestrator(session.id)).toBeDefined();
314
+
315
+ orchestrator.destroy();
316
+
317
+ // After destroy, orchestrator should be unregistered
318
+ expect(getCallOrchestrator(session.id)).toBeUndefined();
319
+ });
320
+
321
+ test('destroy: can be called multiple times without error', () => {
322
+ const { orchestrator } = setupOrchestrator();
323
+
324
+ orchestrator.destroy();
325
+ // Second destroy should not throw
326
+ expect(() => orchestrator.destroy()).not.toThrow();
327
+ });
328
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test';
2
+
3
+ mock.module('../util/logger.js', () => ({
4
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
5
+ get: () => () => {},
6
+ }),
7
+ }));
8
+
9
+ import {
10
+ registerCallQuestionNotifier,
11
+ unregisterCallQuestionNotifier,
12
+ fireCallQuestionNotifier,
13
+ registerCallCompletionNotifier,
14
+ unregisterCallCompletionNotifier,
15
+ fireCallCompletionNotifier,
16
+ registerCallOrchestrator,
17
+ unregisterCallOrchestrator,
18
+ getCallOrchestrator,
19
+ } from '../calls/call-state.js';
20
+ import type { CallOrchestrator } from '../calls/call-orchestrator.js';
21
+
22
+ describe('call-state', () => {
23
+ // Clean up notifiers between tests
24
+ beforeEach(() => {
25
+ unregisterCallQuestionNotifier('test-conv');
26
+ unregisterCallCompletionNotifier('test-conv');
27
+ unregisterCallOrchestrator('test-session');
28
+ });
29
+
30
+ // ── Question notifiers ────────────────────────────────────────────
31
+
32
+ test('registerCallQuestionNotifier + fireCallQuestionNotifier: callback receives args', () => {
33
+ let receivedSessionId = '';
34
+ let receivedQuestion = '';
35
+
36
+ registerCallQuestionNotifier('test-conv', (callSessionId, question) => {
37
+ receivedSessionId = callSessionId;
38
+ receivedQuestion = question;
39
+ });
40
+
41
+ fireCallQuestionNotifier('test-conv', 'session-123', 'What is the date?');
42
+
43
+ expect(receivedSessionId).toBe('session-123');
44
+ expect(receivedQuestion).toBe('What is the date?');
45
+ });
46
+
47
+ test('unregisterCallQuestionNotifier: fire after unregister does nothing', () => {
48
+ let called = false;
49
+
50
+ registerCallQuestionNotifier('test-conv', () => {
51
+ called = true;
52
+ });
53
+
54
+ unregisterCallQuestionNotifier('test-conv');
55
+ fireCallQuestionNotifier('test-conv', 'session-123', 'Some question');
56
+
57
+ expect(called).toBe(false);
58
+ });
59
+
60
+ test('fireCallQuestionNotifier does nothing when no notifier is registered', () => {
61
+ // Should not throw
62
+ fireCallQuestionNotifier('unregistered-conv', 'session-1', 'question');
63
+ });
64
+
65
+ // ── Completion notifiers ──────────────────────────────────────────
66
+
67
+ test('registerCallCompletionNotifier + fireCallCompletionNotifier: callback receives callSessionId', () => {
68
+ let receivedSessionId = '';
69
+
70
+ registerCallCompletionNotifier('test-conv', (callSessionId) => {
71
+ receivedSessionId = callSessionId;
72
+ });
73
+
74
+ fireCallCompletionNotifier('test-conv', 'session-456');
75
+
76
+ expect(receivedSessionId).toBe('session-456');
77
+ });
78
+
79
+ test('unregisterCallCompletionNotifier: fire after unregister does nothing', () => {
80
+ let called = false;
81
+
82
+ registerCallCompletionNotifier('test-conv', () => {
83
+ called = true;
84
+ });
85
+
86
+ unregisterCallCompletionNotifier('test-conv');
87
+ fireCallCompletionNotifier('test-conv', 'session-456');
88
+
89
+ expect(called).toBe(false);
90
+ });
91
+
92
+ test('fireCallCompletionNotifier does nothing when no notifier is registered', () => {
93
+ // Should not throw
94
+ fireCallCompletionNotifier('unregistered-conv', 'session-1');
95
+ });
96
+
97
+ // ── Orchestrator registry ─────────────────────────────────────────
98
+
99
+ test('registerCallOrchestrator + getCallOrchestrator: retrieves orchestrator', () => {
100
+ const fakeOrchestrator = { id: 'fake-orch' } as unknown as CallOrchestrator;
101
+
102
+ registerCallOrchestrator('test-session', fakeOrchestrator);
103
+
104
+ const retrieved = getCallOrchestrator('test-session');
105
+ expect(retrieved).toBe(fakeOrchestrator);
106
+ });
107
+
108
+ test('unregisterCallOrchestrator: getCallOrchestrator returns undefined after unregister', () => {
109
+ const fakeOrchestrator = { id: 'fake-orch-2' } as unknown as CallOrchestrator;
110
+
111
+ registerCallOrchestrator('test-session', fakeOrchestrator);
112
+ unregisterCallOrchestrator('test-session');
113
+
114
+ const retrieved = getCallOrchestrator('test-session');
115
+ expect(retrieved).toBeUndefined();
116
+ });
117
+
118
+ test('getCallOrchestrator returns undefined for unregistered session', () => {
119
+ const retrieved = getCallOrchestrator('nonexistent-session');
120
+ expect(retrieved).toBeUndefined();
121
+ });
122
+
123
+ test('registering a new orchestrator for same session overwrites the previous one', () => {
124
+ const first = { id: 'first' } as unknown as CallOrchestrator;
125
+ const second = { id: 'second' } as unknown as CallOrchestrator;
126
+
127
+ registerCallOrchestrator('test-session', first);
128
+ registerCallOrchestrator('test-session', second);
129
+
130
+ const retrieved = getCallOrchestrator('test-session');
131
+ expect(retrieved).toBe(second);
132
+ });
133
+ });