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
@@ -0,0 +1,78 @@
1
+ import type { ToolContext, ToolExecutionResult } from '../types.js';
2
+ import { getWorkItem, listWorkItems, identifyEntityById, buildWorkItemMismatchError } from '../../work-items/work-item-store.js';
3
+ import { runWorkItemInBackground } from '../../work-items/work-item-runner.js';
4
+ import { getTask } from '../../tasks/task-store.js';
5
+
6
+ export async function executeTaskQueueRun(
7
+ input: Record<string, unknown>,
8
+ _context: ToolContext,
9
+ ): Promise<ToolExecutionResult> {
10
+ const workItemId = input.work_item_id as string | undefined;
11
+ const taskName = input.task_name as string | undefined;
12
+ const title = input.title as string | undefined;
13
+
14
+ if (!workItemId && !taskName && !title) {
15
+ return {
16
+ content: 'Error: Provide work_item_id, task_name, or title to identify the task to run.',
17
+ isError: true,
18
+ };
19
+ }
20
+
21
+ try {
22
+ let resolvedId: string | undefined;
23
+
24
+ if (workItemId) {
25
+ const item = getWorkItem(workItemId);
26
+ if (!item) {
27
+ const entity = identifyEntityById(workItemId);
28
+ if (entity.type === 'task_template') {
29
+ return {
30
+ content: `Error: "${workItemId}" is a task template ID, not a work item. Use task_list_show to find the work item ID.`,
31
+ isError: true,
32
+ };
33
+ }
34
+ return { content: `Error: No work item found with ID "${workItemId}".`, isError: true };
35
+ }
36
+ resolvedId = item.id;
37
+ } else {
38
+ // Search by task_name or title among active work items
39
+ const needle = (taskName ?? title)!.toLowerCase();
40
+ const allItems = listWorkItems();
41
+ const activeItems = allItems.filter((i) => !['archived', 'done'].includes(i.status));
42
+ const matches = activeItems.filter((i) => i.title.toLowerCase().includes(needle));
43
+
44
+ if (matches.length === 0) {
45
+ return {
46
+ content: `Error: No active work item matching "${taskName ?? title}". Use task_list_show to see your task queue.`,
47
+ isError: true,
48
+ };
49
+ }
50
+
51
+ if (matches.length > 1) {
52
+ const lines = [`Multiple work items match "${taskName ?? title}". Please specify by ID:`, ''];
53
+ for (const m of matches) {
54
+ lines.push(`- ${m.title} (ID: ${m.id}, status: ${m.status})`);
55
+ }
56
+ return { content: lines.join('\n'), isError: true };
57
+ }
58
+
59
+ resolvedId = matches[0].id;
60
+ }
61
+
62
+ const result = runWorkItemInBackground(resolvedId);
63
+
64
+ if (!result.success) {
65
+ return { content: `Error: ${result.error}`, isError: true };
66
+ }
67
+
68
+ const item = getWorkItem(resolvedId)!;
69
+ const task = getTask(item.taskId);
70
+ return {
71
+ content: `Started running task "${item.title}"${task ? ` (template: ${task.title})` : ''}. It will execute in the background. Use task_list_show to check progress.`,
72
+ isError: false,
73
+ };
74
+ } catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ return { content: `Error: ${msg}`, isError: true };
77
+ }
78
+ }
@@ -124,7 +124,7 @@ export function getTCPPort(): number {
124
124
  *
125
125
  * The flag-file check makes it easy to enable TCP in dev without restarting
126
126
  * the shell: `touch ~/.vellum/tcp-enabled && kill -USR1 <daemon-pid>`.
127
- * The macOS DaemonLauncher also sets the env var for bundled-binary deployments.
127
+ * The macOS CLI (AssistantCli) also sets the env var for bundled-binary deployments.
128
128
  */
129
129
  export function isTCPEnabled(): boolean {
130
130
  const override = process.env.VELLUM_DAEMON_TCP_ENABLED?.trim();
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Module-level registry for running work items from tool context.
3
+ *
4
+ * The daemon server registers its `getOrCreateSession` and `broadcast`
5
+ * callbacks at startup. Tool implementations can then trigger async
6
+ * work item execution without needing direct access to HandlerContext.
7
+ */
8
+
9
+ import { getLogger } from '../util/logger.js';
10
+ import { getWorkItem, updateWorkItem, type WorkItemStatus } from './work-item-store.js';
11
+ import { getTask } from '../tasks/task-store.js';
12
+ import { runTask } from '../tasks/task-runner.js';
13
+ import { sanitizeToolList, getRegisteredToolNames } from '../tasks/tool-sanitizer.js';
14
+ import type { Session } from '../daemon/session.js';
15
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
16
+
17
+ const log = getLogger('work-item-runner');
18
+
19
+ // ── Daemon callback registry ─────────────────────────────────────────
20
+
21
+ interface DaemonCallbacks {
22
+ getOrCreateSession: (conversationId: string) => Promise<Session>;
23
+ broadcast: (msg: ServerMessage) => void;
24
+ }
25
+
26
+ let _callbacks: DaemonCallbacks | null = null;
27
+
28
+ export function registerDaemonCallbacks(callbacks: DaemonCallbacks): void {
29
+ _callbacks = callbacks;
30
+ }
31
+
32
+ // ── Public API ───────────────────────────────────────────────────────
33
+
34
+ function broadcastWorkItemStatus(broadcast: (msg: ServerMessage) => void, id: string): void {
35
+ const item = getWorkItem(id);
36
+ if (item) {
37
+ broadcast({
38
+ type: 'work_item_status_changed',
39
+ item: {
40
+ id: item.id,
41
+ taskId: item.taskId,
42
+ title: item.title,
43
+ status: item.status,
44
+ lastRunId: item.lastRunId,
45
+ lastRunConversationId: item.lastRunConversationId,
46
+ lastRunStatus: item.lastRunStatus,
47
+ updatedAt: item.updatedAt,
48
+ },
49
+ } as ServerMessage);
50
+ }
51
+ }
52
+
53
+ export interface RunWorkItemResult {
54
+ success: boolean;
55
+ error?: string;
56
+ errorCode?: string;
57
+ }
58
+
59
+ /**
60
+ * Run a work item in the background. Returns immediately after validation.
61
+ * The actual execution happens asynchronously.
62
+ *
63
+ * When called from a chat tool (e.g. Telegram), required tools are
64
+ * auto-approved since the user explicitly requested execution.
65
+ */
66
+ export function runWorkItemInBackground(workItemId: string): RunWorkItemResult {
67
+ if (!_callbacks) {
68
+ return { success: false, error: 'Daemon callbacks not registered', errorCode: 'not_initialized' };
69
+ }
70
+
71
+ const workItem = getWorkItem(workItemId);
72
+ if (!workItem) {
73
+ return { success: false, error: 'Work item not found', errorCode: 'not_found' };
74
+ }
75
+
76
+ if (workItem.status === 'running') {
77
+ return { success: false, error: 'Work item is already running', errorCode: 'already_running' };
78
+ }
79
+
80
+ const NON_RUNNABLE_STATUSES: readonly string[] = ['archived'];
81
+ if (NON_RUNNABLE_STATUSES.includes(workItem.status)) {
82
+ return { success: false, error: `Work item has status '${workItem.status}' and cannot be run`, errorCode: 'invalid_status' };
83
+ }
84
+
85
+ const task = getTask(workItem.taskId);
86
+ if (!task) {
87
+ return { success: false, error: `Associated task not found: ${workItem.taskId}`, errorCode: 'no_task' };
88
+ }
89
+
90
+ // Resolve required tools
91
+ let requiredTools: string[];
92
+ if (workItem.requiredTools !== null && workItem.requiredTools !== undefined) {
93
+ requiredTools = sanitizeToolList(JSON.parse(workItem.requiredTools));
94
+ } else {
95
+ requiredTools = task.requiredTools
96
+ ? sanitizeToolList(JSON.parse(task.requiredTools))
97
+ : getRegisteredToolNames();
98
+ }
99
+
100
+ // Auto-approve all required tools for chat-initiated runs.
101
+ // The user explicitly asked to run the task, so we treat that as consent.
102
+ const approvedTools = requiredTools;
103
+
104
+ // Set status to running
105
+ updateWorkItem(workItemId, { status: 'running' });
106
+
107
+ const { getOrCreateSession, broadcast } = _callbacks;
108
+
109
+ // Broadcast the running state
110
+ broadcastWorkItemStatus(broadcast, workItemId);
111
+ broadcast({ type: 'tasks_changed' } as ServerMessage);
112
+
113
+ // Execute asynchronously
114
+ let session: Awaited<ReturnType<typeof getOrCreateSession>> | null = null;
115
+ void (async () => {
116
+ try {
117
+ const result = await runTask(
118
+ { taskId: workItem.taskId, workingDir: process.cwd(), approvedTools },
119
+ async (conversationId, message, taskRunId) => {
120
+ if (!session) {
121
+ updateWorkItem(workItemId, { lastRunConversationId: conversationId });
122
+ session = await getOrCreateSession(conversationId);
123
+
124
+ broadcast({
125
+ type: 'task_run_thread_created',
126
+ conversationId,
127
+ workItemId,
128
+ title: workItem.title,
129
+ } as ServerMessage);
130
+ (session as unknown as { taskRunId?: string }).taskRunId = taskRunId;
131
+ (session as unknown as { headlessLock: boolean }).headlessLock = true;
132
+ }
133
+ await session.processMessage(message, [], (event) => {
134
+ broadcast(event);
135
+ });
136
+ },
137
+ );
138
+
139
+ if (session) {
140
+ (session as unknown as { headlessLock: boolean }).headlessLock = false;
141
+ }
142
+
143
+ const current = getWorkItem(workItemId);
144
+ if (current?.status !== 'cancelled') {
145
+ const finalStatus: WorkItemStatus = result.status === 'completed' ? 'awaiting_review' : 'failed';
146
+ updateWorkItem(workItemId, {
147
+ status: finalStatus,
148
+ lastRunId: result.taskRunId,
149
+ lastRunConversationId: result.conversationId,
150
+ lastRunStatus: result.status,
151
+ });
152
+ }
153
+
154
+ broadcastWorkItemStatus(broadcast, workItemId);
155
+ broadcast({ type: 'tasks_changed' } as ServerMessage);
156
+ } catch (err) {
157
+ if (session) {
158
+ (session as unknown as { headlessLock: boolean }).headlessLock = false;
159
+ }
160
+ log.error({ err, workItemId }, 'work item background run failed');
161
+ updateWorkItem(workItemId, {
162
+ status: 'failed',
163
+ lastRunStatus: 'failed',
164
+ });
165
+ broadcastWorkItemStatus(broadcast, workItemId);
166
+ broadcast({ type: 'tasks_changed' } as ServerMessage);
167
+ }
168
+ })();
169
+
170
+ return { success: true };
171
+ }
@@ -10,9 +10,10 @@ export type CommitMessageSource = 'llm' | 'deterministic';
10
10
  export type LLMFallbackReason =
11
11
  | 'disabled'
12
12
  | 'missing_provider_api_key'
13
- | 'provider_not_initialized'
14
13
  | 'breaker_open'
15
14
  | 'insufficient_budget'
15
+ | 'missing_fast_model'
16
+ | 'provider_not_initialized'
16
17
  | 'timeout'
17
18
  | 'provider_error'
18
19
  | 'invalid_output';
@@ -103,17 +104,25 @@ export class ProviderCommitMessageGenerator {
103
104
  const config = getConfig();
104
105
  const llmConfig = config.workspaceGit.commitMessageLLM;
105
106
 
107
+ // ── Fallback check order (canonical) ──────────────────────────────
108
+ // 1. disabled
109
+ // 2. missing_provider_api_key (except keyless providers like ollama)
110
+ // 3. breaker_open
111
+ // 4. insufficient_budget
112
+ // 5. missing_fast_model
113
+ // 6. provider_not_initialized
114
+ // 7. call provider → timeout / provider_error / invalid_output
115
+ // ──────────────────────────────────────────────────────────────────
116
+
106
117
  // Step 1: Feature gate
107
118
  if (!llmConfig.enabled) {
108
119
  return buildDeterministicResult(context, 'disabled');
109
120
  }
110
-
111
- // Step 2: Provider gate
112
121
  if (!llmConfig.useConfiguredProvider) {
113
122
  return buildDeterministicResult(context, 'disabled');
114
123
  }
115
124
 
116
- // Step 2.5: API key preflight (skip for providers that run without a key)
125
+ // Step 2: API key preflight (skip for providers that run without a key)
117
126
  if (!KEYLESS_PROVIDERS.has(config.provider)) {
118
127
  const providerApiKey = config.apiKeys[config.provider];
119
128
  if (!providerApiKey || providerApiKey === '') {
@@ -143,7 +152,19 @@ export class ProviderCommitMessageGenerator {
143
152
  }
144
153
  }
145
154
 
146
- // Step 5: Call the provider
155
+ // Step 5: Fast model preflight — resolve before any provider call
156
+ const fastModel = llmConfig.providerFastModelOverrides[config.provider]
157
+ ?? PROVIDER_DEFAULT_FAST_MODELS[config.provider];
158
+
159
+ if (!fastModel) {
160
+ log.debug(
161
+ { provider: config.provider },
162
+ 'No fast model resolvable for provider; falling back to deterministic',
163
+ );
164
+ return buildDeterministicResult(context, 'missing_fast_model');
165
+ }
166
+
167
+ // Step 6 + 7: Call the provider
147
168
  try {
148
169
  const { getProvider } = await import('../providers/registry.js');
149
170
 
@@ -179,14 +200,6 @@ export class ProviderCommitMessageGenerator {
179
200
  },
180
201
  ];
181
202
 
182
- // Resolve fast model
183
- const fastModel = llmConfig.providerFastModelOverrides[config.provider]
184
- ?? PROVIDER_DEFAULT_FAST_MODELS[config.provider];
185
- if (!fastModel) {
186
- log.debug({ provider: config.provider }, 'No default fast model for provider; falling back to deterministic');
187
- return buildDeterministicResult(context, 'provider_error');
188
- }
189
-
190
203
  // AbortController with timeout
191
204
  const ac = new AbortController();
192
205
  const timer = setTimeout(() => ac.abort(), llmConfig.timeoutMs);
@@ -199,7 +212,11 @@ export class ProviderCommitMessageGenerator {
199
212
  SYSTEM_PROMPT,
200
213
  {
201
214
  signal: ac.signal,
202
- config: { model: fastModel, max_tokens: llmConfig.maxTokens, temperature: llmConfig.temperature },
215
+ config: {
216
+ model: fastModel,
217
+ max_tokens: llmConfig.maxTokens,
218
+ temperature: llmConfig.temperature,
219
+ },
203
220
  },
204
221
  );
205
222
  } catch (err: unknown) {
@@ -230,21 +247,20 @@ export class ProviderCommitMessageGenerator {
230
247
  return buildDeterministicResult(context, 'invalid_output');
231
248
  }
232
249
 
233
- // Validate single-line subject: first line must be <= 72 chars
234
- const firstLine = text.split('\n')[0];
235
- if (firstLine.length > 72) {
250
+ // Cap subject line to 72 chars deterministically (no fallback, no breaker failure)
251
+ const lines = text.split('\n');
252
+ if (lines[0].length > 72) {
236
253
  log.debug(
237
- { subjectLength: firstLine.length },
238
- 'LLM subject line too long; falling back to deterministic',
254
+ { originalLength: lines[0].length },
255
+ 'Capping LLM subject line to 72 chars',
239
256
  );
240
- this.recordFailure();
241
- return buildDeterministicResult(context, 'invalid_output');
257
+ lines[0] = lines[0].slice(0, 72);
242
258
  }
259
+ const finalMessage = lines.join('\n');
243
260
 
244
261
  this.recordSuccess();
245
- return { message: text, source: 'llm' };
262
+ return { message: finalMessage, source: 'llm' };
246
263
  } catch (err: unknown) {
247
- // Step 6: Any error -> deterministic fallback
248
264
  log.warn(
249
265
  { err: err instanceof Error ? err.message : String(err) },
250
266
  'Commit message LLM provider error; falling back to deterministic',
@@ -72,10 +72,14 @@ export async function commitTurnChanges(
72
72
  if (!provider) {
73
73
  // Guard: skip pre-check if deadline already elapsed to avoid unnecessary mutex contention
74
74
  let preClean = false;
75
+ let candidateChangedFiles: string[] = [];
75
76
  if (!deadlineMs || Date.now() < deadlineMs) {
76
77
  try {
77
78
  const preStatus = await gitService.getStatus();
78
79
  preClean = preStatus.clean;
80
+ if (!preClean) {
81
+ candidateChangedFiles = [...new Set([...preStatus.staged, ...preStatus.modified, ...preStatus.untracked])];
82
+ }
79
83
  } catch {
80
84
  // If we can't determine status, assume dirty so we don't skip the commit
81
85
  }
@@ -90,10 +94,10 @@ export async function commitTurnChanges(
90
94
  trigger: 'turn',
91
95
  sessionId,
92
96
  turnNumber,
93
- changedFiles: [], // File list unavailable outside the git mutex; generator handles empty arrays
97
+ changedFiles: candidateChangedFiles,
94
98
  timestampMs: Date.now(),
95
99
  },
96
- { deadlineMs, changedFiles: [] },
100
+ { deadlineMs, changedFiles: candidateChangedFiles },
97
101
  );
98
102
  commitMessageSource = result.source;
99
103
  llmFallbackReason = result.reason;
@@ -1,221 +0,0 @@
1
- import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
- import { mkdtempSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import * as net from 'node:net';
6
-
7
- const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
8
-
9
- let rawConfigStore: Record<string, unknown> = {};
10
- const saveRawConfigCalls: Record<string, unknown>[] = [];
11
-
12
- mock.module('../config/loader.js', () => ({
13
- getConfig: () => ({}),
14
- loadConfig: () => ({}),
15
- loadRawConfig: () => ({ ...rawConfigStore }),
16
- saveRawConfig: (cfg: Record<string, unknown>) => {
17
- saveRawConfigCalls.push(cfg);
18
- rawConfigStore = { ...cfg };
19
- },
20
- saveConfig: () => {},
21
- invalidateConfigCache: () => {},
22
- }));
23
-
24
- mock.module('../util/platform.js', () => ({
25
- getRootDir: () => testDir,
26
- getDataDir: () => testDir,
27
- getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
28
- isMacOS: () => process.platform === 'darwin',
29
- isLinux: () => process.platform === 'linux',
30
- isWindows: () => process.platform === 'win32',
31
- getSocketPath: () => join(testDir, 'test.sock'),
32
- getPidPath: () => join(testDir, 'test.pid'),
33
- getDbPath: () => join(testDir, 'test.db'),
34
- getLogPath: () => join(testDir, 'test.log'),
35
- ensureDataDir: () => {},
36
- }));
37
-
38
- mock.module('../util/logger.js', () => ({
39
- getLogger: () => ({
40
- info: () => {},
41
- warn: () => {},
42
- error: () => {},
43
- debug: () => {},
44
- trace: () => {},
45
- fatal: () => {},
46
- child: () => ({
47
- info: () => {},
48
- warn: () => {},
49
- error: () => {},
50
- debug: () => {},
51
- }),
52
- }),
53
- }));
54
-
55
- mock.module('../memory/app-store.js', () => ({
56
- queryAppRecords: () => [],
57
- createAppRecord: () => {},
58
- updateAppRecord: () => {},
59
- deleteAppRecord: () => {},
60
- listApps: () => [],
61
- getApp: () => undefined,
62
- createApp: () => {},
63
- updateApp: () => {},
64
- }));
65
-
66
- mock.module('../slack/slack-webhook.js', () => ({
67
- postToSlackWebhook: async () => {},
68
- }));
69
-
70
- import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
71
- import type {
72
- TwilioWebhookConfigRequest,
73
- ServerMessage,
74
- } from '../daemon/ipc-contract.js';
75
-
76
- function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
77
- const sent: ServerMessage[] = [];
78
- const ctx: HandlerContext = {
79
- sessions: new Map(),
80
- socketToSession: new Map(),
81
- cuSessions: new Map(),
82
- socketToCuSession: new Map(),
83
- cuObservationParseSequence: new Map(),
84
- socketSandboxOverride: new Map(),
85
- sharedRequestTimestamps: [],
86
- debounceTimers: new Map(),
87
- suppressConfigReload: false,
88
- setSuppressConfigReload: () => {},
89
- updateConfigFingerprint: () => {},
90
- send: (_socket, msg) => { sent.push(msg); },
91
- broadcast: () => {},
92
- clearAllSessions: () => 0,
93
- getOrCreateSession: () => { throw new Error('not implemented'); },
94
- touchSession: () => {},
95
- };
96
- return { ctx, sent };
97
- }
98
-
99
- describe('Twilio webhook config handler', () => {
100
- beforeEach(() => {
101
- rawConfigStore = {};
102
- saveRawConfigCalls.length = 0;
103
- });
104
-
105
- test('get returns empty string when no config set', () => {
106
- rawConfigStore = {};
107
-
108
- const msg: TwilioWebhookConfigRequest = {
109
- type: 'twilio_webhook_config',
110
- action: 'get',
111
- };
112
-
113
- const { ctx, sent } = createTestContext();
114
- handleMessage(msg, {} as net.Socket, ctx);
115
-
116
- expect(sent).toHaveLength(1);
117
- const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
118
- expect(res.type).toBe('twilio_webhook_config_response');
119
- expect(res.success).toBe(true);
120
- expect(res.webhookBaseUrl).toBe('');
121
- });
122
-
123
- test('set persists value and returns it', () => {
124
- rawConfigStore = {};
125
-
126
- const msg: TwilioWebhookConfigRequest = {
127
- type: 'twilio_webhook_config',
128
- action: 'set',
129
- webhookBaseUrl: 'https://example.com/twilio',
130
- };
131
-
132
- const { ctx, sent } = createTestContext();
133
- handleMessage(msg, {} as net.Socket, ctx);
134
-
135
- expect(sent).toHaveLength(1);
136
- const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
137
- expect(res.type).toBe('twilio_webhook_config_response');
138
- expect(res.success).toBe(true);
139
- expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
140
-
141
- expect(saveRawConfigCalls).toHaveLength(1);
142
- const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
143
- expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
144
- });
145
-
146
- test('set normalizes trailing slashes', () => {
147
- rawConfigStore = {};
148
-
149
- const msg: TwilioWebhookConfigRequest = {
150
- type: 'twilio_webhook_config',
151
- action: 'set',
152
- webhookBaseUrl: 'https://example.com/twilio///',
153
- };
154
-
155
- const { ctx, sent } = createTestContext();
156
- handleMessage(msg, {} as net.Socket, ctx);
157
-
158
- expect(sent).toHaveLength(1);
159
- const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
160
- expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
161
-
162
- const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
163
- expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
164
- });
165
-
166
- test('set treats empty string as unset', () => {
167
- rawConfigStore = { calls: { webhookBaseUrl: 'https://example.com/twilio' } };
168
- saveRawConfigCalls.length = 0;
169
-
170
- const msg: TwilioWebhookConfigRequest = {
171
- type: 'twilio_webhook_config',
172
- action: 'set',
173
- webhookBaseUrl: '',
174
- };
175
-
176
- const { ctx, sent } = createTestContext();
177
- handleMessage(msg, {} as net.Socket, ctx);
178
-
179
- expect(sent).toHaveLength(1);
180
- const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
181
- expect(res.success).toBe(true);
182
- expect(res.webhookBaseUrl).toBe('');
183
-
184
- const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
185
- expect(saved.calls?.webhookBaseUrl).toBeUndefined();
186
- });
187
-
188
- test('get after set roundtrip works', () => {
189
- rawConfigStore = {};
190
-
191
- // Set
192
- const setMsg: TwilioWebhookConfigRequest = {
193
- type: 'twilio_webhook_config',
194
- action: 'set',
195
- webhookBaseUrl: 'https://my-server.ngrok.io',
196
- };
197
-
198
- const { ctx: setCtx, sent: setSent } = createTestContext();
199
- handleMessage(setMsg, {} as net.Socket, setCtx);
200
-
201
- expect(setSent).toHaveLength(1);
202
- const setRes = setSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
203
- expect(setRes.success).toBe(true);
204
- expect(setRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
205
-
206
- // Get (rawConfigStore was updated by the mock saveRawConfig)
207
- const getMsg: TwilioWebhookConfigRequest = {
208
- type: 'twilio_webhook_config',
209
- action: 'get',
210
- };
211
-
212
- const { ctx: getCtx, sent: getSent } = createTestContext();
213
- handleMessage(getMsg, {} as net.Socket, getCtx);
214
-
215
- expect(getSent).toHaveLength(1);
216
- const getRes = getSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
217
- expect(getRes.type).toBe('twilio_webhook_config_response');
218
- expect(getRes.success).toBe(true);
219
- expect(getRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
220
- });
221
- });