@vellumai/assistant 0.4.4 → 0.4.6
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/ARCHITECTURE.md +4 -4
- package/README.md +6 -6
- package/bun.lock +6 -2
- package/docs/architecture/memory.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/actor-token-service.test.ts +5 -2
- package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/call-controller.test.ts +78 -0
- package/src/__tests__/call-domain.test.ts +148 -10
- package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
- package/src/__tests__/call-pointer-messages.test.ts +105 -43
- package/src/__tests__/canonical-guardian-store.test.ts +44 -10
- package/src/__tests__/channel-approval-routes.test.ts +67 -65
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
- package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
- package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
- package/src/__tests__/guardian-grant-minting.test.ts +24 -24
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
- package/src/__tests__/guardian-routing-state.test.ts +4 -4
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
- package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
- package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +50 -47
- package/src/__tests__/relay-server.test.ts +71 -0
- package/src/__tests__/send-endpoint-busy.test.ts +6 -0
- package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
- package/src/__tests__/system-prompt.test.ts +1 -0
- package/src/__tests__/tool-approval-handler.test.ts +1 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
- package/src/__tests__/trusted-contact-verification.test.ts +10 -10
- package/src/approvals/guardian-decision-primitive.ts +29 -25
- package/src/approvals/guardian-request-resolvers.ts +9 -5
- package/src/calls/call-pointer-message-composer.ts +27 -85
- package/src/calls/call-pointer-messages.ts +54 -21
- package/src/calls/guardian-dispatch.ts +30 -0
- package/src/calls/relay-server.ts +13 -13
- package/src/config/system-prompt.ts +10 -3
- package/src/config/templates/BOOTSTRAP.md +6 -5
- package/src/config/templates/USER.md +1 -0
- package/src/config/user-reference.ts +44 -0
- package/src/daemon/handlers/guardian-actions.ts +5 -2
- package/src/daemon/handlers/sessions.ts +8 -3
- package/src/daemon/lifecycle.ts +109 -3
- package/src/daemon/server.ts +32 -24
- package/src/daemon/session-agent-loop.ts +4 -3
- package/src/daemon/session-lifecycle.ts +1 -9
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-runtime-assembly.ts +2 -0
- package/src/daemon/session-tool-setup.ts +10 -0
- package/src/daemon/session.ts +1 -0
- package/src/memory/canonical-guardian-store.ts +40 -0
- package/src/memory/conversation-crud.ts +26 -0
- package/src/memory/conversation-store.ts +1 -0
- package/src/memory/db-init.ts +8 -0
- package/src/memory/guardian-bindings.ts +4 -0
- package/src/memory/job-handlers/backfill.ts +2 -9
- package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
- package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema.ts +3 -0
- package/src/notifications/copy-composer.ts +2 -2
- package/src/runtime/access-request-helper.ts +43 -28
- package/src/runtime/actor-trust-resolver.ts +19 -14
- package/src/runtime/channel-guardian-service.ts +6 -0
- package/src/runtime/guardian-context-resolver.ts +6 -2
- package/src/runtime/guardian-reply-router.ts +33 -16
- package/src/runtime/guardian-vellum-migration.ts +29 -5
- package/src/runtime/http-types.ts +0 -13
- package/src/runtime/local-actor-identity.ts +19 -13
- package/src/runtime/middleware/actor-token.ts +2 -2
- package/src/runtime/routes/channel-delivery-routes.ts +5 -5
- package/src/runtime/routes/conversation-routes.ts +45 -35
- package/src/runtime/routes/guardian-action-routes.ts +7 -1
- package/src/runtime/routes/guardian-approval-interception.ts +52 -52
- package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
- package/src/runtime/routes/inbound-conversation.ts +7 -7
- package/src/runtime/routes/inbound-message-handler.ts +105 -94
- package/src/runtime/tool-grant-request-helper.ts +1 -0
- package/src/util/logger.ts +10 -0
- package/src/daemon/call-pointer-generators.ts +0 -59
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard test: `isTrusted` must not appear in production code.
|
|
3
|
+
*
|
|
4
|
+
* The authorization model was migrated from a boolean `isTrusted` flag to
|
|
5
|
+
* principal-based authorization (`guardianPrincipalId` matching). This guard
|
|
6
|
+
* ensures the legacy pattern is never reintroduced in production source files.
|
|
7
|
+
*
|
|
8
|
+
* The invariant: `actor.guardianPrincipalId === request.guardianPrincipalId`
|
|
9
|
+
* (with cross-channel fallback via the vellum canonical principal).
|
|
10
|
+
*
|
|
11
|
+
* Allowed exceptions:
|
|
12
|
+
* - Variable names like `isTrustedActor` or `isTrustedContact` that refer
|
|
13
|
+
* to trust-class checks (e.g. `trustClass === 'guardian'`), NOT to a
|
|
14
|
+
* boolean `isTrusted` property on ActorContext.
|
|
15
|
+
* - Test files (__tests__/) — may reference `isTrusted` in test descriptions
|
|
16
|
+
* or comments about the migration.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
|
|
24
|
+
const repoRoot = resolve(__dirname, '..', '..', '..');
|
|
25
|
+
|
|
26
|
+
describe('isTrusted guard', () => {
|
|
27
|
+
test('isTrusted property must not exist in production ActorContext usage', () => {
|
|
28
|
+
// Search for `isTrusted` used as a property (e.g., `.isTrusted`, `isTrusted:`,
|
|
29
|
+
// `isTrusted =`) in production TypeScript files, excluding tests, node_modules,
|
|
30
|
+
// and the allowed trust-class variable pattern.
|
|
31
|
+
const raw = execSync(
|
|
32
|
+
[
|
|
33
|
+
'grep -rn "isTrusted" assistant/src/ --include="*.ts"',
|
|
34
|
+
'grep -v "__tests__"',
|
|
35
|
+
'grep -v "node_modules"',
|
|
36
|
+
].join(' | ') + ' || true',
|
|
37
|
+
{ encoding: 'utf-8', cwd: repoRoot },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Filter in JS: strip allowed token names from each line, then check if
|
|
41
|
+
// `isTrusted` still appears. This avoids the grep -v approach which could
|
|
42
|
+
// mask forbidden usage on lines that also contain allowed tokens.
|
|
43
|
+
const ALLOWED_TOKENS = ['isTrustedActor', 'isTrustedContact', 'isTrustedTrustClass'];
|
|
44
|
+
const offending = raw
|
|
45
|
+
.trim()
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter((line) => {
|
|
48
|
+
if (!line) return false;
|
|
49
|
+
let stripped = line;
|
|
50
|
+
for (const token of ALLOWED_TOKENS) {
|
|
51
|
+
stripped = stripped.replaceAll(token, '');
|
|
52
|
+
}
|
|
53
|
+
return stripped.includes('isTrusted');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (offending.length > 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'Found `isTrusted` references in production code. Authorization must use ' +
|
|
59
|
+
'`guardianPrincipalId` matching instead. Offending lines:\n' +
|
|
60
|
+
offending.join('\n'),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('ActorContext interface must not declare isTrusted field', () => {
|
|
66
|
+
// Verify the ActorContext type definition does not include isTrusted
|
|
67
|
+
const result = execSync(
|
|
68
|
+
[
|
|
69
|
+
'grep -n "isTrusted" assistant/src/approvals/guardian-request-resolvers.ts',
|
|
70
|
+
'true',
|
|
71
|
+
].join(' || '),
|
|
72
|
+
{ encoding: 'utf-8', cwd: repoRoot },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(result.trim()).toBe('');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -138,12 +138,12 @@ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
138
138
|
const body: Record<string, unknown> = {
|
|
139
139
|
sourceChannel: 'telegram',
|
|
140
140
|
interface: 'telegram',
|
|
141
|
-
|
|
141
|
+
conversationExternalId: 'chat-123',
|
|
142
142
|
externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
143
143
|
content: 'Hello, can I use this assistant?',
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
actorExternalId: 'user-unknown-456',
|
|
145
|
+
actorDisplayName: 'Alice Unknown',
|
|
146
|
+
actorUsername: 'alice_unknown',
|
|
147
147
|
replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
|
|
148
148
|
...overrides,
|
|
149
149
|
};
|
|
@@ -206,8 +206,8 @@ describe('non-member access request notification', () => {
|
|
|
206
206
|
expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
|
|
207
207
|
expect(emitSignalCalls[0].sourceChannel).toBe('telegram');
|
|
208
208
|
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
209
|
-
expect(payload.
|
|
210
|
-
expect(payload.
|
|
209
|
+
expect(payload.actorExternalId).toBe('user-unknown-456');
|
|
210
|
+
expect(payload.actorDisplayName).toBe('Alice Unknown');
|
|
211
211
|
|
|
212
212
|
// A canonical access request was created
|
|
213
213
|
const pending = listCanonicalGuardianRequests({
|
|
@@ -258,9 +258,9 @@ describe('non-member access request notification', () => {
|
|
|
258
258
|
expect(pending.length).toBe(1);
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
-
test('access request is created
|
|
262
|
-
// No guardian binding on any channel —
|
|
263
|
-
//
|
|
261
|
+
test('access request is created with self-healed principal even without same-channel guardian binding', async () => {
|
|
262
|
+
// No guardian binding on any channel — self-heal creates a vellum binding
|
|
263
|
+
// so the access_request (now decisionable) has a guardianPrincipalId.
|
|
264
264
|
const req = buildInboundRequest();
|
|
265
265
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
266
266
|
const json = await resp.json() as Record<string, unknown>;
|
|
@@ -276,7 +276,7 @@ describe('non-member access request notification', () => {
|
|
|
276
276
|
expect(emitSignalCalls.length).toBe(1);
|
|
277
277
|
expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
|
|
278
278
|
|
|
279
|
-
// Canonical request was created with
|
|
279
|
+
// Canonical request was created with a self-healed principal
|
|
280
280
|
const pending = listCanonicalGuardianRequests({
|
|
281
281
|
status: 'pending',
|
|
282
282
|
requesterExternalUserId: 'user-unknown-456',
|
|
@@ -284,7 +284,9 @@ describe('non-member access request notification', () => {
|
|
|
284
284
|
kind: 'access_request',
|
|
285
285
|
});
|
|
286
286
|
expect(pending.length).toBe(1);
|
|
287
|
-
|
|
287
|
+
// Self-heal bootstraps a vellum binding — guardianExternalUserId is now set
|
|
288
|
+
expect(pending[0].guardianExternalUserId).toBeDefined();
|
|
289
|
+
expect(pending[0].guardianPrincipalId).toBeDefined();
|
|
288
290
|
});
|
|
289
291
|
|
|
290
292
|
test('cross-channel fallback: SMS guardian binding resolves for Telegram access request', async () => {
|
|
@@ -319,7 +321,7 @@ describe('non-member access request notification', () => {
|
|
|
319
321
|
expect(pending[0].guardianExternalUserId).toBe('guardian-sms-user');
|
|
320
322
|
});
|
|
321
323
|
|
|
322
|
-
test('no notification when
|
|
324
|
+
test('no notification when actorExternalId is absent', async () => {
|
|
323
325
|
createBinding({
|
|
324
326
|
assistantId: 'self',
|
|
325
327
|
channel: 'telegram',
|
|
@@ -327,13 +329,12 @@ describe('non-member access request notification', () => {
|
|
|
327
329
|
guardianDeliveryChatId: 'guardian-chat-789',
|
|
328
330
|
});
|
|
329
331
|
|
|
330
|
-
// Message without
|
|
331
|
-
// The ACL check requires senderExternalUserId to look up members,
|
|
332
|
-
// so without it the non-member gate is bypassed entirely.
|
|
332
|
+
// Message without actorExternalId — the handler returns BAD_REQUEST.
|
|
333
333
|
const req = buildInboundRequest({
|
|
334
|
-
|
|
334
|
+
actorExternalId: undefined,
|
|
335
335
|
});
|
|
336
|
-
await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
336
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
337
|
+
expect(resp.status).toBe(400);
|
|
337
338
|
|
|
338
339
|
// No access request notification should fire (no identity to notify about)
|
|
339
340
|
expect(emitSignalCalls.length).toBe(0);
|
|
@@ -345,12 +346,12 @@ describe('access-request-helper unit tests', () => {
|
|
|
345
346
|
resetState();
|
|
346
347
|
});
|
|
347
348
|
|
|
348
|
-
test('notifyGuardianOfAccessRequest returns no_sender_id when
|
|
349
|
+
test('notifyGuardianOfAccessRequest returns no_sender_id when actorExternalId is absent', () => {
|
|
349
350
|
const result = notifyGuardianOfAccessRequest({
|
|
350
351
|
canonicalAssistantId: 'self',
|
|
351
352
|
sourceChannel: 'telegram',
|
|
352
|
-
|
|
353
|
-
|
|
353
|
+
conversationExternalId: 'chat-123',
|
|
354
|
+
actorExternalId: undefined,
|
|
354
355
|
});
|
|
355
356
|
|
|
356
357
|
expect(result.notified).toBe(false);
|
|
@@ -363,13 +364,13 @@ describe('access-request-helper unit tests', () => {
|
|
|
363
364
|
expect(pending.length).toBe(0);
|
|
364
365
|
});
|
|
365
366
|
|
|
366
|
-
test('notifyGuardianOfAccessRequest creates request with
|
|
367
|
+
test('notifyGuardianOfAccessRequest creates request with self-healed principal when no binding exists', () => {
|
|
367
368
|
const result = notifyGuardianOfAccessRequest({
|
|
368
369
|
canonicalAssistantId: 'self',
|
|
369
370
|
sourceChannel: 'telegram',
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
conversationExternalId: 'chat-123',
|
|
372
|
+
actorExternalId: 'unknown-user',
|
|
373
|
+
actorDisplayName: 'Bob',
|
|
373
374
|
});
|
|
374
375
|
|
|
375
376
|
expect(result.notified).toBe(true);
|
|
@@ -383,7 +384,9 @@ describe('access-request-helper unit tests', () => {
|
|
|
383
384
|
kind: 'access_request',
|
|
384
385
|
});
|
|
385
386
|
expect(pending.length).toBe(1);
|
|
386
|
-
|
|
387
|
+
// Self-heal bootstraps a vellum binding
|
|
388
|
+
expect(pending[0].guardianExternalUserId).toBeDefined();
|
|
389
|
+
expect(pending[0].guardianPrincipalId).toBeDefined();
|
|
387
390
|
|
|
388
391
|
// Signal was emitted
|
|
389
392
|
expect(emitSignalCalls.length).toBe(1);
|
|
@@ -401,8 +404,8 @@ describe('access-request-helper unit tests', () => {
|
|
|
401
404
|
const result = notifyGuardianOfAccessRequest({
|
|
402
405
|
canonicalAssistantId: 'self',
|
|
403
406
|
sourceChannel: 'telegram',
|
|
404
|
-
|
|
405
|
-
|
|
407
|
+
conversationExternalId: 'tg-chat',
|
|
408
|
+
actorExternalId: 'unknown-tg-user',
|
|
406
409
|
});
|
|
407
410
|
|
|
408
411
|
expect(result.notified).toBe(true);
|
|
@@ -438,8 +441,8 @@ describe('access-request-helper unit tests', () => {
|
|
|
438
441
|
const result = notifyGuardianOfAccessRequest({
|
|
439
442
|
canonicalAssistantId: 'self',
|
|
440
443
|
sourceChannel: 'telegram',
|
|
441
|
-
|
|
442
|
-
|
|
444
|
+
conversationExternalId: 'chat-123',
|
|
445
|
+
actorExternalId: 'unknown-user',
|
|
443
446
|
});
|
|
444
447
|
|
|
445
448
|
expect(result.notified).toBe(true);
|
|
@@ -457,13 +460,13 @@ describe('access-request-helper unit tests', () => {
|
|
|
457
460
|
expect(payload.guardianBindingChannel).toBe('telegram');
|
|
458
461
|
});
|
|
459
462
|
|
|
460
|
-
test('notifyGuardianOfAccessRequest for voice channel includes
|
|
463
|
+
test('notifyGuardianOfAccessRequest for voice channel includes actorDisplayName in contextPayload', () => {
|
|
461
464
|
const result = notifyGuardianOfAccessRequest({
|
|
462
465
|
canonicalAssistantId: 'self',
|
|
463
466
|
sourceChannel: 'voice',
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
+
conversationExternalId: '+15559998888',
|
|
468
|
+
actorExternalId: '+15559998888',
|
|
469
|
+
actorDisplayName: 'Alice Caller',
|
|
467
470
|
});
|
|
468
471
|
|
|
469
472
|
expect(result.notified).toBe(true);
|
|
@@ -471,8 +474,8 @@ describe('access-request-helper unit tests', () => {
|
|
|
471
474
|
|
|
472
475
|
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
473
476
|
expect(payload.sourceChannel).toBe('voice');
|
|
474
|
-
expect(payload.
|
|
475
|
-
expect(payload.
|
|
477
|
+
expect(payload.actorDisplayName).toBe('Alice Caller');
|
|
478
|
+
expect(payload.actorExternalId).toBe('+15559998888');
|
|
476
479
|
expect(payload.senderIdentifier).toBe('Alice Caller');
|
|
477
480
|
|
|
478
481
|
// Canonical request should exist
|
|
@@ -489,9 +492,9 @@ describe('access-request-helper unit tests', () => {
|
|
|
489
492
|
const result = notifyGuardianOfAccessRequest({
|
|
490
493
|
canonicalAssistantId: 'self',
|
|
491
494
|
sourceChannel: 'telegram',
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
+
conversationExternalId: 'chat-123',
|
|
496
|
+
actorExternalId: 'unknown-user',
|
|
497
|
+
actorDisplayName: 'Test User',
|
|
495
498
|
});
|
|
496
499
|
|
|
497
500
|
expect(result.notified).toBe(true);
|
|
@@ -507,9 +510,9 @@ describe('access-request-helper unit tests', () => {
|
|
|
507
510
|
const result = notifyGuardianOfAccessRequest({
|
|
508
511
|
canonicalAssistantId: 'self',
|
|
509
512
|
sourceChannel: 'telegram',
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
+
conversationExternalId: 'chat-123',
|
|
514
|
+
actorExternalId: 'revoked-user',
|
|
515
|
+
actorDisplayName: 'Revoked User',
|
|
513
516
|
previousMemberStatus: 'revoked',
|
|
514
517
|
});
|
|
515
518
|
|
|
@@ -544,9 +547,9 @@ describe('access-request-helper unit tests', () => {
|
|
|
544
547
|
const result = notifyGuardianOfAccessRequest({
|
|
545
548
|
canonicalAssistantId: 'self',
|
|
546
549
|
sourceChannel: 'voice',
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
+
conversationExternalId: '+15556667777',
|
|
551
|
+
actorExternalId: '+15556667777',
|
|
552
|
+
actorDisplayName: 'Noah',
|
|
550
553
|
});
|
|
551
554
|
|
|
552
555
|
expect(result.notified).toBe(true);
|
|
@@ -584,9 +587,9 @@ describe('access-request-helper unit tests', () => {
|
|
|
584
587
|
const result = notifyGuardianOfAccessRequest({
|
|
585
588
|
canonicalAssistantId: 'self',
|
|
586
589
|
sourceChannel: 'telegram',
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
+
conversationExternalId: 'chat-123',
|
|
591
|
+
actorExternalId: 'unknown-user',
|
|
592
|
+
actorDisplayName: 'Alice',
|
|
590
593
|
});
|
|
591
594
|
|
|
592
595
|
expect(result.notified).toBe(true);
|
|
@@ -137,6 +137,7 @@ import {
|
|
|
137
137
|
createCallSession,
|
|
138
138
|
getCallEvents,
|
|
139
139
|
getCallSession,
|
|
140
|
+
updateCallSession,
|
|
140
141
|
} from '../calls/call-store.js';
|
|
141
142
|
import type { RelayWebSocketData } from '../calls/relay-server.js';
|
|
142
143
|
import { activeRelayConnections,RelayConnection } from '../calls/relay-server.js';
|
|
@@ -3186,4 +3187,74 @@ describe('relay-server', () => {
|
|
|
3186
3187
|
mockConfig.calls.userConsultTimeoutSeconds = 120;
|
|
3187
3188
|
relay.destroy();
|
|
3188
3189
|
});
|
|
3190
|
+
|
|
3191
|
+
// ── Pointer message regression tests for non-guardian paths ───────
|
|
3192
|
+
|
|
3193
|
+
test('normal relay close (1000) writes completed pointer to origin conversation', async () => {
|
|
3194
|
+
ensureConversation('conv-relay-ptr-complete');
|
|
3195
|
+
ensureConversation('conv-relay-ptr-complete-origin');
|
|
3196
|
+
const session = createCallSession({
|
|
3197
|
+
conversationId: 'conv-relay-ptr-complete',
|
|
3198
|
+
provider: 'twilio',
|
|
3199
|
+
fromNumber: '+15551111111',
|
|
3200
|
+
toNumber: '+15559876543',
|
|
3201
|
+
assistantId: 'self',
|
|
3202
|
+
initiatedFromConversationId: 'conv-relay-ptr-complete-origin',
|
|
3203
|
+
});
|
|
3204
|
+
updateCallSession(session.id, { status: 'in_progress', startedAt: Date.now() - 30_000 });
|
|
3205
|
+
|
|
3206
|
+
const { relay } = createMockWs(session.id);
|
|
3207
|
+
|
|
3208
|
+
await relay.handleMessage(JSON.stringify({
|
|
3209
|
+
type: 'setup',
|
|
3210
|
+
callSid: 'CA_relay_ptr_complete',
|
|
3211
|
+
from: '+15551111111',
|
|
3212
|
+
to: '+15559876543',
|
|
3213
|
+
customParameters: {},
|
|
3214
|
+
}));
|
|
3215
|
+
|
|
3216
|
+
relay.handleTransportClosed(1000, 'normal');
|
|
3217
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3218
|
+
|
|
3219
|
+
const text = getLatestAssistantText('conv-relay-ptr-complete-origin');
|
|
3220
|
+
expect(text).not.toBeNull();
|
|
3221
|
+
expect(text!).toContain('+15559876543');
|
|
3222
|
+
expect(text!).toContain('completed');
|
|
3223
|
+
|
|
3224
|
+
relay.destroy();
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
test('abnormal relay close writes failed pointer to origin conversation', async () => {
|
|
3228
|
+
ensureConversation('conv-relay-ptr-fail');
|
|
3229
|
+
ensureConversation('conv-relay-ptr-fail-origin');
|
|
3230
|
+
const session = createCallSession({
|
|
3231
|
+
conversationId: 'conv-relay-ptr-fail',
|
|
3232
|
+
provider: 'twilio',
|
|
3233
|
+
fromNumber: '+15551111111',
|
|
3234
|
+
toNumber: '+15559876543',
|
|
3235
|
+
assistantId: 'self',
|
|
3236
|
+
initiatedFromConversationId: 'conv-relay-ptr-fail-origin',
|
|
3237
|
+
});
|
|
3238
|
+
updateCallSession(session.id, { status: 'in_progress' });
|
|
3239
|
+
|
|
3240
|
+
const { relay } = createMockWs(session.id);
|
|
3241
|
+
|
|
3242
|
+
await relay.handleMessage(JSON.stringify({
|
|
3243
|
+
type: 'setup',
|
|
3244
|
+
callSid: 'CA_relay_ptr_fail',
|
|
3245
|
+
from: '+15551111111',
|
|
3246
|
+
to: '+15559876543',
|
|
3247
|
+
customParameters: {},
|
|
3248
|
+
}));
|
|
3249
|
+
|
|
3250
|
+
relay.handleTransportClosed(1006, 'abnormal');
|
|
3251
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3252
|
+
|
|
3253
|
+
const text = getLatestAssistantText('conv-relay-ptr-fail-origin');
|
|
3254
|
+
expect(text).not.toBeNull();
|
|
3255
|
+
expect(text!).toContain('+15559876543');
|
|
3256
|
+
expect(text!).toContain('failed');
|
|
3257
|
+
|
|
3258
|
+
relay.destroy();
|
|
3259
|
+
});
|
|
3189
3260
|
});
|
|
@@ -333,6 +333,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
333
333
|
sourceChannel: 'vellum',
|
|
334
334
|
conversationId,
|
|
335
335
|
toolName: 'call_start',
|
|
336
|
+
guardianPrincipalId: 'test-principal-id',
|
|
336
337
|
status: 'pending',
|
|
337
338
|
requestCode: 'ABC123',
|
|
338
339
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
@@ -389,6 +390,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
389
390
|
conversationId,
|
|
390
391
|
toolName: 'call_start',
|
|
391
392
|
status: 'pending',
|
|
393
|
+
guardianPrincipalId: 'test-principal-id',
|
|
392
394
|
requestCode: 'C0FFEE',
|
|
393
395
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
394
396
|
});
|
|
@@ -450,6 +452,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
450
452
|
conversationId,
|
|
451
453
|
toolName: 'call_start',
|
|
452
454
|
status: 'pending',
|
|
455
|
+
guardianPrincipalId: 'test-principal-id',
|
|
453
456
|
requestCode: 'DEF456',
|
|
454
457
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
455
458
|
});
|
|
@@ -505,6 +508,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
505
508
|
conversationId,
|
|
506
509
|
toolName: 'call_start',
|
|
507
510
|
status: 'pending',
|
|
511
|
+
guardianPrincipalId: 'test-principal-id',
|
|
508
512
|
requestCode: 'Q2D456',
|
|
509
513
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
510
514
|
});
|
|
@@ -560,6 +564,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
560
564
|
conversationId,
|
|
561
565
|
toolName: 'call_start',
|
|
562
566
|
status: 'pending',
|
|
567
|
+
guardianPrincipalId: 'test-principal-id',
|
|
563
568
|
requestCode: 'GHI789',
|
|
564
569
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
565
570
|
});
|
|
@@ -613,6 +618,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
613
618
|
conversationId,
|
|
614
619
|
toolName: 'call_start',
|
|
615
620
|
status: 'pending',
|
|
621
|
+
guardianPrincipalId: 'test-principal-id',
|
|
616
622
|
requestCode: 'JKL012',
|
|
617
623
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
618
624
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the toolsDisabledDepth mechanism in createResolveToolsCallback.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Resolver returns empty tools when toolsDisabledDepth > 0
|
|
6
|
+
* - Resolver returns normal tools when toolsDisabledDepth is back to 0
|
|
7
|
+
* - allowedToolNames is cleared while disabled and restored on next normal call
|
|
8
|
+
* - Depth counter survives overlapping increments/decrements
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import type { SkillProjectionCache } from '../daemon/session-skill-tools.js';
|
|
14
|
+
import type { Message, ToolDefinition } from '../providers/types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Mocks — must be set up before importing the module under test
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
mock.module('../daemon/session-skill-tools.js', () => ({
|
|
21
|
+
projectSkillTools: mock((_history: Message[], _opts: unknown) => ({
|
|
22
|
+
allowedToolNames: new Set<string>(),
|
|
23
|
+
toolDefinitions: [],
|
|
24
|
+
})),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Import after mocks
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createResolveToolsCallback,
|
|
33
|
+
type SkillProjectionContext,
|
|
34
|
+
} from '../daemon/session-tool-setup.js';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function makeToolDef(name: string): ToolDefinition {
|
|
41
|
+
return { name, description: `${name} tool`, input_schema: {} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeCtx(overrides: Partial<SkillProjectionContext> = {}): SkillProjectionContext {
|
|
45
|
+
return {
|
|
46
|
+
skillProjectionState: new Map(),
|
|
47
|
+
skillProjectionCache: {} as SkillProjectionCache,
|
|
48
|
+
coreToolNames: new Set(['tool_a', 'tool_b']),
|
|
49
|
+
toolsDisabledDepth: 0,
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EMPTY_HISTORY: Message[] = [];
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Tests
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe('createResolveToolsCallback — toolsDisabledDepth', () => {
|
|
61
|
+
test('returns undefined when no tool definitions provided', () => {
|
|
62
|
+
const ctx = makeCtx();
|
|
63
|
+
const resolve = createResolveToolsCallback([], ctx);
|
|
64
|
+
expect(resolve).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('returns normal tools when toolsDisabledDepth is 0', () => {
|
|
68
|
+
const toolDefs = [makeToolDef('tool_a'), makeToolDef('tool_b')];
|
|
69
|
+
const ctx = makeCtx();
|
|
70
|
+
const resolve = createResolveToolsCallback(toolDefs, ctx)!;
|
|
71
|
+
|
|
72
|
+
const tools = resolve(EMPTY_HISTORY);
|
|
73
|
+
expect(tools.length).toBeGreaterThanOrEqual(2);
|
|
74
|
+
expect(tools.map((t) => t.name)).toContain('tool_a');
|
|
75
|
+
expect(tools.map((t) => t.name)).toContain('tool_b');
|
|
76
|
+
expect(ctx.allowedToolNames?.size).toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('returns empty tools when toolsDisabledDepth > 0', () => {
|
|
80
|
+
const toolDefs = [makeToolDef('tool_a'), makeToolDef('tool_b')];
|
|
81
|
+
const ctx = makeCtx({ toolsDisabledDepth: 1 });
|
|
82
|
+
const resolve = createResolveToolsCallback(toolDefs, ctx)!;
|
|
83
|
+
|
|
84
|
+
const tools = resolve(EMPTY_HISTORY);
|
|
85
|
+
expect(tools).toEqual([]);
|
|
86
|
+
expect(ctx.allowedToolNames).toEqual(new Set());
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('returns empty tools when toolsDisabledDepth is > 1 (overlapping callers)', () => {
|
|
90
|
+
const toolDefs = [makeToolDef('tool_a')];
|
|
91
|
+
const ctx = makeCtx({ toolsDisabledDepth: 3 });
|
|
92
|
+
const resolve = createResolveToolsCallback(toolDefs, ctx)!;
|
|
93
|
+
|
|
94
|
+
const tools = resolve(EMPTY_HISTORY);
|
|
95
|
+
expect(tools).toEqual([]);
|
|
96
|
+
expect(ctx.allowedToolNames).toEqual(new Set());
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('restores normal tools after depth returns to 0', () => {
|
|
100
|
+
const toolDefs = [makeToolDef('tool_a'), makeToolDef('tool_b')];
|
|
101
|
+
const ctx = makeCtx({ toolsDisabledDepth: 0 });
|
|
102
|
+
const resolve = createResolveToolsCallback(toolDefs, ctx)!;
|
|
103
|
+
|
|
104
|
+
// First call: normal
|
|
105
|
+
let tools = resolve(EMPTY_HISTORY);
|
|
106
|
+
expect(tools.length).toBeGreaterThanOrEqual(2);
|
|
107
|
+
|
|
108
|
+
// Simulate pointer processor incrementing depth
|
|
109
|
+
ctx.toolsDisabledDepth++;
|
|
110
|
+
tools = resolve(EMPTY_HISTORY);
|
|
111
|
+
expect(tools).toEqual([]);
|
|
112
|
+
expect(ctx.allowedToolNames).toEqual(new Set());
|
|
113
|
+
|
|
114
|
+
// Simulate pointer processor decrementing depth (back to 0)
|
|
115
|
+
ctx.toolsDisabledDepth--;
|
|
116
|
+
tools = resolve(EMPTY_HISTORY);
|
|
117
|
+
expect(tools.length).toBeGreaterThanOrEqual(2);
|
|
118
|
+
expect(ctx.allowedToolNames!.has('tool_a')).toBe(true);
|
|
119
|
+
expect(ctx.allowedToolNames!.has('tool_b')).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('overlapping increments keep tools disabled until all decremented', () => {
|
|
123
|
+
const toolDefs = [makeToolDef('tool_a')];
|
|
124
|
+
const ctx = makeCtx();
|
|
125
|
+
const resolve = createResolveToolsCallback(toolDefs, ctx)!;
|
|
126
|
+
|
|
127
|
+
// Two overlapping pointer requests
|
|
128
|
+
ctx.toolsDisabledDepth++;
|
|
129
|
+
ctx.toolsDisabledDepth++;
|
|
130
|
+
expect(resolve(EMPTY_HISTORY)).toEqual([]);
|
|
131
|
+
|
|
132
|
+
// First one finishes
|
|
133
|
+
ctx.toolsDisabledDepth--;
|
|
134
|
+
expect(ctx.toolsDisabledDepth).toBe(1);
|
|
135
|
+
expect(resolve(EMPTY_HISTORY)).toEqual([]);
|
|
136
|
+
|
|
137
|
+
// Second one finishes
|
|
138
|
+
ctx.toolsDisabledDepth--;
|
|
139
|
+
expect(ctx.toolsDisabledDepth).toBe(0);
|
|
140
|
+
const tools = resolve(EMPTY_HISTORY);
|
|
141
|
+
expect(tools.length).toBeGreaterThanOrEqual(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('clears allowedToolNames on every disabled call', () => {
|
|
145
|
+
const toolDefs = [makeToolDef('tool_a')];
|
|
146
|
+
const ctx = makeCtx({ toolsDisabledDepth: 1 });
|
|
147
|
+
const resolve = createResolveToolsCallback(toolDefs, ctx)!;
|
|
148
|
+
|
|
149
|
+
// Pre-populate allowedToolNames as if a previous normal turn set them
|
|
150
|
+
ctx.allowedToolNames = new Set(['tool_a', 'skill_x']);
|
|
151
|
+
|
|
152
|
+
resolve(EMPTY_HISTORY);
|
|
153
|
+
expect(ctx.allowedToolNames).toEqual(new Set());
|
|
154
|
+
});
|
|
155
|
+
});
|