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.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/browser-skill-endstate.test.ts +1 -5
- package/src/__tests__/call-orchestrator.test.ts +328 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +476 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
- package/src/__tests__/config-schema.test.ts +49 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/ipc-snapshot.test.ts +34 -0
- package/src/__tests__/registry.test.ts +13 -8
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
- package/src/__tests__/run-orchestrator.test.ts +3 -3
- package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
- package/src/__tests__/runtime-runs-http.test.ts +1 -19
- package/src/__tests__/runtime-runs.test.ts +7 -7
- package/src/__tests__/session-queue.test.ts +50 -0
- package/src/__tests__/turn-commit.test.ts +56 -0
- package/src/__tests__/workspace-git-service.test.ts +217 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
- package/src/bundler/app-bundler.ts +29 -12
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-orchestrator.ts +364 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +229 -0
- package/src/calls/relay-server.ts +298 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +169 -0
- package/src/calls/twilio-routes.ts +236 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/doordash.ts +5 -24
- package/src/config/bundled-skills/doordash/SKILL.md +104 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +57 -0
- package/src/config/system-prompt.ts +50 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/handlers/config.ts +30 -0
- package/src/daemon/handlers/index.ts +6 -0
- package/src/daemon/handlers/work-items.ts +142 -2
- package/src/daemon/ipc-contract-inventory.json +12 -0
- package/src/daemon/ipc-contract.ts +52 -0
- package/src/daemon/lifecycle.ts +27 -5
- package/src/daemon/server.ts +10 -12
- package/src/daemon/session-tool-setup.ts +6 -0
- package/src/daemon/session.ts +40 -1
- package/src/index.ts +2 -0
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/db.ts +266 -0
- package/src/memory/schema.ts +42 -0
- package/src/runtime/http-server.ts +189 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +6 -6
- package/src/runtime/routes/channel-routes.ts +16 -18
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +32 -5
- package/src/tools/calls/call-end.ts +117 -0
- package/src/tools/calls/call-start.ts +134 -0
- package/src/tools/calls/call-status.ts +97 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/registry.ts +2 -4
- package/src/tools/tasks/index.ts +2 -0
- package/src/tools/tasks/task-delete.ts +49 -8
- package/src/tools/tasks/task-run.ts +9 -1
- package/src/tools/tasks/work-item-enqueue.ts +93 -3
- package/src/tools/tasks/work-item-list.ts +10 -25
- package/src/tools/tasks/work-item-remove.ts +112 -0
- package/src/tools/tasks/work-item-update.ts +186 -0
- package/src/tools/tool-manifest.ts +39 -31
- package/src/tools/ui-surface/definitions.ts +3 -0
- package/src/work-items/work-item-store.ts +209 -0
- package/src/workspace/commit-message-enrichment-service.ts +260 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +187 -32
- package/src/workspace/heartbeat-service.ts +70 -13
- package/src/workspace/turn-commit.ts +39 -49
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|