vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,438 @@
1
+ import { describe, test, expect, beforeEach, afterAll, afterEach, mock, spyOn } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test isolation: in-memory SQLite via temp directory
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const testDir = mkdtempSync(join(tmpdir(), 'channel-approval-routes-test-'));
11
+
12
+ mock.module('../util/platform.js', () => ({
13
+ getRootDir: () => testDir,
14
+ getDataDir: () => testDir,
15
+ isMacOS: () => process.platform === 'darwin',
16
+ isLinux: () => process.platform === 'linux',
17
+ isWindows: () => process.platform === 'win32',
18
+ getSocketPath: () => join(testDir, 'test.sock'),
19
+ getPidPath: () => join(testDir, 'test.pid'),
20
+ getDbPath: () => join(testDir, 'test.db'),
21
+ getLogPath: () => join(testDir, 'test.log'),
22
+ ensureDataDir: () => {},
23
+ }));
24
+
25
+ mock.module('../util/logger.js', () => ({
26
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
27
+ get: () => () => {},
28
+ }),
29
+ }));
30
+
31
+ // Mock security check to always pass
32
+ mock.module('../security/secret-ingress.js', () => ({
33
+ checkIngressForSecrets: () => ({ blocked: false }),
34
+ }));
35
+
36
+ // Mock render to return the raw content as text
37
+ mock.module('../daemon/handlers.js', () => ({
38
+ renderHistoryContent: (content: unknown) => ({
39
+ text: typeof content === 'string' ? content : JSON.stringify(content),
40
+ }),
41
+ }));
42
+
43
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
44
+ import {
45
+ createRun,
46
+ setRunConfirmation,
47
+ } from '../memory/runs-store.js';
48
+ import type { PendingConfirmation } from '../memory/runs-store.js';
49
+ import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
50
+ import { handleChannelInbound, isChannelApprovalsEnabled } from '../runtime/routes/channel-routes.js';
51
+ import * as gatewayClient from '../runtime/gateway-client.js';
52
+
53
+ initializeDb();
54
+
55
+ afterAll(() => {
56
+ resetDb();
57
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function ensureConversation(conversationId: string): void {
65
+ const db = getDb();
66
+ try {
67
+ db.run(
68
+ `INSERT INTO conversations (id, createdAt, updatedAt) VALUES (?, ?, ?)`,
69
+ [conversationId, Date.now(), Date.now()],
70
+ );
71
+ } catch {
72
+ // already exists
73
+ }
74
+ }
75
+
76
+ function resetTables(): void {
77
+ const db = getDb();
78
+ db.run('DELETE FROM message_runs');
79
+ db.run('DELETE FROM channel_inbound_events');
80
+ db.run('DELETE FROM conversations');
81
+ }
82
+
83
+ const sampleConfirmation: PendingConfirmation = {
84
+ toolName: 'shell',
85
+ toolUseId: 'req-abc-123',
86
+ input: { command: 'rm -rf /tmp/test' },
87
+ riskLevel: 'high',
88
+ allowlistOptions: [{ label: 'rm -rf /tmp/test', pattern: 'rm -rf /tmp/test' }],
89
+ scopeOptions: [{ label: 'everywhere', scope: 'everywhere' }],
90
+ };
91
+
92
+ function makeMockOrchestrator(
93
+ submitResult: 'applied' | 'run_not_found' | 'no_pending_decision' = 'applied',
94
+ ): RunOrchestrator {
95
+ return {
96
+ submitDecision: mock(() => submitResult),
97
+ getRun: mock(() => null),
98
+ startRun: mock(async () => ({
99
+ id: 'run-1',
100
+ conversationId: 'conv-1',
101
+ messageId: null,
102
+ status: 'running' as const,
103
+ pendingConfirmation: null,
104
+ pendingSecret: null,
105
+ inputTokens: 0,
106
+ outputTokens: 0,
107
+ estimatedCost: 0,
108
+ error: null,
109
+ createdAt: Date.now(),
110
+ updatedAt: Date.now(),
111
+ })),
112
+ } as unknown as RunOrchestrator;
113
+ }
114
+
115
+ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
116
+ const body = {
117
+ sourceChannel: 'telegram',
118
+ externalChatId: 'chat-123',
119
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
120
+ content: 'hello',
121
+ replyCallbackUrl: 'https://gateway.test/deliver',
122
+ ...overrides,
123
+ };
124
+ return new Request('http://localhost/channels/inbound', {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify(body),
128
+ });
129
+ }
130
+
131
+ const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Set up / tear down feature flag for each test
135
+ // ---------------------------------------------------------------------------
136
+
137
+ let originalEnv: string | undefined;
138
+
139
+ beforeEach(() => {
140
+ resetTables();
141
+ originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
142
+ noopProcessMessage.mockClear();
143
+ });
144
+
145
+ afterEach(() => {
146
+ if (originalEnv === undefined) {
147
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
148
+ } else {
149
+ process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
150
+ }
151
+ });
152
+
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+ // 1. Feature flag gating
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+
157
+ describe('isChannelApprovalsEnabled', () => {
158
+ test('returns false when env var is not set', () => {
159
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
160
+ expect(isChannelApprovalsEnabled()).toBe(false);
161
+ });
162
+
163
+ test('returns false when env var is "false"', () => {
164
+ process.env.CHANNEL_APPROVALS_ENABLED = 'false';
165
+ expect(isChannelApprovalsEnabled()).toBe(false);
166
+ });
167
+
168
+ test('returns true when env var is "true"', () => {
169
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
170
+ expect(isChannelApprovalsEnabled()).toBe(true);
171
+ });
172
+ });
173
+
174
+ describe('feature flag disabled → normal flow', () => {
175
+ beforeEach(() => {
176
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
177
+ });
178
+
179
+ test('proceeds normally even when pending approvals exist', async () => {
180
+ ensureConversation('conv-1');
181
+ const run = createRun('conv-1', 'msg-1');
182
+ setRunConfirmation(run.id, sampleConfirmation);
183
+
184
+ const orchestrator = makeMockOrchestrator();
185
+ const req = makeInboundRequest({
186
+ content: 'approve',
187
+ callbackData: 'apr:run-1:approve_once',
188
+ });
189
+
190
+ const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
191
+ const body = await res.json() as Record<string, unknown>;
192
+
193
+ // Should proceed normally — no approval interception
194
+ expect(body.accepted).toBe(true);
195
+ expect(body.approval).toBeUndefined();
196
+ });
197
+ });
198
+
199
+ // ═══════════════════════════════════════════════════════════════════════════
200
+ // 2. Callback data triggers decision handling
201
+ // ═══════════════════════════════════════════════════════════════════════════
202
+
203
+ describe('inbound callback metadata triggers decision handling', () => {
204
+ beforeEach(() => {
205
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
206
+ });
207
+
208
+ test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
209
+ // We need the conversation to exist AND have a pending run.
210
+ // The channel-delivery-store will create a conversation for us via recordInbound,
211
+ // but we also need the run to be linked to the same conversationId.
212
+ // Let's set up the conversation first, then create a run.
213
+ ensureConversation('conv-1');
214
+
215
+ // Create and record an earlier inbound event for this chat to establish
216
+ // the conversation mapping (so recordInbound returns the same conversationId).
217
+ // Actually, recordInbound auto-creates a conversationId based on source+chat.
218
+ // We need to find out what conversationId will be generated for telegram:chat-123.
219
+
220
+ // Let's use a spy to check if handleChannelDecision-equivalent behavior fires.
221
+ const orchestrator = makeMockOrchestrator();
222
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
223
+
224
+ // First, send a normal message to establish the conversation.
225
+ const initReq = makeInboundRequest({ content: 'init' });
226
+ const initRes = await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
227
+ const initBody = await initRes.json() as { conversationId?: string; eventId?: string; accepted?: boolean };
228
+
229
+ // Now we need to find the actual conversationId that was created.
230
+ // Check the channel_inbound_events table.
231
+ const db = getDb();
232
+ const events = db.prepare('SELECT conversationId FROM channel_inbound_events').all() as Array<{ conversationId: string }>;
233
+ const conversationId = events[0]?.conversationId;
234
+ expect(conversationId).toBeTruthy();
235
+
236
+ // Ensure conversation row exists for FK constraints
237
+ ensureConversation(conversationId!);
238
+
239
+ // Create a pending run for this conversation
240
+ const run = createRun(conversationId!, 'msg-1');
241
+ setRunConfirmation(run.id, sampleConfirmation);
242
+
243
+ // Now send a callback data message
244
+ const req = makeInboundRequest({
245
+ content: '',
246
+ callbackData: `apr:${run.id}:approve_once`,
247
+ });
248
+
249
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
250
+ const body = await res.json() as Record<string, unknown>;
251
+
252
+ expect(body.accepted).toBe(true);
253
+ expect(body.approval).toBe('decision_applied');
254
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
255
+
256
+ deliverSpy.mockRestore();
257
+ });
258
+
259
+ test('callback data "apr:<runId>:reject" applies a rejection', async () => {
260
+ const orchestrator = makeMockOrchestrator();
261
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
262
+
263
+ // Establish the conversation
264
+ const initReq = makeInboundRequest({ content: 'init' });
265
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
266
+
267
+ const db = getDb();
268
+ const events = db.prepare('SELECT conversationId FROM channel_inbound_events').all() as Array<{ conversationId: string }>;
269
+ const conversationId = events[0]?.conversationId;
270
+ ensureConversation(conversationId!);
271
+
272
+ const run = createRun(conversationId!, 'msg-1');
273
+ setRunConfirmation(run.id, sampleConfirmation);
274
+
275
+ const req = makeInboundRequest({
276
+ content: '',
277
+ callbackData: `apr:${run.id}:reject`,
278
+ });
279
+
280
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
281
+ const body = await res.json() as Record<string, unknown>;
282
+
283
+ expect(body.accepted).toBe(true);
284
+ expect(body.approval).toBe('decision_applied');
285
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
286
+
287
+ deliverSpy.mockRestore();
288
+ });
289
+ });
290
+
291
+ // ═══════════════════════════════════════════════════════════════════════════
292
+ // 3. Plain text triggers decision handling
293
+ // ═══════════════════════════════════════════════════════════════════════════
294
+
295
+ describe('inbound text matching approval phrases triggers decision handling', () => {
296
+ beforeEach(() => {
297
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
298
+ });
299
+
300
+ test('text "approve" triggers approve_once decision', async () => {
301
+ const orchestrator = makeMockOrchestrator();
302
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
303
+
304
+ // Establish the conversation
305
+ const initReq = makeInboundRequest({ content: 'init' });
306
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
307
+
308
+ const db = getDb();
309
+ const events = db.prepare('SELECT conversationId FROM channel_inbound_events').all() as Array<{ conversationId: string }>;
310
+ const conversationId = events[0]?.conversationId;
311
+ ensureConversation(conversationId!);
312
+
313
+ const run = createRun(conversationId!, 'msg-1');
314
+ setRunConfirmation(run.id, sampleConfirmation);
315
+
316
+ const req = makeInboundRequest({ content: 'approve' });
317
+
318
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
319
+ const body = await res.json() as Record<string, unknown>;
320
+
321
+ expect(body.accepted).toBe(true);
322
+ expect(body.approval).toBe('decision_applied');
323
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
324
+
325
+ deliverSpy.mockRestore();
326
+ });
327
+
328
+ test('text "always" triggers approve_always decision', async () => {
329
+ const orchestrator = makeMockOrchestrator();
330
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
331
+
332
+ const initReq = makeInboundRequest({ content: 'init' });
333
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
334
+
335
+ const db = getDb();
336
+ const events = db.prepare('SELECT conversationId FROM channel_inbound_events').all() as Array<{ conversationId: string }>;
337
+ const conversationId = events[0]?.conversationId;
338
+ ensureConversation(conversationId!);
339
+
340
+ const run = createRun(conversationId!, 'msg-1');
341
+ setRunConfirmation(run.id, sampleConfirmation);
342
+
343
+ const req = makeInboundRequest({ content: 'always' });
344
+
345
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
346
+ const body = await res.json() as Record<string, unknown>;
347
+
348
+ expect(body.accepted).toBe(true);
349
+ expect(body.approval).toBe('decision_applied');
350
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
351
+
352
+ deliverSpy.mockRestore();
353
+ });
354
+ });
355
+
356
+ // ═══════════════════════════════════════════════════════════════════════════
357
+ // 4. Non-decision messages during pending approval trigger reminder
358
+ // ═══════════════════════════════════════════════════════════════════════════
359
+
360
+ describe('non-decision messages during pending approval trigger reminder', () => {
361
+ beforeEach(() => {
362
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
363
+ });
364
+
365
+ test('sends a reminder prompt when message is not a decision', async () => {
366
+ const orchestrator = makeMockOrchestrator();
367
+ const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
368
+ const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
369
+
370
+ const initReq = makeInboundRequest({ content: 'init' });
371
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
372
+
373
+ const db = getDb();
374
+ const events = db.prepare('SELECT conversationId FROM channel_inbound_events').all() as Array<{ conversationId: string }>;
375
+ const conversationId = events[0]?.conversationId;
376
+ ensureConversation(conversationId!);
377
+
378
+ const run = createRun(conversationId!, 'msg-1');
379
+ setRunConfirmation(run.id, sampleConfirmation);
380
+
381
+ // Send a message that is NOT a decision
382
+ const req = makeInboundRequest({ content: 'what is the weather?' });
383
+
384
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
385
+ const body = await res.json() as Record<string, unknown>;
386
+
387
+ expect(body.accepted).toBe(true);
388
+ expect(body.approval).toBe('reminder_sent');
389
+
390
+ // The approval prompt delivery should have been called
391
+ expect(deliverSpy).toHaveBeenCalled();
392
+ const callArgs = deliverSpy.mock.calls[0];
393
+ // The text should contain the reminder prefix
394
+ expect(callArgs[2]).toContain("I'm still waiting");
395
+ // The approval UI metadata should be present
396
+ expect(callArgs[3]).toBeDefined();
397
+ expect(callArgs[3]!.runId).toBe(run.id);
398
+
399
+ deliverSpy.mockRestore();
400
+ replySpy.mockRestore();
401
+ });
402
+ });
403
+
404
+ // ═══════════════════════════════════════════════════════════════════════════
405
+ // 5. Messages without pending approval proceed normally
406
+ // ═══════════════════════════════════════════════════════════════════════════
407
+
408
+ describe('messages without pending approval proceed normally', () => {
409
+ beforeEach(() => {
410
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
411
+ });
412
+
413
+ test('proceeds to normal processing when no pending approval exists', async () => {
414
+ const orchestrator = makeMockOrchestrator();
415
+
416
+ const req = makeInboundRequest({ content: 'hello world' });
417
+
418
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
419
+ const body = await res.json() as Record<string, unknown>;
420
+
421
+ expect(body.accepted).toBe(true);
422
+ expect(body.approval).toBeUndefined();
423
+ // Normal flow should have been triggered
424
+ });
425
+
426
+ test('text "approve" is processed normally when no pending approval exists', async () => {
427
+ const orchestrator = makeMockOrchestrator();
428
+
429
+ const req = makeInboundRequest({ content: 'approve' });
430
+
431
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
432
+ const body = await res.json() as Record<string, unknown>;
433
+
434
+ expect(body.accepted).toBe(true);
435
+ // Should NOT be treated as an approval decision since there's no pending approval
436
+ expect(body.approval).toBeUndefined();
437
+ });
438
+ });