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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +171 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +402 -5
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +271 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +28 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +96 -8
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +97 -0
- package/src/calls/elevenlabs-config.ts +31 -0
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +50 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +114 -0
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +26 -2
- package/src/config/schema.ts +178 -9
- package/src/config/types.ts +3 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/defaults.ts +11 -0
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/conversation-routes.ts +12 -5
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- 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
|
+
});
|