@vellumai/assistant 0.3.3 → 0.3.5
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/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- package/src/util/retry.ts +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterAll,
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Test isolation: in-memory SQLite via temp directory
|
|
@@ -41,12 +42,13 @@ mock.module('../daemon/handlers.js', () => ({
|
|
|
41
42
|
}));
|
|
42
43
|
|
|
43
44
|
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
44
|
-
import { conversations } from '../memory/schema.js';
|
|
45
|
+
import { conversations, externalConversationBindings } from '../memory/schema.js';
|
|
45
46
|
import {
|
|
46
47
|
createRun,
|
|
47
48
|
setRunConfirmation,
|
|
48
49
|
} from '../memory/runs-store.js';
|
|
49
50
|
import type { PendingConfirmation } from '../memory/runs-store.js';
|
|
51
|
+
import { setConversationKeyIfAbsent } from '../memory/conversation-key-store.js';
|
|
50
52
|
import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
|
|
51
53
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
52
54
|
import {
|
|
@@ -58,7 +60,6 @@ import {
|
|
|
58
60
|
import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
|
|
59
61
|
import {
|
|
60
62
|
handleChannelInbound,
|
|
61
|
-
isChannelApprovalsEnabled,
|
|
62
63
|
sweepExpiredGuardianApprovals,
|
|
63
64
|
verifyGatewayOrigin,
|
|
64
65
|
_setTestPollMaxWait,
|
|
@@ -94,6 +95,7 @@ function resetTables(): void {
|
|
|
94
95
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
95
96
|
db.run('DELETE FROM channel_guardian_verification_challenges');
|
|
96
97
|
db.run('DELETE FROM channel_guardian_bindings');
|
|
98
|
+
db.run('DELETE FROM conversation_keys');
|
|
97
99
|
db.run('DELETE FROM message_runs');
|
|
98
100
|
db.run('DELETE FROM channel_inbound_events');
|
|
99
101
|
db.run('DELETE FROM messages');
|
|
@@ -141,6 +143,7 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
141
143
|
const body = {
|
|
142
144
|
sourceChannel: 'telegram',
|
|
143
145
|
externalChatId: 'chat-123',
|
|
146
|
+
senderExternalUserId: 'telegram-user-default',
|
|
144
147
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
145
148
|
content: 'hello',
|
|
146
149
|
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
@@ -158,53 +161,12 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
158
161
|
|
|
159
162
|
const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
|
|
160
163
|
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
// Set up / tear down feature flag for each test
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
let originalEnv: string | undefined;
|
|
166
|
-
|
|
167
164
|
beforeEach(() => {
|
|
168
165
|
resetTables();
|
|
169
|
-
originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
|
|
170
166
|
noopProcessMessage.mockClear();
|
|
171
167
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (originalEnv === undefined) {
|
|
175
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
176
|
-
} else {
|
|
177
|
-
process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
182
|
-
// 1. Feature flag gating
|
|
183
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
184
|
-
|
|
185
|
-
describe('isChannelApprovalsEnabled', () => {
|
|
186
|
-
test('returns false when env var is not set', () => {
|
|
187
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
188
|
-
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test('returns false when env var is "false"', () => {
|
|
192
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'false';
|
|
193
|
-
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test('returns true when env var is "true"', () => {
|
|
197
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
198
|
-
expect(isChannelApprovalsEnabled()).toBe(true);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe('feature flag disabled → normal flow', () => {
|
|
203
|
-
beforeEach(() => {
|
|
204
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test('proceeds normally even when pending approvals exist', async () => {
|
|
168
|
+
describe('stale callback handling without matching pending approval', () => {
|
|
169
|
+
test('ignores stale callback payloads even when pending approvals exist', async () => {
|
|
208
170
|
ensureConversation('conv-1');
|
|
209
171
|
const run = createRun('conv-1');
|
|
210
172
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
@@ -218,9 +180,10 @@ describe('feature flag disabled → normal flow', () => {
|
|
|
218
180
|
const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
|
|
219
181
|
const body = await res.json() as Record<string, unknown>;
|
|
220
182
|
|
|
221
|
-
//
|
|
183
|
+
// Callback payloads without a matching pending approval are treated as
|
|
184
|
+
// stale and ignored.
|
|
222
185
|
expect(body.accepted).toBe(true);
|
|
223
|
-
expect(body.approval).
|
|
186
|
+
expect(body.approval).toBe('stale_ignored');
|
|
224
187
|
});
|
|
225
188
|
});
|
|
226
189
|
|
|
@@ -230,7 +193,12 @@ describe('feature flag disabled → normal flow', () => {
|
|
|
230
193
|
|
|
231
194
|
describe('inbound callback metadata triggers decision handling', () => {
|
|
232
195
|
beforeEach(() => {
|
|
233
|
-
|
|
196
|
+
createBinding({
|
|
197
|
+
assistantId: 'self',
|
|
198
|
+
channel: 'telegram',
|
|
199
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
200
|
+
guardianDeliveryChatId: 'chat-123',
|
|
201
|
+
});
|
|
234
202
|
});
|
|
235
203
|
|
|
236
204
|
test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
|
|
@@ -322,7 +290,12 @@ describe('inbound callback metadata triggers decision handling', () => {
|
|
|
322
290
|
|
|
323
291
|
describe('inbound text matching approval phrases triggers decision handling', () => {
|
|
324
292
|
beforeEach(() => {
|
|
325
|
-
|
|
293
|
+
createBinding({
|
|
294
|
+
assistantId: 'self',
|
|
295
|
+
channel: 'telegram',
|
|
296
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
297
|
+
guardianDeliveryChatId: 'chat-123',
|
|
298
|
+
});
|
|
326
299
|
});
|
|
327
300
|
|
|
328
301
|
test('text "approve" triggers approve_once decision', async () => {
|
|
@@ -387,7 +360,12 @@ describe('inbound text matching approval phrases triggers decision handling', ()
|
|
|
387
360
|
|
|
388
361
|
describe('non-decision messages during pending approval trigger reminder', () => {
|
|
389
362
|
beforeEach(() => {
|
|
390
|
-
|
|
363
|
+
createBinding({
|
|
364
|
+
assistantId: 'self',
|
|
365
|
+
channel: 'telegram',
|
|
366
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
367
|
+
guardianDeliveryChatId: 'chat-123',
|
|
368
|
+
});
|
|
391
369
|
});
|
|
392
370
|
|
|
393
371
|
test('sends a reminder prompt when message is not a decision', async () => {
|
|
@@ -435,7 +413,6 @@ describe('non-decision messages during pending approval trigger reminder', () =>
|
|
|
435
413
|
|
|
436
414
|
describe('messages without pending approval proceed normally', () => {
|
|
437
415
|
beforeEach(() => {
|
|
438
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
439
416
|
});
|
|
440
417
|
|
|
441
418
|
test('proceeds to normal processing when no pending approval exists', async () => {
|
|
@@ -479,7 +456,6 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
479
456
|
});
|
|
480
457
|
|
|
481
458
|
test('allows empty content when callbackData is present', async () => {
|
|
482
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
483
459
|
const orchestrator = makeMockOrchestrator();
|
|
484
460
|
|
|
485
461
|
// Establish the conversation first
|
|
@@ -512,7 +488,6 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
512
488
|
});
|
|
513
489
|
|
|
514
490
|
test('allows undefined content when callbackData is present', async () => {
|
|
515
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
516
491
|
const orchestrator = makeMockOrchestrator();
|
|
517
492
|
|
|
518
493
|
// Establish the conversation first
|
|
@@ -561,7 +536,12 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
561
536
|
|
|
562
537
|
describe('callback run ID validation', () => {
|
|
563
538
|
beforeEach(() => {
|
|
564
|
-
|
|
539
|
+
createBinding({
|
|
540
|
+
assistantId: 'self',
|
|
541
|
+
channel: 'telegram',
|
|
542
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
543
|
+
guardianDeliveryChatId: 'chat-123',
|
|
544
|
+
});
|
|
565
545
|
});
|
|
566
546
|
|
|
567
547
|
test('ignores stale callback when run ID does not match pending run', async () => {
|
|
@@ -669,7 +649,6 @@ describe('callback run ID validation', () => {
|
|
|
669
649
|
|
|
670
650
|
describe('linkMessage in approval-aware processing path', () => {
|
|
671
651
|
beforeEach(() => {
|
|
672
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
673
652
|
});
|
|
674
653
|
|
|
675
654
|
test('linkMessage is called when run has a messageId and reaches terminal state', async () => {
|
|
@@ -726,7 +705,6 @@ describe('linkMessage in approval-aware processing path', () => {
|
|
|
726
705
|
|
|
727
706
|
describe('terminal state check before markProcessed', () => {
|
|
728
707
|
beforeEach(() => {
|
|
729
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
730
708
|
});
|
|
731
709
|
|
|
732
710
|
test('records processing failure when run disappears (non-approval non-terminal state)', async () => {
|
|
@@ -865,7 +843,12 @@ describe('terminal state check before markProcessed', () => {
|
|
|
865
843
|
|
|
866
844
|
describe('no immediate reply after approval decision', () => {
|
|
867
845
|
beforeEach(() => {
|
|
868
|
-
|
|
846
|
+
createBinding({
|
|
847
|
+
assistantId: 'self',
|
|
848
|
+
channel: 'telegram',
|
|
849
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
850
|
+
guardianDeliveryChatId: 'chat-123',
|
|
851
|
+
});
|
|
869
852
|
});
|
|
870
853
|
|
|
871
854
|
test('deliverChannelReply is NOT called from interception after decision is applied', async () => {
|
|
@@ -943,7 +926,6 @@ describe('no immediate reply after approval decision', () => {
|
|
|
943
926
|
|
|
944
927
|
describe('stale callback handling', () => {
|
|
945
928
|
beforeEach(() => {
|
|
946
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
947
929
|
});
|
|
948
930
|
|
|
949
931
|
test('callback with no pending approval returns stale_ignored and does not start a run', async () => {
|
|
@@ -1007,7 +989,6 @@ describe('stale callback handling', () => {
|
|
|
1007
989
|
|
|
1008
990
|
describe('poll timeout handling by run state', () => {
|
|
1009
991
|
beforeEach(() => {
|
|
1010
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1011
992
|
});
|
|
1012
993
|
|
|
1013
994
|
test('records processing failure when run disappears (getRun returns null) before terminal state', async () => {
|
|
@@ -1060,8 +1041,8 @@ describe('poll timeout handling by run state', () => {
|
|
|
1060
1041
|
|
|
1061
1042
|
test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
|
|
1062
1043
|
// Use a short poll timeout so the test can exercise the timeout path
|
|
1063
|
-
// without waiting 5 minutes.
|
|
1064
|
-
_setTestPollMaxWait(
|
|
1044
|
+
// without waiting 5 minutes. Must exceed one poll interval (500ms).
|
|
1045
|
+
_setTestPollMaxWait(700);
|
|
1065
1046
|
|
|
1066
1047
|
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1067
1048
|
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
@@ -1083,20 +1064,26 @@ describe('poll timeout handling by run state', () => {
|
|
|
1083
1064
|
updatedAt: Date.now(),
|
|
1084
1065
|
};
|
|
1085
1066
|
|
|
1086
|
-
// getRun returns
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1067
|
+
// getRun returns null on the first call (causing the poll loop to break
|
|
1068
|
+
// immediately), then returns needs_confirmation on the post-loop call.
|
|
1069
|
+
// This exercises the timeout path deterministically without spinning for
|
|
1070
|
+
// the full poll duration.
|
|
1071
|
+
let getRunCalls = 0;
|
|
1089
1072
|
const orchestrator = {
|
|
1090
1073
|
submitDecision: mock(() => 'applied' as const),
|
|
1091
|
-
getRun: mock(() =>
|
|
1074
|
+
getRun: mock(() => {
|
|
1075
|
+
getRunCalls++;
|
|
1076
|
+
if (getRunCalls <= 1) return null;
|
|
1077
|
+
return { ...mockRun, status: 'needs_confirmation' as const };
|
|
1078
|
+
}),
|
|
1092
1079
|
startRun: mock(async () => mockRun),
|
|
1093
1080
|
} as unknown as RunOrchestrator;
|
|
1094
1081
|
|
|
1095
1082
|
const req = makeInboundRequest({ content: 'hello needs_confirm' });
|
|
1096
1083
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1097
1084
|
|
|
1098
|
-
// Wait for the background async to complete
|
|
1099
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1085
|
+
// Wait for the background async to complete
|
|
1086
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1100
1087
|
|
|
1101
1088
|
// markProcessed SHOULD have been called — the run is waiting for approval,
|
|
1102
1089
|
// and the post-decision delivery path will handle the final reply.
|
|
@@ -1112,6 +1099,125 @@ describe('poll timeout handling by run state', () => {
|
|
|
1112
1099
|
_setTestPollMaxWait(null);
|
|
1113
1100
|
});
|
|
1114
1101
|
|
|
1102
|
+
test('marks event as processed when run transitions from needs_confirmation to running at poll timeout', async () => {
|
|
1103
|
+
// When an approval is applied near the poll deadline, the run transitions
|
|
1104
|
+
// from needs_confirmation to running. The post-decision delivery path
|
|
1105
|
+
// in handleApprovalInterception handles the final reply, so the main poll
|
|
1106
|
+
// should mark the event as processed rather than recording a failure.
|
|
1107
|
+
//
|
|
1108
|
+
// The hasPostDecisionDelivery flag is only set when the approval prompt
|
|
1109
|
+
// is actually delivered successfully — not in auto-deny paths. This test
|
|
1110
|
+
// sets up a guardian actor with a real DB run so the standard approval
|
|
1111
|
+
// prompt is delivered and the flag is set.
|
|
1112
|
+
_setTestPollMaxWait(100);
|
|
1113
|
+
|
|
1114
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1115
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1116
|
+
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
1117
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1118
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1119
|
+
|
|
1120
|
+
// Set up a guardian binding so the sender is a guardian (standard approval
|
|
1121
|
+
// path, not auto-deny). This ensures the approval prompt is delivered and
|
|
1122
|
+
// hasPostDecisionDelivery is set to true.
|
|
1123
|
+
createBinding({
|
|
1124
|
+
assistantId: 'self',
|
|
1125
|
+
channel: 'telegram',
|
|
1126
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
1127
|
+
guardianDeliveryChatId: 'chat-123',
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
const conversationId = `conv-post-approval-${Date.now()}`;
|
|
1131
|
+
ensureConversation(conversationId);
|
|
1132
|
+
setConversationKeyIfAbsent('asst:self:telegram:chat-123', conversationId);
|
|
1133
|
+
setConversationKeyIfAbsent('telegram:chat-123', conversationId);
|
|
1134
|
+
|
|
1135
|
+
let realRunId: string | undefined;
|
|
1136
|
+
|
|
1137
|
+
// Simulate a run that transitions from needs_confirmation back to running
|
|
1138
|
+
// (approval applied) before the poll exits, then stays running past timeout.
|
|
1139
|
+
let getRunCalls = 0;
|
|
1140
|
+
const orchestrator = {
|
|
1141
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1142
|
+
getRun: mock(() => {
|
|
1143
|
+
getRunCalls++;
|
|
1144
|
+
// First call inside the loop: needs_confirmation (triggers approval prompt delivery)
|
|
1145
|
+
if (getRunCalls <= 1) return {
|
|
1146
|
+
id: realRunId ?? 'run-post-approval',
|
|
1147
|
+
conversationId,
|
|
1148
|
+
messageId: 'user-msg-203',
|
|
1149
|
+
status: 'needs_confirmation' as const,
|
|
1150
|
+
pendingConfirmation: sampleConfirmation,
|
|
1151
|
+
pendingSecret: null,
|
|
1152
|
+
inputTokens: 0,
|
|
1153
|
+
outputTokens: 0,
|
|
1154
|
+
estimatedCost: 0,
|
|
1155
|
+
error: null,
|
|
1156
|
+
createdAt: Date.now(),
|
|
1157
|
+
updatedAt: Date.now(),
|
|
1158
|
+
};
|
|
1159
|
+
// Subsequent calls: running (approval was applied, run resumed)
|
|
1160
|
+
return {
|
|
1161
|
+
id: realRunId ?? 'run-post-approval',
|
|
1162
|
+
conversationId,
|
|
1163
|
+
messageId: 'user-msg-203',
|
|
1164
|
+
status: 'running' as const,
|
|
1165
|
+
pendingConfirmation: null,
|
|
1166
|
+
pendingSecret: null,
|
|
1167
|
+
inputTokens: 0,
|
|
1168
|
+
outputTokens: 0,
|
|
1169
|
+
estimatedCost: 0,
|
|
1170
|
+
error: null,
|
|
1171
|
+
createdAt: Date.now(),
|
|
1172
|
+
updatedAt: Date.now(),
|
|
1173
|
+
};
|
|
1174
|
+
}),
|
|
1175
|
+
startRun: mock(async (_convId: string) => {
|
|
1176
|
+
const run = createRun(conversationId);
|
|
1177
|
+
realRunId = run.id;
|
|
1178
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1179
|
+
return {
|
|
1180
|
+
id: run.id,
|
|
1181
|
+
conversationId,
|
|
1182
|
+
messageId: null,
|
|
1183
|
+
status: 'running' as const,
|
|
1184
|
+
pendingConfirmation: null,
|
|
1185
|
+
pendingSecret: null,
|
|
1186
|
+
inputTokens: 0,
|
|
1187
|
+
outputTokens: 0,
|
|
1188
|
+
estimatedCost: 0,
|
|
1189
|
+
error: null,
|
|
1190
|
+
createdAt: Date.now(),
|
|
1191
|
+
updatedAt: Date.now(),
|
|
1192
|
+
};
|
|
1193
|
+
}),
|
|
1194
|
+
} as unknown as RunOrchestrator;
|
|
1195
|
+
|
|
1196
|
+
const req = makeInboundRequest({ content: 'hello post-approval running' });
|
|
1197
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1198
|
+
|
|
1199
|
+
// Wait for background async to complete (poll timeout + buffer)
|
|
1200
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1201
|
+
|
|
1202
|
+
// The approval prompt should have been delivered (standard path for guardian actor)
|
|
1203
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
1204
|
+
|
|
1205
|
+
// markProcessed SHOULD have been called — the approval prompt was delivered
|
|
1206
|
+
// (hasPostDecisionDelivery is true) and the run transitioned to running
|
|
1207
|
+
// (post-approval), so the post-decision delivery path handles the final reply.
|
|
1208
|
+
expect(markSpy).toHaveBeenCalled();
|
|
1209
|
+
|
|
1210
|
+
// recordProcessingFailure should NOT have been called
|
|
1211
|
+
expect(failureSpy).not.toHaveBeenCalled();
|
|
1212
|
+
|
|
1213
|
+
linkSpy.mockRestore();
|
|
1214
|
+
markSpy.mockRestore();
|
|
1215
|
+
failureSpy.mockRestore();
|
|
1216
|
+
deliverSpy.mockRestore();
|
|
1217
|
+
approvalSpy.mockRestore();
|
|
1218
|
+
_setTestPollMaxWait(null);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1115
1221
|
test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
|
|
1116
1222
|
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1117
1223
|
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
@@ -1165,7 +1271,6 @@ describe('poll timeout handling by run state', () => {
|
|
|
1165
1271
|
|
|
1166
1272
|
describe('post-decision delivery after poll timeout', () => {
|
|
1167
1273
|
beforeEach(() => {
|
|
1168
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1169
1274
|
});
|
|
1170
1275
|
|
|
1171
1276
|
test('delivers reply via callback after a late approval decision', async () => {
|
|
@@ -1279,7 +1384,6 @@ describe('post-decision delivery after poll timeout', () => {
|
|
|
1279
1384
|
|
|
1280
1385
|
describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
1281
1386
|
beforeEach(() => {
|
|
1282
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1283
1387
|
});
|
|
1284
1388
|
|
|
1285
1389
|
test('startRun is called with sourceChannel from inbound event', async () => {
|
|
@@ -1337,13 +1441,19 @@ describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
|
1337
1441
|
|
|
1338
1442
|
describe('SMS channel approval decisions', () => {
|
|
1339
1443
|
beforeEach(() => {
|
|
1340
|
-
|
|
1444
|
+
createBinding({
|
|
1445
|
+
assistantId: 'self',
|
|
1446
|
+
channel: 'sms',
|
|
1447
|
+
guardianExternalUserId: 'sms-user-default',
|
|
1448
|
+
guardianDeliveryChatId: 'sms-chat-123',
|
|
1449
|
+
});
|
|
1341
1450
|
});
|
|
1342
1451
|
|
|
1343
1452
|
function makeSmsInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
1344
1453
|
const body = {
|
|
1345
1454
|
sourceChannel: 'sms',
|
|
1346
1455
|
externalChatId: 'sms-chat-123',
|
|
1456
|
+
senderExternalUserId: 'sms-user-default',
|
|
1347
1457
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1348
1458
|
content: 'hello',
|
|
1349
1459
|
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
@@ -1525,7 +1635,9 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1525
1635
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
1526
1636
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1527
1637
|
expect(replyPayload.chatId).toBe('sms-chat-verify');
|
|
1528
|
-
expect(replyPayload.text).
|
|
1638
|
+
expect(typeof replyPayload.text).toBe('string');
|
|
1639
|
+
expect(replyPayload.text.toLowerCase()).toContain('guardian');
|
|
1640
|
+
expect(replyPayload.text.toLowerCase()).toContain('verif');
|
|
1529
1641
|
|
|
1530
1642
|
deliverSpy.mockRestore();
|
|
1531
1643
|
});
|
|
@@ -1558,7 +1670,9 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1558
1670
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1559
1671
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
1560
1672
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1561
|
-
expect(replyPayload.text).
|
|
1673
|
+
expect(typeof replyPayload.text).toBe('string');
|
|
1674
|
+
expect(replyPayload.text.toLowerCase()).toContain('verif');
|
|
1675
|
+
expect(replyPayload.text.toLowerCase()).toContain('failed');
|
|
1562
1676
|
|
|
1563
1677
|
deliverSpy.mockRestore();
|
|
1564
1678
|
});
|
|
@@ -1570,7 +1684,6 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1570
1684
|
|
|
1571
1685
|
describe('SMS non-guardian actor gating', () => {
|
|
1572
1686
|
beforeEach(() => {
|
|
1573
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1574
1687
|
});
|
|
1575
1688
|
|
|
1576
1689
|
test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
|
|
@@ -1644,7 +1757,18 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1644
1757
|
|
|
1645
1758
|
describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
1646
1759
|
beforeEach(() => {
|
|
1647
|
-
|
|
1760
|
+
createBinding({
|
|
1761
|
+
assistantId: 'self',
|
|
1762
|
+
channel: 'telegram',
|
|
1763
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
1764
|
+
guardianDeliveryChatId: 'chat-123',
|
|
1765
|
+
});
|
|
1766
|
+
createBinding({
|
|
1767
|
+
assistantId: 'self',
|
|
1768
|
+
channel: 'http-api',
|
|
1769
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
1770
|
+
guardianDeliveryChatId: 'chat-123',
|
|
1771
|
+
});
|
|
1648
1772
|
});
|
|
1649
1773
|
|
|
1650
1774
|
test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
|
|
@@ -1800,10 +1924,9 @@ function makeSensitiveOrchestrator(opts: {
|
|
|
1800
1924
|
|
|
1801
1925
|
describe('fail-closed guardian gate — unverified channel', () => {
|
|
1802
1926
|
beforeEach(() => {
|
|
1803
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1804
1927
|
});
|
|
1805
1928
|
|
|
1806
|
-
test('no binding + sensitive action → auto-deny
|
|
1929
|
+
test('no binding + sensitive action → auto-deny with contextual assistant guidance', async () => {
|
|
1807
1930
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1808
1931
|
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1809
1932
|
|
|
@@ -1823,11 +1946,15 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1823
1946
|
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
1824
1947
|
expect(decisionArgs[1]).toBe('deny');
|
|
1825
1948
|
|
|
1826
|
-
// The
|
|
1827
|
-
|
|
1828
|
-
|
|
1949
|
+
// The deny decision should carry guardian setup context for assistant reply generation.
|
|
1950
|
+
expect(typeof decisionArgs[2]).toBe('string');
|
|
1951
|
+
expect((decisionArgs[2] as string).toLowerCase()).toContain('no guardian');
|
|
1952
|
+
|
|
1953
|
+
// The runtime should not send a second deterministic denial notice.
|
|
1954
|
+
const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
|
|
1955
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
|
|
1829
1956
|
);
|
|
1830
|
-
expect(
|
|
1957
|
+
expect(deterministicNoticeCalls.length).toBe(0);
|
|
1831
1958
|
|
|
1832
1959
|
// No approval prompt should have been sent to a guardian (none exists)
|
|
1833
1960
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -1916,11 +2043,19 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1916
2043
|
expect(body.accepted).toBe(true);
|
|
1917
2044
|
expect(body.approval).toBe('decision_applied');
|
|
1918
2045
|
|
|
1919
|
-
// The
|
|
2046
|
+
// The deny decision should carry guardian setup context for the assistant.
|
|
2047
|
+
const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
|
|
2048
|
+
expect(submitCalls.length).toBeGreaterThanOrEqual(1);
|
|
2049
|
+
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
2050
|
+
expect(lastDecision[1]).toBe('deny');
|
|
2051
|
+
expect(typeof lastDecision[2]).toBe('string');
|
|
2052
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('no guardian');
|
|
2053
|
+
|
|
2054
|
+
// Interception should not emit a separate deterministic denial notice.
|
|
1920
2055
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
1921
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian
|
|
2056
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
|
|
1922
2057
|
);
|
|
1923
|
-
expect(denialCalls.length).
|
|
2058
|
+
expect(denialCalls.length).toBe(0);
|
|
1924
2059
|
|
|
1925
2060
|
deliverSpy.mockRestore();
|
|
1926
2061
|
});
|
|
@@ -1932,7 +2067,6 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1932
2067
|
|
|
1933
2068
|
describe('guardian-with-binding path regression', () => {
|
|
1934
2069
|
beforeEach(() => {
|
|
1935
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1936
2070
|
});
|
|
1937
2071
|
|
|
1938
2072
|
test('non-guardian with binding routes approval to guardian chat', async () => {
|
|
@@ -1962,9 +2096,9 @@ describe('guardian-with-binding path regression', () => {
|
|
|
1962
2096
|
const approvalArgs = approvalSpy.mock.calls[0];
|
|
1963
2097
|
expect(approvalArgs[1]).toBe('guardian-chat-1');
|
|
1964
2098
|
|
|
1965
|
-
// Requester should have been notified the request was
|
|
2099
|
+
// Requester should have been notified the request was forwarded to the guardian
|
|
1966
2100
|
const notifyCalls = deliverSpy.mock.calls.filter(
|
|
1967
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2101
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('guardian'),
|
|
1968
2102
|
);
|
|
1969
2103
|
expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
|
|
1970
2104
|
|
|
@@ -2004,6 +2138,53 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2004
2138
|
deliverSpy.mockRestore();
|
|
2005
2139
|
approvalSpy.mockRestore();
|
|
2006
2140
|
});
|
|
2141
|
+
|
|
2142
|
+
test('guardian callback for own pending run is handled by standard interception', async () => {
|
|
2143
|
+
createBinding({
|
|
2144
|
+
assistantId: 'self',
|
|
2145
|
+
channel: 'telegram',
|
|
2146
|
+
guardianExternalUserId: 'guardian-user-self-callback',
|
|
2147
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
const orchestrator = makeMockOrchestrator();
|
|
2151
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2152
|
+
|
|
2153
|
+
// Establish the conversation mapping for chat-123.
|
|
2154
|
+
const initReq = makeInboundRequest({
|
|
2155
|
+
content: 'init',
|
|
2156
|
+
senderExternalUserId: 'guardian-user-self-callback',
|
|
2157
|
+
externalChatId: 'chat-123',
|
|
2158
|
+
});
|
|
2159
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
2160
|
+
|
|
2161
|
+
const db = getDb();
|
|
2162
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
2163
|
+
const conversationId = events[0]?.conversation_id;
|
|
2164
|
+
ensureConversation(conversationId!);
|
|
2165
|
+
|
|
2166
|
+
const run = createRun(conversationId!);
|
|
2167
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2168
|
+
|
|
2169
|
+
// Button callback includes a runId but there is no guardian approval request
|
|
2170
|
+
// because this is the guardian's own approval flow.
|
|
2171
|
+
const req = makeInboundRequest({
|
|
2172
|
+
content: '',
|
|
2173
|
+
senderExternalUserId: 'guardian-user-self-callback',
|
|
2174
|
+
externalChatId: 'chat-123',
|
|
2175
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2179
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2180
|
+
|
|
2181
|
+
expect(body.accepted).toBe(true);
|
|
2182
|
+
expect(body.approval).toBe('decision_applied');
|
|
2183
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
2184
|
+
expect(getPendingApprovalForRun(run.id)).toBeNull();
|
|
2185
|
+
|
|
2186
|
+
deliverSpy.mockRestore();
|
|
2187
|
+
});
|
|
2007
2188
|
});
|
|
2008
2189
|
|
|
2009
2190
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -2012,7 +2193,6 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2012
2193
|
|
|
2013
2194
|
describe('guardian delivery failure → denial', () => {
|
|
2014
2195
|
beforeEach(() => {
|
|
2015
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2016
2196
|
});
|
|
2017
2197
|
|
|
2018
2198
|
test('delivery failure denies run and notifies requester', async () => {
|
|
@@ -2047,14 +2227,14 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2047
2227
|
|
|
2048
2228
|
// Requester should have been notified that delivery failed
|
|
2049
2229
|
const failureCalls = deliverSpy.mock.calls.filter(
|
|
2050
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2230
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('denied'),
|
|
2051
2231
|
);
|
|
2052
2232
|
expect(failureCalls.length).toBeGreaterThanOrEqual(1);
|
|
2053
2233
|
|
|
2054
|
-
// The
|
|
2055
|
-
//
|
|
2234
|
+
// The guardian_request_forwarded success notice should NOT have been
|
|
2235
|
+
// delivered (since delivery failed).
|
|
2056
2236
|
const successCalls = deliverSpy.mock.calls.filter(
|
|
2057
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2237
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
|
|
2058
2238
|
);
|
|
2059
2239
|
expect(successCalls.length).toBe(0);
|
|
2060
2240
|
|
|
@@ -2108,7 +2288,6 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2108
2288
|
|
|
2109
2289
|
describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
2110
2290
|
beforeEach(() => {
|
|
2111
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2112
2291
|
});
|
|
2113
2292
|
|
|
2114
2293
|
test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
|
|
@@ -2154,7 +2333,6 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
|
2154
2333
|
|
|
2155
2334
|
describe('guardian decision scoping — multiple pending approvals', () => {
|
|
2156
2335
|
beforeEach(() => {
|
|
2157
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2158
2336
|
});
|
|
2159
2337
|
|
|
2160
2338
|
test('callback for older run resolves to the correct approval request', async () => {
|
|
@@ -2241,7 +2419,6 @@ describe('guardian decision scoping — multiple pending approvals', () => {
|
|
|
2241
2419
|
|
|
2242
2420
|
describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
2243
2421
|
beforeEach(() => {
|
|
2244
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2245
2422
|
});
|
|
2246
2423
|
|
|
2247
2424
|
test('does not apply plain-text decision to wrong run when multiple pending', async () => {
|
|
@@ -2314,7 +2491,7 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2314
2491
|
|
|
2315
2492
|
// A disambiguation message should have been sent to the guardian
|
|
2316
2493
|
const disambigCalls = deliverSpy.mock.calls.filter(
|
|
2317
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending
|
|
2494
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('pending'),
|
|
2318
2495
|
);
|
|
2319
2496
|
expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
|
|
2320
2497
|
|
|
@@ -2328,7 +2505,6 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2328
2505
|
|
|
2329
2506
|
describe('expired guardian approval auto-denies via sweep', () => {
|
|
2330
2507
|
beforeEach(() => {
|
|
2331
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2332
2508
|
});
|
|
2333
2509
|
|
|
2334
2510
|
test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
|
|
@@ -2472,7 +2648,12 @@ describe('deliver-once idempotency guard', () => {
|
|
|
2472
2648
|
|
|
2473
2649
|
describe('final reply idempotency — no duplicate delivery', () => {
|
|
2474
2650
|
beforeEach(() => {
|
|
2475
|
-
|
|
2651
|
+
createBinding({
|
|
2652
|
+
assistantId: 'self',
|
|
2653
|
+
channel: 'telegram',
|
|
2654
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
2655
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2656
|
+
});
|
|
2476
2657
|
});
|
|
2477
2658
|
|
|
2478
2659
|
test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
|
|
@@ -2766,7 +2947,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
2766
2947
|
});
|
|
2767
2948
|
|
|
2768
2949
|
test('actor role resolution uses threaded assistantId', async () => {
|
|
2769
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2770
2950
|
|
|
2771
2951
|
// Create guardian binding for asst-role-X
|
|
2772
2952
|
createBinding({
|
|
@@ -2800,7 +2980,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
2800
2980
|
});
|
|
2801
2981
|
|
|
2802
2982
|
test('same user is guardian for one assistant but not another', async () => {
|
|
2803
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2804
2983
|
|
|
2805
2984
|
// user-multi is guardian for asst-M1 but not asst-M2
|
|
2806
2985
|
createBinding({
|
|
@@ -2856,16 +3035,87 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
2856
3035
|
deliverSpy.mockRestore();
|
|
2857
3036
|
approvalSpy.mockRestore();
|
|
2858
3037
|
});
|
|
3038
|
+
|
|
3039
|
+
test('non-self assistant inbound does not mutate assistant-agnostic external bindings', async () => {
|
|
3040
|
+
const db = getDb();
|
|
3041
|
+
const now = Date.now();
|
|
3042
|
+
ensureConversation('conv-existing-binding');
|
|
3043
|
+
db.insert(externalConversationBindings).values({
|
|
3044
|
+
conversationId: 'conv-existing-binding',
|
|
3045
|
+
sourceChannel: 'telegram',
|
|
3046
|
+
externalChatId: 'chat-123',
|
|
3047
|
+
externalUserId: 'existing-user',
|
|
3048
|
+
createdAt: now,
|
|
3049
|
+
updatedAt: now,
|
|
3050
|
+
lastInboundAt: now,
|
|
3051
|
+
}).run();
|
|
3052
|
+
|
|
3053
|
+
const req = makeInboundRequest({
|
|
3054
|
+
content: 'hello from non-self assistant',
|
|
3055
|
+
senderExternalUserId: 'incoming-user',
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
const res = await handleChannelInbound(req, undefined, 'token', undefined, 'asst-non-self');
|
|
3059
|
+
expect(res.status).toBe(200);
|
|
3060
|
+
|
|
3061
|
+
const binding = db
|
|
3062
|
+
.select()
|
|
3063
|
+
.from(externalConversationBindings)
|
|
3064
|
+
.where(eq(externalConversationBindings.conversationId, 'conv-existing-binding'))
|
|
3065
|
+
.get();
|
|
3066
|
+
expect(binding).not.toBeNull();
|
|
3067
|
+
expect(binding!.externalUserId).toBe('existing-user');
|
|
3068
|
+
});
|
|
2859
3069
|
});
|
|
2860
3070
|
|
|
2861
3071
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2862
|
-
// 27. Guardian enforcement
|
|
3072
|
+
// 27. Guardian enforcement behavior
|
|
2863
3073
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2864
3074
|
|
|
2865
|
-
describe('guardian enforcement
|
|
2866
|
-
test('
|
|
2867
|
-
|
|
3075
|
+
describe('guardian enforcement behavior', () => {
|
|
3076
|
+
test('guardian sender on telegram uses approval-aware path', async () => {
|
|
3077
|
+
|
|
3078
|
+
// Default senderExternalUserId in makeInboundRequest is telegram-user-default.
|
|
3079
|
+
createBinding({
|
|
3080
|
+
assistantId: 'self',
|
|
3081
|
+
channel: 'telegram',
|
|
3082
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
3083
|
+
guardianDeliveryChatId: 'chat-123',
|
|
3084
|
+
});
|
|
3085
|
+
|
|
3086
|
+
const processSpy = mock(async () => ({ messageId: 'msg-bg-guardian' }));
|
|
3087
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3088
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3089
|
+
|
|
3090
|
+
const orchestrator = makeSensitiveOrchestrator({
|
|
3091
|
+
runId: 'run-guardian-flag-off-telegram',
|
|
3092
|
+
terminalStatus: 'completed',
|
|
3093
|
+
});
|
|
3094
|
+
|
|
3095
|
+
const req = makeInboundRequest({
|
|
3096
|
+
content: 'place a call',
|
|
3097
|
+
senderExternalUserId: 'telegram-user-default',
|
|
3098
|
+
sourceChannel: 'telegram',
|
|
3099
|
+
});
|
|
3100
|
+
|
|
3101
|
+
const res = await handleChannelInbound(req, processSpy, 'token', orchestrator);
|
|
3102
|
+
expect(res.status).toBe(200);
|
|
3103
|
+
|
|
3104
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3105
|
+
|
|
3106
|
+
// Regression guard: this must use the orchestrator approval path, not
|
|
3107
|
+
// fire-and-forget processMessage, otherwise prompts can time out.
|
|
3108
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3109
|
+
expect(processSpy).not.toHaveBeenCalled();
|
|
2868
3110
|
|
|
3111
|
+
// Guardian self-approval prompt should be delivered to the requester's chat.
|
|
3112
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3113
|
+
|
|
3114
|
+
approvalSpy.mockRestore();
|
|
3115
|
+
deliverSpy.mockRestore();
|
|
3116
|
+
});
|
|
3117
|
+
|
|
3118
|
+
test('non-guardian sensitive action routes approval to guardian', async () => {
|
|
2869
3119
|
// Create a guardian binding — user-guardian is the guardian
|
|
2870
3120
|
createBinding({
|
|
2871
3121
|
assistantId: 'self',
|
|
@@ -2874,31 +3124,28 @@ describe('guardian enforcement independence from approval flag', () => {
|
|
|
2874
3124
|
guardianDeliveryChatId: 'chat-guardian',
|
|
2875
3125
|
});
|
|
2876
3126
|
|
|
2877
|
-
|
|
2878
|
-
// Actor role resolution should still classify them as non-guardian
|
|
2879
|
-
// even though the approval UX is off.
|
|
2880
|
-
const orchestrator = makeMockOrchestrator();
|
|
3127
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-flag-off-guardian', terminalStatus: 'completed' });
|
|
2881
3128
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3129
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2882
3130
|
|
|
2883
3131
|
const req = makeInboundRequest({
|
|
2884
|
-
content: '
|
|
3132
|
+
content: 'do something dangerous',
|
|
2885
3133
|
senderExternalUserId: 'user-non-guardian',
|
|
2886
3134
|
});
|
|
2887
3135
|
|
|
2888
3136
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2889
3137
|
expect(res.status).toBe(200);
|
|
3138
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2890
3139
|
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2895
|
-
expect(body.accepted).toBe(true);
|
|
3140
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3141
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
3142
|
+
expect(approvalArgs[1]).toBe('chat-guardian');
|
|
2896
3143
|
|
|
2897
3144
|
deliverSpy.mockRestore();
|
|
3145
|
+
approvalSpy.mockRestore();
|
|
2898
3146
|
});
|
|
2899
3147
|
|
|
2900
3148
|
test('missing senderExternalUserId with guardian binding fails closed', async () => {
|
|
2901
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2902
3149
|
|
|
2903
3150
|
// Create a guardian binding — guardian enforcement is active
|
|
2904
3151
|
createBinding({
|
|
@@ -2928,20 +3175,21 @@ describe('guardian enforcement independence from approval flag', () => {
|
|
|
2928
3175
|
// Wait for background processing
|
|
2929
3176
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2930
3177
|
|
|
2931
|
-
// The unknown actor should be treated as unverified_channel and
|
|
2932
|
-
//
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
3178
|
+
// The unknown actor should be treated as unverified_channel and denied,
|
|
3179
|
+
// with context passed into the tool-denial response for assistant phrasing.
|
|
3180
|
+
const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
|
|
3181
|
+
expect(submitCalls.length).toBeGreaterThanOrEqual(1);
|
|
3182
|
+
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3183
|
+
expect(lastDecision[1]).toBe('deny');
|
|
3184
|
+
expect(typeof lastDecision[2]).toBe('string');
|
|
3185
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
|
|
3186
|
+
|
|
3187
|
+
// No separate deterministic denial notice should be emitted here.
|
|
2936
3188
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
2937
|
-
(call) =>
|
|
2938
|
-
|
|
2939
|
-
const text = (call[1] as { text?: string }).text ?? '';
|
|
2940
|
-
return text.includes('requires guardian approval') &&
|
|
2941
|
-
(text.includes('identity could not be determined') || text.includes('no guardian has been set up'));
|
|
2942
|
-
},
|
|
3189
|
+
(call) => typeof call[1] === 'object'
|
|
3190
|
+
&& ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
|
|
2943
3191
|
);
|
|
2944
|
-
expect(denialCalls.length).
|
|
3192
|
+
expect(denialCalls.length).toBe(0);
|
|
2945
3193
|
|
|
2946
3194
|
// Auto-deny path should never prompt for approval
|
|
2947
3195
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -2950,25 +3198,40 @@ describe('guardian enforcement independence from approval flag', () => {
|
|
|
2950
3198
|
approvalSpy.mockRestore();
|
|
2951
3199
|
});
|
|
2952
3200
|
|
|
2953
|
-
test('missing senderExternalUserId without guardian binding
|
|
2954
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
3201
|
+
test('missing senderExternalUserId without guardian binding fails closed', async () => {
|
|
2955
3202
|
|
|
2956
|
-
// No guardian binding exists
|
|
2957
|
-
|
|
3203
|
+
// No guardian binding exists, but identity is missing — treat sender as
|
|
3204
|
+
// unverified_channel and auto-deny sensitive actions.
|
|
3205
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-noid-nobinding', terminalStatus: 'failed' });
|
|
2958
3206
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3207
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2959
3208
|
|
|
2960
3209
|
const req = makeInboundRequest({
|
|
2961
|
-
content: '
|
|
3210
|
+
content: 'do something dangerous',
|
|
2962
3211
|
senderExternalUserId: undefined,
|
|
2963
3212
|
});
|
|
2964
3213
|
|
|
2965
3214
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2966
3215
|
expect(res.status).toBe(200);
|
|
2967
3216
|
|
|
2968
|
-
|
|
2969
|
-
|
|
3217
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3218
|
+
|
|
3219
|
+
const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
|
|
3220
|
+
expect(submitCalls.length).toBeGreaterThanOrEqual(1);
|
|
3221
|
+
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3222
|
+
expect(lastDecision[1]).toBe('deny');
|
|
3223
|
+
expect(typeof lastDecision[2]).toBe('string');
|
|
3224
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
|
|
3225
|
+
|
|
3226
|
+
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3227
|
+
(call) => typeof call[1] === 'object'
|
|
3228
|
+
&& ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
|
|
3229
|
+
);
|
|
3230
|
+
expect(denialCalls.length).toBe(0);
|
|
3231
|
+
expect(approvalSpy).not.toHaveBeenCalled();
|
|
2970
3232
|
|
|
2971
3233
|
deliverSpy.mockRestore();
|
|
3234
|
+
approvalSpy.mockRestore();
|
|
2972
3235
|
});
|
|
2973
3236
|
});
|
|
2974
3237
|
|
|
@@ -3119,7 +3382,6 @@ describe('handleChannelInbound gatewayOriginSecret integration', () => {
|
|
|
3119
3382
|
|
|
3120
3383
|
describe('unknown actor identity — forceStrictSideEffects', () => {
|
|
3121
3384
|
beforeEach(() => {
|
|
3122
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
3123
3385
|
});
|
|
3124
3386
|
|
|
3125
3387
|
test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {
|