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.
Files changed (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. 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 output too long (subject > 72 chars)
266
- test('LLM output too long (subject > 72 chars)returns deterministic, reason "invalid_output"', async () => {
267
- const longSubject = 'a'.repeat(73);
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('deterministic');
274
- expect(result.reason).toBe('invalid_output');
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
- // 12. Keyless provider (Ollama) skips API key preflight
278
- test('Ollama without API keydoes not return missing_provider_api_key', async () => {
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. No default fast model for unknown provider
292
- test('No default fast model for unknown provider → returns deterministic fallback', async () => {
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('provider_error');
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
+ });