vellum 0.2.8 → 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 +2 -2
- package/package.json +3 -2
- 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 +91 -11
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -16
- package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
- package/src/__tests__/public-ingress-urls.test.ts +50 -34
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -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 +2 -3
- 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 +1 -2
- package/src/config/schema.ts +2 -6
- 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/handlers/config.ts +33 -50
- 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/ipc-contract-inventory.json +4 -4
- package/src/daemon/ipc-contract.ts +25 -21
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/inbound/public-ingress-urls.ts +36 -30
- package/src/memory/db.ts +132 -5
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -3
- package/src/memory/schema.ts +2 -2
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +95 -10
- 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 +10 -0
- package/src/security/oauth2.ts +41 -7
- package/src/subagent/manager.ts +3 -1
- 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/__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 -47
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getTwilioConnectActionUrl,
|
|
24
24
|
getTwilioRelayUrl,
|
|
25
25
|
getOAuthCallbackUrl,
|
|
26
|
+
getTelegramWebhookUrl,
|
|
26
27
|
} from '../inbound/public-ingress-urls.js';
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -30,63 +31,45 @@ import {
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
31
32
|
|
|
32
33
|
describe('getPublicBaseUrl', () => {
|
|
33
|
-
let
|
|
34
|
+
let savedIngressEnv: string | undefined;
|
|
34
35
|
|
|
35
36
|
beforeEach(() => {
|
|
36
|
-
|
|
37
|
-
delete process.env.
|
|
37
|
+
savedIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
|
|
38
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
afterEach(() => {
|
|
41
|
-
if (
|
|
42
|
-
process.env.
|
|
42
|
+
if (savedIngressEnv !== undefined) {
|
|
43
|
+
process.env.INGRESS_PUBLIC_BASE_URL = savedIngressEnv;
|
|
43
44
|
} else {
|
|
44
|
-
delete process.env.
|
|
45
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
45
46
|
}
|
|
46
47
|
});
|
|
47
48
|
|
|
48
|
-
test('
|
|
49
|
+
test('returns ingress.publicBaseUrl when set', () => {
|
|
49
50
|
const result = getPublicBaseUrl({
|
|
50
51
|
ingress: { publicBaseUrl: 'https://ingress.example.com/' },
|
|
51
|
-
calls: { webhookBaseUrl: 'https://calls.example.com' },
|
|
52
52
|
});
|
|
53
53
|
expect(result).toBe('https://ingress.example.com');
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test('falls back to
|
|
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/';
|
|
57
58
|
const result = getPublicBaseUrl({
|
|
58
59
|
ingress: { publicBaseUrl: '' },
|
|
59
|
-
calls: { webhookBaseUrl: 'https://calls.example.com/' },
|
|
60
60
|
});
|
|
61
|
-
expect(result).toBe('https://
|
|
61
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
test('falls back to
|
|
65
|
-
|
|
66
|
-
calls: { webhookBaseUrl: 'https://calls.example.com' },
|
|
67
|
-
});
|
|
68
|
-
expect(result).toBe('https://calls.example.com');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('falls back to TWILIO_WEBHOOK_BASE_URL env var when both config fields are empty', () => {
|
|
72
|
-
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com/';
|
|
73
|
-
const result = getPublicBaseUrl({
|
|
74
|
-
ingress: { publicBaseUrl: '' },
|
|
75
|
-
calls: { webhookBaseUrl: '' },
|
|
76
|
-
});
|
|
77
|
-
expect(result).toBe('https://env.example.com');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test('falls back to env var when config fields are undefined', () => {
|
|
81
|
-
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
|
|
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';
|
|
82
66
|
const result = getPublicBaseUrl({});
|
|
83
|
-
expect(result).toBe('https://env.example.com');
|
|
67
|
+
expect(result).toBe('https://ingress-env.example.com');
|
|
84
68
|
});
|
|
85
69
|
|
|
86
70
|
test('throws when no source provides a value', () => {
|
|
87
71
|
expect(() => getPublicBaseUrl({
|
|
88
72
|
ingress: { publicBaseUrl: '' },
|
|
89
|
-
calls: { webhookBaseUrl: '' },
|
|
90
73
|
})).toThrow(/No public base URL configured/);
|
|
91
74
|
});
|
|
92
75
|
|
|
@@ -108,12 +91,24 @@ describe('getPublicBaseUrl', () => {
|
|
|
108
91
|
expect(result).toBe('https://example.com');
|
|
109
92
|
});
|
|
110
93
|
|
|
111
|
-
test('skips whitespace-only ingress.publicBaseUrl and falls through', () => {
|
|
94
|
+
test('skips whitespace-only ingress.publicBaseUrl and falls through to env', () => {
|
|
95
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://ingress-env.example.com';
|
|
112
96
|
const result = getPublicBaseUrl({
|
|
113
97
|
ingress: { publicBaseUrl: ' ' },
|
|
114
|
-
calls: { webhookBaseUrl: 'https://calls.example.com' },
|
|
115
98
|
});
|
|
116
|
-
expect(result).toBe('https://
|
|
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');
|
|
117
112
|
});
|
|
118
113
|
});
|
|
119
114
|
|
|
@@ -204,3 +199,24 @@ describe('getOAuthCallbackUrl', () => {
|
|
|
204
199
|
expect(url).toBe('https://example.com/webhooks/oauth/callback');
|
|
205
200
|
});
|
|
206
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
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-layer integration tests for GET /v1/events (SSE assistant-events endpoint).
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* - 401 unauthorized (missing bearer token)
|
|
6
|
+
* - 400 when conversationKey is absent
|
|
7
|
+
* - Happy path: stream receives a published AssistantEvent
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
10
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'runtime-events-sse-test-')));
|
|
15
|
+
|
|
16
|
+
mock.module('../util/platform.js', () => ({
|
|
17
|
+
getRootDir: () => testDir,
|
|
18
|
+
getDataDir: () => testDir,
|
|
19
|
+
isMacOS: () => process.platform === 'darwin',
|
|
20
|
+
isLinux: () => process.platform === 'linux',
|
|
21
|
+
isWindows: () => process.platform === 'win32',
|
|
22
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
23
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
24
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
25
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
26
|
+
ensureDataDir: () => {},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module('../util/logger.js', () => ({
|
|
30
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
31
|
+
get: () => () => {},
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
mock.module('../config/loader.js', () => ({
|
|
36
|
+
getConfig: () => ({
|
|
37
|
+
model: 'test',
|
|
38
|
+
provider: 'test',
|
|
39
|
+
apiKeys: {},
|
|
40
|
+
memory: { enabled: false },
|
|
41
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
42
|
+
secretDetection: { enabled: false },
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
47
|
+
import { RuntimeHttpServer } from '../runtime/http-server.js';
|
|
48
|
+
import { assistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
49
|
+
import { buildAssistantEvent } from '../runtime/assistant-event.js';
|
|
50
|
+
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
|
|
51
|
+
|
|
52
|
+
initializeDb();
|
|
53
|
+
|
|
54
|
+
const TEST_TOKEN = 'test-bearer-token-events-sse';
|
|
55
|
+
const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
|
|
56
|
+
|
|
57
|
+
describe('SSE assistant-events endpoint', () => {
|
|
58
|
+
let server: RuntimeHttpServer;
|
|
59
|
+
let port: number;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
const db = getDb();
|
|
63
|
+
db.run('DELETE FROM conversation_keys');
|
|
64
|
+
db.run('DELETE FROM conversations');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
resetDb();
|
|
69
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function startServer(): Promise<void> {
|
|
73
|
+
port = 19500 + Math.floor(Math.random() * 500);
|
|
74
|
+
server = new RuntimeHttpServer({ port, bearerToken: TEST_TOKEN });
|
|
75
|
+
await server.start();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function stopServer(): Promise<void> {
|
|
79
|
+
await server?.stop();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function eventsUrl(params = ''): string {
|
|
83
|
+
return `http://127.0.0.1:${port}/v1/events${params ? `?${params}` : ''}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
test('401 when bearer token is missing', async () => {
|
|
89
|
+
await startServer();
|
|
90
|
+
|
|
91
|
+
const res = await fetch(eventsUrl('conversationKey=test-noauth'));
|
|
92
|
+
expect(res.status).toBe(401);
|
|
93
|
+
// Consume body to prevent resource leak
|
|
94
|
+
await res.body?.cancel();
|
|
95
|
+
|
|
96
|
+
await stopServer();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('401 when bearer token is wrong', async () => {
|
|
100
|
+
await startServer();
|
|
101
|
+
|
|
102
|
+
const res = await fetch(eventsUrl('conversationKey=test-badauth'), {
|
|
103
|
+
headers: { Authorization: 'Bearer wrong-token' },
|
|
104
|
+
});
|
|
105
|
+
expect(res.status).toBe(401);
|
|
106
|
+
await res.body?.cancel();
|
|
107
|
+
|
|
108
|
+
await stopServer();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
test('400 when conversationKey is missing', async () => {
|
|
114
|
+
await startServer();
|
|
115
|
+
|
|
116
|
+
const res = await fetch(eventsUrl(), { headers: AUTH_HEADERS });
|
|
117
|
+
expect(res.status).toBe(400);
|
|
118
|
+
const body = await res.json() as { error: string };
|
|
119
|
+
expect(body.error).toContain('conversationKey');
|
|
120
|
+
|
|
121
|
+
await stopServer();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── Happy path ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
test('stream receives a published assistant event', async () => {
|
|
127
|
+
// Test the handler directly (bypassing HTTP) to avoid chunked-transfer
|
|
128
|
+
// buffering in Bun's loopback SSE implementation. The HTTP auth and
|
|
129
|
+
// routing are already covered by other test files; here we focus on the
|
|
130
|
+
// SSE subscription logic and frame delivery.
|
|
131
|
+
const { conversationId } = getOrCreateConversation('sse-happy-path');
|
|
132
|
+
|
|
133
|
+
const ac = new AbortController();
|
|
134
|
+
const req = new Request(
|
|
135
|
+
'http://localhost/v1/events?conversationKey=sse-happy-path',
|
|
136
|
+
{ signal: ac.signal },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const { handleSubscribeAssistantEvents } = await import('../runtime/routes/events-routes.js');
|
|
140
|
+
const response = handleSubscribeAssistantEvents(req, new URL(req.url));
|
|
141
|
+
|
|
142
|
+
expect(response.status).toBe(200);
|
|
143
|
+
expect(response.headers.get('content-type')).toContain('text/event-stream');
|
|
144
|
+
|
|
145
|
+
// start() is called synchronously during ReadableStream construction, so the
|
|
146
|
+
// hub subscription is already registered before we publish.
|
|
147
|
+
const event = buildAssistantEvent('self', { type: 'pong' }, conversationId);
|
|
148
|
+
await assistantEventHub.publish(event);
|
|
149
|
+
|
|
150
|
+
// Read the first frame directly from the response body stream.
|
|
151
|
+
const reader = response.body!.getReader();
|
|
152
|
+
const { value, done } = await reader.read();
|
|
153
|
+
ac.abort();
|
|
154
|
+
|
|
155
|
+
expect(done).toBe(false);
|
|
156
|
+
const frame = new TextDecoder().decode(value);
|
|
157
|
+
expect(frame).toContain('event: assistant_event');
|
|
158
|
+
expect(frame).toContain(`"assistantId":"self"`);
|
|
159
|
+
expect(frame).toContain(`"sessionId":"${conversationId}"`);
|
|
160
|
+
expect(frame).toContain('"type":"pong"');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -33,7 +33,7 @@ let mockAuthToken: string | undefined = 'test-auth-token-secret';
|
|
|
33
33
|
|
|
34
34
|
mock.module('../security/secure-keys.js', () => ({
|
|
35
35
|
getSecureKey: (account: string) => {
|
|
36
|
-
if (account === '
|
|
36
|
+
if (account === 'credential:twilio:auth_token') return mockAuthToken;
|
|
37
37
|
return undefined;
|
|
38
38
|
},
|
|
39
39
|
}));
|
|
@@ -52,7 +52,7 @@ let mockAuthToken: string | undefined = 'test-auth-token-for-webhooks';
|
|
|
52
52
|
|
|
53
53
|
mock.module('../security/secure-keys.js', () => ({
|
|
54
54
|
getSecureKey: (account: string) => {
|
|
55
|
-
if (account === '
|
|
55
|
+
if (account === 'credential:twilio:auth_token') return mockAuthToken;
|
|
56
56
|
return undefined;
|
|
57
57
|
},
|
|
58
58
|
}));
|
|
@@ -72,7 +72,7 @@ mock.module('../calls/twilio-provider.js', () => {
|
|
|
72
72
|
readonly name = 'twilio';
|
|
73
73
|
|
|
74
74
|
static getAuthToken(): string | null {
|
|
75
|
-
return getSecureKey('
|
|
75
|
+
return getSecureKey('credential:twilio:auth_token') ?? null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
static verifyWebhookSignature(
|
|
@@ -205,9 +205,9 @@ describe('twilio webhook routes', () => {
|
|
|
205
205
|
});
|
|
206
206
|
|
|
207
207
|
async function startServer(): Promise<void> {
|
|
208
|
-
|
|
209
|
-
server = new RuntimeHttpServer({ port, bearerToken: TEST_TOKEN });
|
|
208
|
+
server = new RuntimeHttpServer({ port: 0, bearerToken: TEST_TOKEN });
|
|
210
209
|
await server.start();
|
|
210
|
+
port = server.actualPort;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
async function stopServer(): Promise<void> {
|