vellum 0.2.7 → 0.2.9
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/bun.lock +4 -4
- package/package.json +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -5
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +222 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +18 -3
- package/src/calls/twilio-routes.ts +10 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +30 -6
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +49 -17
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -4
- package/src/daemon/ipc-contract.ts +34 -15
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +112 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +472 -148
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -6
- package/src/memory/schema.ts +2 -6
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +174 -7
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +208 -58
- package/src/subagent/manager.ts +3 -1
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- package/src/calls/twilio-webhook-urls.ts +0 -50
|
@@ -262,34 +262,51 @@ describe('ProviderCommitMessageGenerator', () => {
|
|
|
262
262
|
expect(result.reason).toBe('invalid_output');
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
// 11. LLM
|
|
266
|
-
test('LLM
|
|
267
|
-
const longSubject = 'a'.repeat(
|
|
265
|
+
// 11. LLM subject > 72 chars → truncated to 72, still source "llm"
|
|
266
|
+
test('LLM subject > 72 chars → truncated to 72, source "llm"', async () => {
|
|
267
|
+
const longSubject = 'a'.repeat(100);
|
|
268
268
|
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject));
|
|
269
269
|
const gen = getCommitMessageGenerator();
|
|
270
270
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
271
271
|
changedFiles: baseContext.changedFiles,
|
|
272
272
|
});
|
|
273
|
-
expect(result.source).toBe('
|
|
274
|
-
expect(result.reason).
|
|
273
|
+
expect(result.source).toBe('llm');
|
|
274
|
+
expect(result.reason).toBeUndefined();
|
|
275
|
+
expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
|
|
276
|
+
expect(result.message).toBe('a'.repeat(72));
|
|
275
277
|
});
|
|
276
278
|
|
|
277
|
-
//
|
|
278
|
-
test('
|
|
279
|
+
// 11b. LLM subject > 72 chars with body → subject truncated, body preserved
|
|
280
|
+
test('LLM subject > 72 chars with body → subject truncated, body preserved', async () => {
|
|
281
|
+
const longSubject = 'b'.repeat(80);
|
|
282
|
+
const body = '\n\n- bullet one\n- bullet two';
|
|
283
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject + body));
|
|
284
|
+
const gen = getCommitMessageGenerator();
|
|
285
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
286
|
+
changedFiles: baseContext.changedFiles,
|
|
287
|
+
});
|
|
288
|
+
expect(result.source).toBe('llm');
|
|
289
|
+
expect(result.reason).toBeUndefined();
|
|
290
|
+
expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
|
|
291
|
+
expect(result.message).toBe('b'.repeat(72) + body);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// 12. Keyless provider (Ollama) without fast model → missing_fast_model (skips API key check)
|
|
295
|
+
test('Ollama without API key or fast model → returns deterministic, reason "missing_fast_model"', async () => {
|
|
279
296
|
(currentConfig as Record<string, unknown>).provider = 'ollama';
|
|
280
297
|
currentConfig.apiKeys = {} as Record<string, string>;
|
|
281
|
-
// Ollama has no default fast model, so it will fall through to provider_error,
|
|
282
|
-
// but crucially it should NOT be blocked by missing_provider_api_key
|
|
283
298
|
const gen = getCommitMessageGenerator();
|
|
284
299
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
285
300
|
changedFiles: baseContext.changedFiles,
|
|
286
301
|
});
|
|
287
302
|
expect(result.source).toBe('deterministic');
|
|
303
|
+
expect(result.reason).toBe('missing_fast_model');
|
|
288
304
|
expect(result.reason).not.toBe('missing_provider_api_key');
|
|
305
|
+
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
289
306
|
});
|
|
290
307
|
|
|
291
|
-
// 13.
|
|
292
|
-
test('
|
|
308
|
+
// 13. Unknown provider without fast model default → missing_fast_model, no provider call
|
|
309
|
+
test('Unknown provider without fast model default → returns deterministic, reason "missing_fast_model"', async () => {
|
|
293
310
|
(currentConfig as Record<string, unknown>).provider = 'exotic-provider';
|
|
294
311
|
currentConfig.apiKeys = { 'exotic-provider': 'sk-exotic' } as Record<string, string>;
|
|
295
312
|
const gen = getCommitMessageGenerator();
|
|
@@ -297,7 +314,29 @@ describe('ProviderCommitMessageGenerator', () => {
|
|
|
297
314
|
changedFiles: baseContext.changedFiles,
|
|
298
315
|
});
|
|
299
316
|
expect(result.source).toBe('deterministic');
|
|
300
|
-
expect(result.reason).toBe('
|
|
317
|
+
expect(result.reason).toBe('missing_fast_model');
|
|
301
318
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
302
319
|
});
|
|
320
|
+
|
|
321
|
+
// 14. Fast-model override enables LLM path for provider without built-in default
|
|
322
|
+
test('fast-model override enables LLM path for provider without built-in default', async () => {
|
|
323
|
+
(currentConfig as Record<string, unknown>).provider = 'ollama';
|
|
324
|
+
currentConfig.apiKeys = {} as Record<string, string>; // Ollama is keyless
|
|
325
|
+
currentConfig.workspaceGit.commitMessageLLM.providerFastModelOverrides = {
|
|
326
|
+
ollama: 'llama3.2:3b',
|
|
327
|
+
};
|
|
328
|
+
const commitMsg = 'fix: local model commit';
|
|
329
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(commitMsg));
|
|
330
|
+
const gen = getCommitMessageGenerator();
|
|
331
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
332
|
+
changedFiles: baseContext.changedFiles,
|
|
333
|
+
});
|
|
334
|
+
expect(result.source).toBe('llm');
|
|
335
|
+
expect(result.message).toBe(commitMsg);
|
|
336
|
+
|
|
337
|
+
// Verify the override model was passed
|
|
338
|
+
const callArgs = mockSendMessage.mock.calls[0];
|
|
339
|
+
const options = callArgs[3] as { config: { model: string } };
|
|
340
|
+
expect(options.config.model).toBe('llama3.2:3b');
|
|
341
|
+
});
|
|
303
342
|
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks — silence logger output during tests
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
function makeLoggerStub(): Record<string, unknown> {
|
|
8
|
+
const stub: Record<string, unknown> = {};
|
|
9
|
+
for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
|
|
10
|
+
stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
|
|
11
|
+
}
|
|
12
|
+
return stub;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mock.module('../util/logger.js', () => ({
|
|
16
|
+
getLogger: () => makeLoggerStub(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
getPublicBaseUrl,
|
|
21
|
+
getTwilioVoiceWebhookUrl,
|
|
22
|
+
getTwilioStatusCallbackUrl,
|
|
23
|
+
getTwilioConnectActionUrl,
|
|
24
|
+
getTwilioRelayUrl,
|
|
25
|
+
getOAuthCallbackUrl,
|
|
26
|
+
getTelegramWebhookUrl,
|
|
27
|
+
} from '../inbound/public-ingress-urls.js';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// getPublicBaseUrl — fallback chain
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
describe('getPublicBaseUrl', () => {
|
|
34
|
+
let savedIngressEnv: string | undefined;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
savedIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
|
|
38
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (savedIngressEnv !== undefined) {
|
|
43
|
+
process.env.INGRESS_PUBLIC_BASE_URL = savedIngressEnv;
|
|
44
|
+
} else {
|
|
45
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('returns ingress.publicBaseUrl when set', () => {
|
|
50
|
+
const result = getPublicBaseUrl({
|
|
51
|
+
ingress: { publicBaseUrl: 'https://ingress.example.com/' },
|
|
52
|
+
});
|
|
53
|
+
expect(result).toBe('https://ingress.example.com');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('falls back to INGRESS_PUBLIC_BASE_URL env var when ingress.publicBaseUrl is empty', () => {
|
|
57
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://ingress-env.example.com/';
|
|
58
|
+
const result = getPublicBaseUrl({
|
|
59
|
+
ingress: { publicBaseUrl: '' },
|
|
60
|
+
});
|
|
61
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('falls back to INGRESS_PUBLIC_BASE_URL env var when config is empty', () => {
|
|
65
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://ingress-env.example.com';
|
|
66
|
+
const result = getPublicBaseUrl({});
|
|
67
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('throws when no source provides a value', () => {
|
|
71
|
+
expect(() => getPublicBaseUrl({
|
|
72
|
+
ingress: { publicBaseUrl: '' },
|
|
73
|
+
})).toThrow(/No public base URL configured/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('throws when all sources are undefined', () => {
|
|
77
|
+
expect(() => getPublicBaseUrl({})).toThrow(/No public base URL configured/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('normalizes trailing slashes from ingress.publicBaseUrl', () => {
|
|
81
|
+
const result = getPublicBaseUrl({
|
|
82
|
+
ingress: { publicBaseUrl: 'https://example.com///' },
|
|
83
|
+
});
|
|
84
|
+
expect(result).toBe('https://example.com');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('trims whitespace from ingress.publicBaseUrl', () => {
|
|
88
|
+
const result = getPublicBaseUrl({
|
|
89
|
+
ingress: { publicBaseUrl: ' https://example.com ' },
|
|
90
|
+
});
|
|
91
|
+
expect(result).toBe('https://example.com');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('skips whitespace-only ingress.publicBaseUrl and falls through to env', () => {
|
|
95
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://ingress-env.example.com';
|
|
96
|
+
const result = getPublicBaseUrl({
|
|
97
|
+
ingress: { publicBaseUrl: ' ' },
|
|
98
|
+
});
|
|
99
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('normalizes trailing slashes from INGRESS_PUBLIC_BASE_URL', () => {
|
|
103
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://ingress-env.example.com///';
|
|
104
|
+
const result = getPublicBaseUrl({});
|
|
105
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('trims whitespace from INGRESS_PUBLIC_BASE_URL', () => {
|
|
109
|
+
process.env.INGRESS_PUBLIC_BASE_URL = ' https://ingress-env.example.com ';
|
|
110
|
+
const result = getPublicBaseUrl({});
|
|
111
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// getTwilioVoiceWebhookUrl
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe('getTwilioVoiceWebhookUrl', () => {
|
|
120
|
+
test('builds correct URL with callSessionId', () => {
|
|
121
|
+
const url = getTwilioVoiceWebhookUrl(
|
|
122
|
+
{ ingress: { publicBaseUrl: 'https://example.com' } },
|
|
123
|
+
'session-123',
|
|
124
|
+
);
|
|
125
|
+
expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=session-123');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('normalizes base URL before composing', () => {
|
|
129
|
+
const url = getTwilioVoiceWebhookUrl(
|
|
130
|
+
{ ingress: { publicBaseUrl: 'https://example.com/' } },
|
|
131
|
+
'abc',
|
|
132
|
+
);
|
|
133
|
+
expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=abc');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// getTwilioStatusCallbackUrl
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe('getTwilioStatusCallbackUrl', () => {
|
|
142
|
+
test('builds correct URL', () => {
|
|
143
|
+
const url = getTwilioStatusCallbackUrl({
|
|
144
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
145
|
+
});
|
|
146
|
+
expect(url).toBe('https://example.com/webhooks/twilio/status');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// getTwilioConnectActionUrl
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe('getTwilioConnectActionUrl', () => {
|
|
155
|
+
test('builds correct URL', () => {
|
|
156
|
+
const url = getTwilioConnectActionUrl({
|
|
157
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
158
|
+
});
|
|
159
|
+
expect(url).toBe('https://example.com/webhooks/twilio/connect-action');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// getTwilioRelayUrl — scheme conversion
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
describe('getTwilioRelayUrl', () => {
|
|
168
|
+
test('converts https to wss', () => {
|
|
169
|
+
const url = getTwilioRelayUrl({
|
|
170
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
171
|
+
});
|
|
172
|
+
expect(url).toBe('wss://example.com/webhooks/twilio/relay');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('converts http to ws', () => {
|
|
176
|
+
const url = getTwilioRelayUrl({
|
|
177
|
+
ingress: { publicBaseUrl: 'http://localhost:7821' },
|
|
178
|
+
});
|
|
179
|
+
expect(url).toBe('ws://localhost:7821/webhooks/twilio/relay');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('normalizes trailing slash before conversion', () => {
|
|
183
|
+
const url = getTwilioRelayUrl({
|
|
184
|
+
ingress: { publicBaseUrl: 'https://example.com/' },
|
|
185
|
+
});
|
|
186
|
+
expect(url).toBe('wss://example.com/webhooks/twilio/relay');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// getOAuthCallbackUrl
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
describe('getOAuthCallbackUrl', () => {
|
|
195
|
+
test('builds correct URL', () => {
|
|
196
|
+
const url = getOAuthCallbackUrl({
|
|
197
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
198
|
+
});
|
|
199
|
+
expect(url).toBe('https://example.com/webhooks/oauth/callback');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// getTelegramWebhookUrl
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe('getTelegramWebhookUrl', () => {
|
|
209
|
+
test('builds correct URL', () => {
|
|
210
|
+
const url = getTelegramWebhookUrl({
|
|
211
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
212
|
+
});
|
|
213
|
+
expect(url).toBe('https://example.com/webhooks/telegram');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('normalizes trailing slash before composing', () => {
|
|
217
|
+
const url = getTelegramWebhookUrl({
|
|
218
|
+
ingress: { publicBaseUrl: 'https://example.com/' },
|
|
219
|
+
});
|
|
220
|
+
expect(url).toBe('https://example.com/webhooks/telegram');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC parity tests for the SSE assistant-events endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Asserts that every streaming/delta IPC ServerMessage type is preserved
|
|
5
|
+
* exactly — field-for-field — when delivered through the SSE route.
|
|
6
|
+
*
|
|
7
|
+
* Message types covered:
|
|
8
|
+
* - assistant_text_delta
|
|
9
|
+
* - assistant_thinking_delta
|
|
10
|
+
* - tool_input_delta
|
|
11
|
+
* - tool_output_chunk
|
|
12
|
+
* - tool_result
|
|
13
|
+
* - message_complete (terminal)
|
|
14
|
+
* - generation_handoff (terminal)
|
|
15
|
+
* - generation_cancelled (terminal)
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
18
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'runtime-events-sse-parity-')));
|
|
23
|
+
|
|
24
|
+
mock.module('../util/platform.js', () => ({
|
|
25
|
+
getRootDir: () => testDir,
|
|
26
|
+
getDataDir: () => testDir,
|
|
27
|
+
isMacOS: () => process.platform === 'darwin',
|
|
28
|
+
isLinux: () => process.platform === 'linux',
|
|
29
|
+
isWindows: () => process.platform === 'win32',
|
|
30
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
31
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
32
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
33
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
34
|
+
ensureDataDir: () => {},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module('../util/logger.js', () => ({
|
|
38
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
39
|
+
get: () => () => {},
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module('../config/loader.js', () => ({
|
|
44
|
+
getConfig: () => ({
|
|
45
|
+
model: 'test',
|
|
46
|
+
provider: 'test',
|
|
47
|
+
apiKeys: {},
|
|
48
|
+
memory: { enabled: false },
|
|
49
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
50
|
+
secretDetection: { enabled: false },
|
|
51
|
+
}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
55
|
+
import { assistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
56
|
+
import { buildAssistantEvent } from '../runtime/assistant-event.js';
|
|
57
|
+
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
|
|
58
|
+
import type { AssistantEvent } from '../runtime/assistant-event.js';
|
|
59
|
+
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
60
|
+
|
|
61
|
+
initializeDb();
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
resetDb();
|
|
65
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Subscribe to the SSE endpoint for a given conversationKey, publish one
|
|
74
|
+
* event, read the first SSE frame, and return the parsed AssistantEvent.
|
|
75
|
+
*
|
|
76
|
+
* Uses handleSubscribeAssistantEvents directly (bypassing HTTP) to avoid
|
|
77
|
+
* chunked-transfer buffering in Bun's loopback implementation.
|
|
78
|
+
*/
|
|
79
|
+
async function publishAndReadFrame(
|
|
80
|
+
conversationKey: string,
|
|
81
|
+
message: ServerMessage,
|
|
82
|
+
): Promise<AssistantEvent> {
|
|
83
|
+
const { conversationId } = getOrCreateConversation(conversationKey);
|
|
84
|
+
|
|
85
|
+
const ac = new AbortController();
|
|
86
|
+
const req = new Request(
|
|
87
|
+
`http://localhost/v1/events?conversationKey=${conversationKey}`,
|
|
88
|
+
{ signal: ac.signal },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const { handleSubscribeAssistantEvents } = await import('../runtime/routes/events-routes.js');
|
|
92
|
+
const response = handleSubscribeAssistantEvents(req, new URL(req.url));
|
|
93
|
+
|
|
94
|
+
const event = buildAssistantEvent('self', message, conversationId);
|
|
95
|
+
await assistantEventHub.publish(event);
|
|
96
|
+
|
|
97
|
+
const reader = response.body!.getReader();
|
|
98
|
+
const { value } = await reader.read();
|
|
99
|
+
ac.abort();
|
|
100
|
+
|
|
101
|
+
const frame = new TextDecoder().decode(value);
|
|
102
|
+
// SSE frame: "event: assistant_event\nid: <id>\ndata: <json>\n\n"
|
|
103
|
+
const dataLine = frame.split('\n').find(l => l.startsWith('data: '));
|
|
104
|
+
if (!dataLine) throw new Error(`No data line in SSE frame:\n${frame}`);
|
|
105
|
+
return JSON.parse(dataLine.slice('data: '.length)) as AssistantEvent;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Tests
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe('SSE IPC parity — streaming/delta message types', () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
const db = getDb();
|
|
115
|
+
db.run('DELETE FROM conversation_keys');
|
|
116
|
+
db.run('DELETE FROM conversations');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── assistant_text_delta ─────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
test('preserves assistant_text_delta payload', async () => {
|
|
122
|
+
const msg = {
|
|
123
|
+
type: 'assistant_text_delta' as const,
|
|
124
|
+
text: 'Hello, world!',
|
|
125
|
+
sessionId: 'conv-text-delta',
|
|
126
|
+
};
|
|
127
|
+
const event = await publishAndReadFrame('parity-text-delta', msg);
|
|
128
|
+
|
|
129
|
+
expect(event.message.type).toBe('assistant_text_delta');
|
|
130
|
+
const m = event.message as typeof msg;
|
|
131
|
+
expect(m.text).toBe('Hello, world!');
|
|
132
|
+
expect(m.sessionId).toBe('conv-text-delta');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('preserves assistant_text_delta without optional sessionId', async () => {
|
|
136
|
+
const msg = {
|
|
137
|
+
type: 'assistant_text_delta' as const,
|
|
138
|
+
text: 'No session here',
|
|
139
|
+
};
|
|
140
|
+
const event = await publishAndReadFrame('parity-text-delta-nosession', msg);
|
|
141
|
+
|
|
142
|
+
const m = event.message as typeof msg;
|
|
143
|
+
expect(m.type).toBe('assistant_text_delta');
|
|
144
|
+
expect(m.text).toBe('No session here');
|
|
145
|
+
expect((m as Record<string, unknown>).sessionId).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── assistant_thinking_delta ─────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
test('preserves assistant_thinking_delta payload', async () => {
|
|
151
|
+
const msg = {
|
|
152
|
+
type: 'assistant_thinking_delta' as const,
|
|
153
|
+
thinking: 'Let me reason through this...',
|
|
154
|
+
};
|
|
155
|
+
const event = await publishAndReadFrame('parity-thinking-delta', msg);
|
|
156
|
+
|
|
157
|
+
expect(event.message.type).toBe('assistant_thinking_delta');
|
|
158
|
+
const m = event.message as typeof msg;
|
|
159
|
+
expect(m.thinking).toBe('Let me reason through this...');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── tool_input_delta ─────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
test('preserves tool_input_delta payload', async () => {
|
|
165
|
+
const msg = {
|
|
166
|
+
type: 'tool_input_delta' as const,
|
|
167
|
+
toolName: 'bash',
|
|
168
|
+
content: '{"command": "ls -la"}',
|
|
169
|
+
sessionId: 'conv-tool-input',
|
|
170
|
+
};
|
|
171
|
+
const event = await publishAndReadFrame('parity-tool-input-delta', msg);
|
|
172
|
+
|
|
173
|
+
expect(event.message.type).toBe('tool_input_delta');
|
|
174
|
+
const m = event.message as typeof msg;
|
|
175
|
+
expect(m.toolName).toBe('bash');
|
|
176
|
+
expect(m.content).toBe('{"command": "ls -la"}');
|
|
177
|
+
expect(m.sessionId).toBe('conv-tool-input');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── tool_output_chunk ────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
test('preserves tool_output_chunk payload', async () => {
|
|
183
|
+
const msg = {
|
|
184
|
+
type: 'tool_output_chunk' as const,
|
|
185
|
+
chunk: 'total 42\n-rw-r--r-- 1 user group 1234 Jan 1 00:00 file.ts',
|
|
186
|
+
sessionId: 'conv-tool-output',
|
|
187
|
+
subType: 'tool_complete' as const,
|
|
188
|
+
subToolName: 'bash',
|
|
189
|
+
subToolInput: 'ls -la',
|
|
190
|
+
subToolIsError: false,
|
|
191
|
+
subToolId: 'tool-abc-123',
|
|
192
|
+
};
|
|
193
|
+
const event = await publishAndReadFrame('parity-tool-output-chunk', msg);
|
|
194
|
+
|
|
195
|
+
expect(event.message.type).toBe('tool_output_chunk');
|
|
196
|
+
const m = event.message as typeof msg;
|
|
197
|
+
expect(m.chunk).toBe(msg.chunk);
|
|
198
|
+
expect(m.sessionId).toBe('conv-tool-output');
|
|
199
|
+
expect(m.subType).toBe('tool_complete');
|
|
200
|
+
expect(m.subToolName).toBe('bash');
|
|
201
|
+
expect(m.subToolInput).toBe('ls -la');
|
|
202
|
+
expect(m.subToolIsError).toBe(false);
|
|
203
|
+
expect(m.subToolId).toBe('tool-abc-123');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('preserves minimal tool_output_chunk (chunk only)', async () => {
|
|
207
|
+
const msg = {
|
|
208
|
+
type: 'tool_output_chunk' as const,
|
|
209
|
+
chunk: 'stdout line 1\n',
|
|
210
|
+
};
|
|
211
|
+
const event = await publishAndReadFrame('parity-tool-output-chunk-minimal', msg);
|
|
212
|
+
|
|
213
|
+
const m = event.message as typeof msg;
|
|
214
|
+
expect(m.type).toBe('tool_output_chunk');
|
|
215
|
+
expect(m.chunk).toBe('stdout line 1\n');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── tool_result ──────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
test('preserves tool_result payload', async () => {
|
|
221
|
+
const msg = {
|
|
222
|
+
type: 'tool_result' as const,
|
|
223
|
+
toolName: 'read_file',
|
|
224
|
+
result: 'File contents here',
|
|
225
|
+
isError: false,
|
|
226
|
+
sessionId: 'conv-tool-result',
|
|
227
|
+
status: 'success',
|
|
228
|
+
};
|
|
229
|
+
const event = await publishAndReadFrame('parity-tool-result', msg);
|
|
230
|
+
|
|
231
|
+
expect(event.message.type).toBe('tool_result');
|
|
232
|
+
const m = event.message as typeof msg;
|
|
233
|
+
expect(m.toolName).toBe('read_file');
|
|
234
|
+
expect(m.result).toBe('File contents here');
|
|
235
|
+
expect(m.isError).toBe(false);
|
|
236
|
+
expect(m.sessionId).toBe('conv-tool-result');
|
|
237
|
+
expect(m.status).toBe('success');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('preserves tool_result with error flag', async () => {
|
|
241
|
+
const msg = {
|
|
242
|
+
type: 'tool_result' as const,
|
|
243
|
+
toolName: 'bash',
|
|
244
|
+
result: 'Command not found: foobar',
|
|
245
|
+
isError: true,
|
|
246
|
+
};
|
|
247
|
+
const event = await publishAndReadFrame('parity-tool-result-error', msg);
|
|
248
|
+
|
|
249
|
+
const m = event.message as typeof msg;
|
|
250
|
+
expect(m.type).toBe('tool_result');
|
|
251
|
+
expect(m.isError).toBe(true);
|
|
252
|
+
expect(m.result).toBe('Command not found: foobar');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── message_complete (terminal) ──────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
test('preserves message_complete payload', async () => {
|
|
258
|
+
const msg = {
|
|
259
|
+
type: 'message_complete' as const,
|
|
260
|
+
sessionId: 'conv-msg-complete',
|
|
261
|
+
};
|
|
262
|
+
const event = await publishAndReadFrame('parity-message-complete', msg);
|
|
263
|
+
|
|
264
|
+
expect(event.message.type).toBe('message_complete');
|
|
265
|
+
const m = event.message as typeof msg;
|
|
266
|
+
expect(m.sessionId).toBe('conv-msg-complete');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('preserves message_complete without sessionId', async () => {
|
|
270
|
+
const msg = { type: 'message_complete' as const };
|
|
271
|
+
const event = await publishAndReadFrame('parity-message-complete-nosession', msg);
|
|
272
|
+
|
|
273
|
+
expect(event.message.type).toBe('message_complete');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── generation_handoff (terminal) ────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
test('preserves generation_handoff payload', async () => {
|
|
279
|
+
const msg = {
|
|
280
|
+
type: 'generation_handoff' as const,
|
|
281
|
+
sessionId: 'conv-handoff',
|
|
282
|
+
requestId: 'req-xyz-789',
|
|
283
|
+
queuedCount: 2,
|
|
284
|
+
};
|
|
285
|
+
const event = await publishAndReadFrame('parity-generation-handoff', msg);
|
|
286
|
+
|
|
287
|
+
expect(event.message.type).toBe('generation_handoff');
|
|
288
|
+
const m = event.message as typeof msg;
|
|
289
|
+
expect(m.sessionId).toBe('conv-handoff');
|
|
290
|
+
expect(m.requestId).toBe('req-xyz-789');
|
|
291
|
+
expect(m.queuedCount).toBe(2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── generation_cancelled (terminal) ─────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
test('preserves generation_cancelled payload', async () => {
|
|
297
|
+
const msg = {
|
|
298
|
+
type: 'generation_cancelled' as const,
|
|
299
|
+
sessionId: 'conv-cancelled',
|
|
300
|
+
};
|
|
301
|
+
const event = await publishAndReadFrame('parity-generation-cancelled', msg);
|
|
302
|
+
|
|
303
|
+
expect(event.message.type).toBe('generation_cancelled');
|
|
304
|
+
const m = event.message as typeof msg;
|
|
305
|
+
expect(m.sessionId).toBe('conv-cancelled');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── Envelope integrity ───────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
test('SSE envelope preserves assistantId and sessionId across all event types', async () => {
|
|
311
|
+
const conversationKey = 'parity-envelope-check';
|
|
312
|
+
const { conversationId } = getOrCreateConversation(conversationKey);
|
|
313
|
+
|
|
314
|
+
const ac = new AbortController();
|
|
315
|
+
const req = new Request(
|
|
316
|
+
`http://localhost/v1/events?conversationKey=${conversationKey}`,
|
|
317
|
+
{ signal: ac.signal },
|
|
318
|
+
);
|
|
319
|
+
const { handleSubscribeAssistantEvents } = await import('../runtime/routes/events-routes.js');
|
|
320
|
+
const response = handleSubscribeAssistantEvents(req, new URL(req.url));
|
|
321
|
+
|
|
322
|
+
const msg: ServerMessage = { type: 'assistant_text_delta' as const, text: 'envelope test' };
|
|
323
|
+
const published = buildAssistantEvent('self', msg, conversationId);
|
|
324
|
+
await assistantEventHub.publish(published);
|
|
325
|
+
|
|
326
|
+
const reader = response.body!.getReader();
|
|
327
|
+
const { value } = await reader.read();
|
|
328
|
+
ac.abort();
|
|
329
|
+
|
|
330
|
+
const frame = new TextDecoder().decode(value);
|
|
331
|
+
const dataLine = frame.split('\n').find(l => l.startsWith('data: '))!;
|
|
332
|
+
const received = JSON.parse(dataLine.slice('data: '.length)) as AssistantEvent;
|
|
333
|
+
|
|
334
|
+
// Envelope fields
|
|
335
|
+
expect(received.id).toBe(published.id);
|
|
336
|
+
expect(received.assistantId).toBe('self');
|
|
337
|
+
expect(received.sessionId).toBe(conversationId);
|
|
338
|
+
expect(received.emittedAt).toBe(published.emittedAt);
|
|
339
|
+
// SSE frame fields
|
|
340
|
+
expect(frame).toContain('event: assistant_event');
|
|
341
|
+
expect(frame).toContain(`id: ${published.id}`);
|
|
342
|
+
});
|
|
343
|
+
});
|