@vellumai/assistant 0.4.1 → 0.4.3
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 +84 -7
- package/bun.lock +0 -83
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +2 -3
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/channel-approval-routes.test.ts +55 -5
- package/src/__tests__/channel-guardian.test.ts +6 -5
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/daemon-server-session-init.test.ts +54 -1
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/non-member-access-request.test.ts +28 -1
- package/src/__tests__/notification-decision-strategy.test.ts +44 -0
- package/src/__tests__/relay-server.test.ts +644 -4
- package/src/__tests__/send-endpoint-busy.test.ts +129 -3
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/approvals/guardian-decision-primitive.ts +24 -2
- package/src/approvals/guardian-request-resolvers.ts +42 -3
- package/src/calls/call-constants.ts +8 -0
- package/src/calls/call-controller.ts +2 -1
- package/src/calls/call-domain.ts +5 -4
- package/src/calls/relay-server.ts +513 -116
- package/src/calls/twilio-routes.ts +3 -5
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
- package/src/config/calls-schema.ts +12 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
- package/src/daemon/handlers/config-channels.ts +5 -7
- package/src/daemon/handlers/config-inbox.ts +2 -0
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +136 -13
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +3 -1
- package/src/daemon/server.ts +19 -3
- package/src/daemon/session-agent-loop.ts +35 -23
- package/src/daemon/session-runtime-assembly.ts +3 -1
- package/src/daemon/session-surfaces.ts +29 -1
- package/src/memory/app-store.ts +6 -0
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/embedding-local.ts +25 -13
- package/src/memory/embedding-runtime-manager.ts +24 -6
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +10 -5
- package/src/notifications/copy-composer.ts +11 -1
- package/src/notifications/emit-signal.ts +2 -1
- package/src/runtime/access-request-helper.ts +11 -3
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/guardian-context-resolver.ts +5 -1
- package/src/runtime/guardian-outbound-actions.ts +5 -4
- package/src/runtime/guardian-reply-router.ts +12 -0
- package/src/runtime/http-server.ts +12 -20
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +2 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +33 -11
- package/src/runtime/routes/events-routes.ts +2 -3
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +16 -4
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/tools/apps/executors.ts +15 -0
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +2 -1
- package/src/workspace/git-service.ts +19 -0
|
@@ -271,7 +271,7 @@ describe('routing invariant: identity checks enforced before decisions', () => {
|
|
|
271
271
|
expect(result.applied).toBe(true);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
-
test('request with no guardian binding
|
|
274
|
+
test('request with no guardian binding rejects non-trusted actor', async () => {
|
|
275
275
|
const req = createCanonicalGuardianRequest({
|
|
276
276
|
kind: 'tool_approval',
|
|
277
277
|
sourceType: 'channel',
|
|
@@ -286,7 +286,9 @@ describe('routing invariant: identity checks enforced before decisions', () => {
|
|
|
286
286
|
actorContext: guardianActor({ externalUserId: 'anyone' }),
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
expect(result.applied).toBe(
|
|
289
|
+
expect(result.applied).toBe(false);
|
|
290
|
+
if (result.applied) return;
|
|
291
|
+
expect(result.reason).toBe('identity_mismatch');
|
|
290
292
|
});
|
|
291
293
|
|
|
292
294
|
test('identity mismatch on code-only message blocks detail leakage', async () => {
|
|
@@ -759,6 +761,45 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
|
|
|
759
761
|
const resolved = getCanonicalGuardianRequest(req.id);
|
|
760
762
|
expect(resolved!.status).toBe('approved');
|
|
761
763
|
});
|
|
764
|
+
|
|
765
|
+
test('code-based routing is constrained to caller-provided pendingRequestIds scope', async () => {
|
|
766
|
+
const inScope = createCanonicalGuardianRequest({
|
|
767
|
+
kind: 'tool_approval',
|
|
768
|
+
sourceType: 'channel',
|
|
769
|
+
conversationId: 'conv-1',
|
|
770
|
+
guardianExternalUserId: 'guardian-1',
|
|
771
|
+
requestCode: '111AAA',
|
|
772
|
+
toolName: 'shell',
|
|
773
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
774
|
+
});
|
|
775
|
+
const outOfScope = createCanonicalGuardianRequest({
|
|
776
|
+
kind: 'tool_approval',
|
|
777
|
+
sourceType: 'channel',
|
|
778
|
+
conversationId: 'conv-2',
|
|
779
|
+
guardianExternalUserId: 'guardian-1',
|
|
780
|
+
requestCode: '222BBB',
|
|
781
|
+
toolName: 'shell',
|
|
782
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
783
|
+
});
|
|
784
|
+
registerPendingToolApprovalInteraction(inScope.id, 'conv-1', 'shell');
|
|
785
|
+
registerPendingToolApprovalInteraction(outOfScope.id, 'conv-2', 'shell');
|
|
786
|
+
|
|
787
|
+
const result = await routeGuardianReply(replyCtx({
|
|
788
|
+
messageText: '222BBB approve',
|
|
789
|
+
conversationId: 'conv-guardian-thread',
|
|
790
|
+
pendingRequestIds: [inScope.id],
|
|
791
|
+
approvalConversationGenerator: undefined,
|
|
792
|
+
}));
|
|
793
|
+
|
|
794
|
+
expect(result.consumed).toBe(false);
|
|
795
|
+
expect(result.type).toBe('not_consumed');
|
|
796
|
+
expect(result.decisionApplied).toBe(false);
|
|
797
|
+
|
|
798
|
+
const inScopeAfter = getCanonicalGuardianRequest(inScope.id);
|
|
799
|
+
const outOfScopeAfter = getCanonicalGuardianRequest(outOfScope.id);
|
|
800
|
+
expect(inScopeAfter!.status).toBe('pending');
|
|
801
|
+
expect(outOfScopeAfter!.status).toBe('pending');
|
|
802
|
+
});
|
|
762
803
|
});
|
|
763
804
|
|
|
764
805
|
// ===========================================================================
|
|
@@ -907,14 +948,14 @@ describe('routing invariant: callback buttons route through canonical primitive'
|
|
|
907
948
|
});
|
|
908
949
|
|
|
909
950
|
// ===========================================================================
|
|
910
|
-
// SECTION 9: Destination
|
|
951
|
+
// SECTION 9: Destination hints do not bypass identity binding for tool_approval
|
|
911
952
|
// ===========================================================================
|
|
912
953
|
|
|
913
|
-
describe('routing invariant: destination hints
|
|
954
|
+
describe('routing invariant: destination hints do not bypass tool_approval identity binding', () => {
|
|
914
955
|
beforeEach(() => resetTables());
|
|
915
956
|
|
|
916
|
-
test('explicit pendingRequestIds
|
|
917
|
-
// Voice-originated
|
|
957
|
+
test('explicit pendingRequestIds still fail closed when guardianExternalUserId is missing', async () => {
|
|
958
|
+
// Voice-originated tool approval with missing guardian identity binding.
|
|
918
959
|
const req = createCanonicalGuardianRequest({
|
|
919
960
|
kind: 'tool_approval',
|
|
920
961
|
sourceType: 'voice',
|
|
@@ -939,11 +980,11 @@ describe('routing invariant: destination hints enable NL approval without guardi
|
|
|
939
980
|
}));
|
|
940
981
|
|
|
941
982
|
expect(result.consumed).toBe(true);
|
|
942
|
-
expect(result.type).toBe('
|
|
943
|
-
expect(result.decisionApplied).toBe(
|
|
983
|
+
expect(result.type).toBe('canonical_decision_stale');
|
|
984
|
+
expect(result.decisionApplied).toBe(false);
|
|
944
985
|
|
|
945
986
|
const resolved = getCanonicalGuardianRequest(req.id);
|
|
946
|
-
expect(resolved!.status).toBe('
|
|
987
|
+
expect(resolved!.status).toBe('pending');
|
|
947
988
|
});
|
|
948
989
|
|
|
949
990
|
test('without destination hints, missing guardianExternalUserId means no pending requests found', async () => {
|
|
@@ -3,7 +3,7 @@ import * as net from 'node:net';
|
|
|
3
3
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
4
4
|
|
|
5
5
|
import type { HandlerContext } from '../daemon/handlers.js';
|
|
6
|
-
import type { UserMessage } from '../daemon/ipc-contract.js';
|
|
6
|
+
import type { ConfirmationResponse, UserMessage } from '../daemon/ipc-contract.js';
|
|
7
7
|
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
8
8
|
import { DebouncerMap } from '../util/debounce.js';
|
|
9
9
|
|
|
@@ -12,8 +12,13 @@ const routeGuardianReplyMock = mock(async () => ({
|
|
|
12
12
|
decisionApplied: false,
|
|
13
13
|
type: 'not_consumed' as const,
|
|
14
14
|
})) as any;
|
|
15
|
+
const createCanonicalGuardianRequestMock = mock(() => ({
|
|
16
|
+
id: 'canonical-id',
|
|
17
|
+
}));
|
|
18
|
+
const generateCanonicalRequestCodeMock = mock(() => 'ABC123');
|
|
15
19
|
const listPendingByDestinationMock = mock(() => [] as Array<{ id: string; kind?: string }>);
|
|
16
20
|
const listCanonicalMock = mock(() => [] as Array<{ id: string }>);
|
|
21
|
+
const resolveCanonicalGuardianRequestMock = mock(() => null as { id: string } | null);
|
|
17
22
|
const getByConversationMock = mock(
|
|
18
23
|
() => [] as Array<{
|
|
19
24
|
requestId: string;
|
|
@@ -21,6 +26,7 @@ const getByConversationMock = mock(
|
|
|
21
26
|
session?: unknown;
|
|
22
27
|
}>,
|
|
23
28
|
);
|
|
29
|
+
const registerMock = mock(() => {});
|
|
24
30
|
const resolveMock = mock(() => undefined as unknown);
|
|
25
31
|
const addMessageMock = mock(async () => ({ id: 'persisted-message-id' }));
|
|
26
32
|
const getConfigMock = mock(() => ({
|
|
@@ -33,11 +39,15 @@ mock.module('../runtime/guardian-reply-router.js', () => ({
|
|
|
33
39
|
}));
|
|
34
40
|
|
|
35
41
|
mock.module('../memory/canonical-guardian-store.js', () => ({
|
|
42
|
+
createCanonicalGuardianRequest: createCanonicalGuardianRequestMock,
|
|
43
|
+
generateCanonicalRequestCode: generateCanonicalRequestCodeMock,
|
|
36
44
|
listPendingCanonicalGuardianRequestsByDestinationConversation: listPendingByDestinationMock,
|
|
37
45
|
listCanonicalGuardianRequests: listCanonicalMock,
|
|
46
|
+
resolveCanonicalGuardianRequest: resolveCanonicalGuardianRequestMock,
|
|
38
47
|
}));
|
|
39
48
|
|
|
40
49
|
mock.module('../runtime/pending-interactions.js', () => ({
|
|
50
|
+
register: registerMock,
|
|
41
51
|
getByConversation: getByConversationMock,
|
|
42
52
|
resolve: resolveMock,
|
|
43
53
|
}));
|
|
@@ -72,7 +82,7 @@ mock.module('../util/logger.js', () => ({
|
|
|
72
82
|
}),
|
|
73
83
|
}));
|
|
74
84
|
|
|
75
|
-
import { handleUserMessage } from '../daemon/handlers/sessions.js';
|
|
85
|
+
import { handleConfirmationResponse, handleUserMessage } from '../daemon/handlers/sessions.js';
|
|
76
86
|
|
|
77
87
|
interface TestSession {
|
|
78
88
|
messages: Array<{ role: string; content: unknown[] }>;
|
|
@@ -151,8 +161,12 @@ function makeSession(overrides: Partial<TestSession> = {}): TestSession {
|
|
|
151
161
|
describe('handleUserMessage pending-confirmation reply interception', () => {
|
|
152
162
|
beforeEach(() => {
|
|
153
163
|
routeGuardianReplyMock.mockClear();
|
|
164
|
+
createCanonicalGuardianRequestMock.mockClear();
|
|
165
|
+
generateCanonicalRequestCodeMock.mockClear();
|
|
154
166
|
listPendingByDestinationMock.mockClear();
|
|
155
167
|
listCanonicalMock.mockClear();
|
|
168
|
+
resolveCanonicalGuardianRequestMock.mockClear();
|
|
169
|
+
registerMock.mockClear();
|
|
156
170
|
getByConversationMock.mockClear();
|
|
157
171
|
resolveMock.mockClear();
|
|
158
172
|
addMessageMock.mockClear();
|
|
@@ -224,6 +238,28 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
|
|
|
224
238
|
expect(requestComplete?.runStillActive).toBe(false);
|
|
225
239
|
});
|
|
226
240
|
|
|
241
|
+
test('consumes decision replies even when queue depth is non-zero', async () => {
|
|
242
|
+
listPendingByDestinationMock.mockReturnValue([{ id: 'req-1', kind: 'tool_approval' }]);
|
|
243
|
+
listCanonicalMock.mockReturnValue([{ id: 'req-1' }]);
|
|
244
|
+
routeGuardianReplyMock.mockResolvedValue({
|
|
245
|
+
consumed: true,
|
|
246
|
+
decisionApplied: true,
|
|
247
|
+
type: 'canonical_decision_applied',
|
|
248
|
+
requestId: 'req-1',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const session = makeSession({
|
|
252
|
+
getQueueDepth: () => 2,
|
|
253
|
+
});
|
|
254
|
+
const { ctx } = createContext(session);
|
|
255
|
+
|
|
256
|
+
await handleUserMessage(makeMessage('approve'), {} as net.Socket, ctx);
|
|
257
|
+
|
|
258
|
+
expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(0);
|
|
260
|
+
expect((session.enqueueMessage as any).mock.calls.length).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
227
263
|
test('does not mutate in-memory history while processing', async () => {
|
|
228
264
|
listPendingByDestinationMock.mockReturnValue([{ id: 'req-1', kind: 'tool_approval' }]);
|
|
229
265
|
listCanonicalMock.mockReturnValue([{ id: 'req-1' }]);
|
|
@@ -314,5 +350,128 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
|
|
|
314
350
|
// session-scoped interaction should be resolved.
|
|
315
351
|
expect(resolveMock).toHaveBeenCalledTimes(1);
|
|
316
352
|
expect(resolveMock).toHaveBeenCalledWith('req-live');
|
|
353
|
+
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledTimes(1);
|
|
354
|
+
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
355
|
+
'req-live',
|
|
356
|
+
'pending',
|
|
357
|
+
{ status: 'denied' },
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('registers IPC confirmation events for NL approval routing', async () => {
|
|
362
|
+
const session = makeSession({
|
|
363
|
+
hasAnyPendingConfirmation: () => false,
|
|
364
|
+
enqueueMessage: mock(() => ({ queued: false, requestId: 'direct-id' })),
|
|
365
|
+
processMessage: async (_content, _attachments, onEvent) => {
|
|
366
|
+
(onEvent as (msg: ServerMessage) => void)({
|
|
367
|
+
type: 'confirmation_request',
|
|
368
|
+
requestId: 'req-confirm-1',
|
|
369
|
+
toolName: 'call_start',
|
|
370
|
+
input: { phone_number: '+18084436762' },
|
|
371
|
+
riskLevel: 'high',
|
|
372
|
+
executionTarget: 'host',
|
|
373
|
+
allowlistOptions: [],
|
|
374
|
+
scopeOptions: [],
|
|
375
|
+
persistentDecisionsAllowed: false,
|
|
376
|
+
} as ServerMessage);
|
|
377
|
+
return 'msg-id';
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
const { ctx, sent } = createContext(session);
|
|
381
|
+
|
|
382
|
+
await handleUserMessage(makeMessage('please call now'), {} as net.Socket, ctx);
|
|
383
|
+
|
|
384
|
+
expect(registerMock).toHaveBeenCalledTimes(1);
|
|
385
|
+
expect(registerMock).toHaveBeenCalledWith(
|
|
386
|
+
'req-confirm-1',
|
|
387
|
+
expect.objectContaining({
|
|
388
|
+
conversationId: 'conv-1',
|
|
389
|
+
kind: 'confirmation',
|
|
390
|
+
session,
|
|
391
|
+
confirmationDetails: expect.objectContaining({
|
|
392
|
+
toolName: 'call_start',
|
|
393
|
+
riskLevel: 'high',
|
|
394
|
+
executionTarget: 'host',
|
|
395
|
+
}),
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
expect(createCanonicalGuardianRequestMock).toHaveBeenCalledTimes(1);
|
|
399
|
+
expect(createCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
400
|
+
expect.objectContaining({
|
|
401
|
+
id: 'req-confirm-1',
|
|
402
|
+
kind: 'tool_approval',
|
|
403
|
+
sourceType: 'desktop',
|
|
404
|
+
sourceChannel: 'vellum',
|
|
405
|
+
conversationId: 'conv-1',
|
|
406
|
+
toolName: 'call_start',
|
|
407
|
+
status: 'pending',
|
|
408
|
+
requestCode: 'ABC123',
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
expect(sent.some((event) => event.type === 'confirmation_request')).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('syncs canonical status to approved for IPC allow decisions', () => {
|
|
415
|
+
const session = {
|
|
416
|
+
hasPendingConfirmation: (requestId: string) => requestId === 'req-confirm-allow',
|
|
417
|
+
handleConfirmationResponse: mock(() => {}),
|
|
418
|
+
};
|
|
419
|
+
const { ctx } = createContext(makeSession());
|
|
420
|
+
ctx.sessions.set('conv-1', session as any);
|
|
421
|
+
|
|
422
|
+
const msg: ConfirmationResponse = {
|
|
423
|
+
type: 'confirmation_response',
|
|
424
|
+
requestId: 'req-confirm-allow',
|
|
425
|
+
decision: 'always_allow',
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
handleConfirmationResponse(msg, {} as net.Socket, ctx);
|
|
429
|
+
|
|
430
|
+
expect((session.handleConfirmationResponse as any).mock.calls.length).toBe(1);
|
|
431
|
+
expect((session.handleConfirmationResponse as any).mock.calls[0]).toEqual([
|
|
432
|
+
'req-confirm-allow',
|
|
433
|
+
'always_allow',
|
|
434
|
+
undefined,
|
|
435
|
+
undefined,
|
|
436
|
+
]);
|
|
437
|
+
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
438
|
+
'req-confirm-allow',
|
|
439
|
+
'pending',
|
|
440
|
+
{ status: 'approved' },
|
|
441
|
+
);
|
|
442
|
+
expect(resolveMock).toHaveBeenCalledWith('req-confirm-allow');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('syncs canonical status to denied for IPC deny decisions in CU sessions', () => {
|
|
446
|
+
const cuSession = {
|
|
447
|
+
hasPendingConfirmation: (requestId: string) => requestId === 'req-confirm-deny',
|
|
448
|
+
handleConfirmationResponse: mock(() => {}),
|
|
449
|
+
};
|
|
450
|
+
const { ctx } = createContext(makeSession({
|
|
451
|
+
hasPendingConfirmation: () => false,
|
|
452
|
+
}));
|
|
453
|
+
ctx.cuSessions.set('cu-1', cuSession as any);
|
|
454
|
+
|
|
455
|
+
const msg: ConfirmationResponse = {
|
|
456
|
+
type: 'confirmation_response',
|
|
457
|
+
requestId: 'req-confirm-deny',
|
|
458
|
+
decision: 'always_deny',
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
handleConfirmationResponse(msg, {} as net.Socket, ctx);
|
|
462
|
+
|
|
463
|
+
expect((cuSession.handleConfirmationResponse as any).mock.calls.length).toBe(1);
|
|
464
|
+
expect((cuSession.handleConfirmationResponse as any).mock.calls[0]).toEqual([
|
|
465
|
+
'req-confirm-deny',
|
|
466
|
+
'always_deny',
|
|
467
|
+
undefined,
|
|
468
|
+
undefined,
|
|
469
|
+
]);
|
|
470
|
+
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
471
|
+
'req-confirm-deny',
|
|
472
|
+
'pending',
|
|
473
|
+
{ status: 'denied' },
|
|
474
|
+
);
|
|
475
|
+
expect(resolveMock).toHaveBeenCalledWith('req-confirm-deny');
|
|
317
476
|
});
|
|
318
477
|
});
|
|
@@ -28,7 +28,6 @@ mock.module('../util/platform.js', () => ({
|
|
|
28
28
|
getDbPath: () => join(testDir, 'test.db'),
|
|
29
29
|
getLogPath: () => join(testDir, 'test.log'),
|
|
30
30
|
ensureDataDir: () => {},
|
|
31
|
-
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
32
31
|
readHttpToken: () => 'test-bearer-token',
|
|
33
32
|
}));
|
|
34
33
|
|
|
@@ -492,6 +492,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
492
492
|
body: JSON.stringify({
|
|
493
493
|
sourceChannel: 'voice',
|
|
494
494
|
expectedExternalUserId: '+15551234567',
|
|
495
|
+
friendName: 'Alice',
|
|
496
|
+
guardianName: 'Bob',
|
|
495
497
|
maxUses: 3,
|
|
496
498
|
}),
|
|
497
499
|
});
|
|
@@ -514,6 +516,9 @@ describe('voice invite HTTP routes', () => {
|
|
|
514
516
|
expect(invite.voiceCodeDigits).toBe(6);
|
|
515
517
|
// expectedExternalUserId should be recorded
|
|
516
518
|
expect(invite.expectedExternalUserId).toBe('+15551234567');
|
|
519
|
+
// friendName and guardianName should be recorded
|
|
520
|
+
expect(invite.friendName).toBe('Alice');
|
|
521
|
+
expect(invite.guardianName).toBe('Bob');
|
|
517
522
|
});
|
|
518
523
|
|
|
519
524
|
test('voice invite creation requires expectedExternalUserId', async () => {
|
|
@@ -522,6 +527,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
522
527
|
headers: { 'Content-Type': 'application/json' },
|
|
523
528
|
body: JSON.stringify({
|
|
524
529
|
sourceChannel: 'voice',
|
|
530
|
+
friendName: 'Alice',
|
|
531
|
+
guardianName: 'Bob',
|
|
525
532
|
}),
|
|
526
533
|
});
|
|
527
534
|
|
|
@@ -540,6 +547,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
540
547
|
body: JSON.stringify({
|
|
541
548
|
sourceChannel: 'voice',
|
|
542
549
|
expectedExternalUserId: 'not-a-phone-number',
|
|
550
|
+
friendName: 'Alice',
|
|
551
|
+
guardianName: 'Bob',
|
|
543
552
|
}),
|
|
544
553
|
});
|
|
545
554
|
|
|
@@ -551,6 +560,44 @@ describe('voice invite HTTP routes', () => {
|
|
|
551
560
|
expect(body.error).toContain('E.164');
|
|
552
561
|
});
|
|
553
562
|
|
|
563
|
+
test('voice invite creation requires friendName', async () => {
|
|
564
|
+
const req = new Request('http://localhost/v1/ingress/invites', {
|
|
565
|
+
method: 'POST',
|
|
566
|
+
headers: { 'Content-Type': 'application/json' },
|
|
567
|
+
body: JSON.stringify({
|
|
568
|
+
sourceChannel: 'voice',
|
|
569
|
+
expectedExternalUserId: '+15551234567',
|
|
570
|
+
guardianName: 'Bob',
|
|
571
|
+
}),
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const res = await handleCreateInvite(req);
|
|
575
|
+
const body = await res.json() as Record<string, unknown>;
|
|
576
|
+
|
|
577
|
+
expect(res.status).toBe(400);
|
|
578
|
+
expect(body.ok).toBe(false);
|
|
579
|
+
expect(body.error).toContain('friendName');
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test('voice invite creation requires guardianName', async () => {
|
|
583
|
+
const req = new Request('http://localhost/v1/ingress/invites', {
|
|
584
|
+
method: 'POST',
|
|
585
|
+
headers: { 'Content-Type': 'application/json' },
|
|
586
|
+
body: JSON.stringify({
|
|
587
|
+
sourceChannel: 'voice',
|
|
588
|
+
expectedExternalUserId: '+15551234567',
|
|
589
|
+
friendName: 'Alice',
|
|
590
|
+
}),
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const res = await handleCreateInvite(req);
|
|
594
|
+
const body = await res.json() as Record<string, unknown>;
|
|
595
|
+
|
|
596
|
+
expect(res.status).toBe(400);
|
|
597
|
+
expect(body.ok).toBe(false);
|
|
598
|
+
expect(body.error).toContain('guardianName');
|
|
599
|
+
});
|
|
600
|
+
|
|
554
601
|
test('voiceCodeDigits is always 6 — custom values are ignored', async () => {
|
|
555
602
|
const req = new Request('http://localhost/v1/ingress/invites', {
|
|
556
603
|
method: 'POST',
|
|
@@ -558,6 +605,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
558
605
|
body: JSON.stringify({
|
|
559
606
|
sourceChannel: 'voice',
|
|
560
607
|
expectedExternalUserId: '+15551234567',
|
|
608
|
+
friendName: 'Alice',
|
|
609
|
+
guardianName: 'Bob',
|
|
561
610
|
voiceCodeDigits: 8,
|
|
562
611
|
}),
|
|
563
612
|
});
|
|
@@ -579,6 +628,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
579
628
|
body: JSON.stringify({
|
|
580
629
|
sourceChannel: 'voice',
|
|
581
630
|
expectedExternalUserId: '+15551234567',
|
|
631
|
+
friendName: 'Alice',
|
|
632
|
+
guardianName: 'Bob',
|
|
582
633
|
}),
|
|
583
634
|
});
|
|
584
635
|
|
|
@@ -600,6 +651,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
600
651
|
body: JSON.stringify({
|
|
601
652
|
sourceChannel: 'voice',
|
|
602
653
|
expectedExternalUserId: '+15551234567',
|
|
654
|
+
friendName: 'Alice',
|
|
655
|
+
guardianName: 'Bob',
|
|
603
656
|
maxUses: 1,
|
|
604
657
|
}),
|
|
605
658
|
}));
|
|
@@ -648,6 +701,8 @@ describe('voice invite HTTP routes', () => {
|
|
|
648
701
|
body: JSON.stringify({
|
|
649
702
|
sourceChannel: 'voice',
|
|
650
703
|
expectedExternalUserId: '+15551234567',
|
|
704
|
+
friendName: 'Alice',
|
|
705
|
+
guardianName: 'Bob',
|
|
651
706
|
maxUses: 1,
|
|
652
707
|
}),
|
|
653
708
|
}));
|
|
@@ -30,7 +30,6 @@ mock.module('../util/platform.js', () => ({
|
|
|
30
30
|
getDbPath: () => join(testDir, 'test.db'),
|
|
31
31
|
getLogPath: () => join(testDir, 'test.log'),
|
|
32
32
|
ensureDataDir: () => {},
|
|
33
|
-
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
34
33
|
readHttpToken: () => 'test-bearer-token',
|
|
35
34
|
}));
|
|
36
35
|
|
|
@@ -437,6 +436,34 @@ describe('access-request-helper unit tests', () => {
|
|
|
437
436
|
expect(payload.guardianBindingChannel).toBe('telegram');
|
|
438
437
|
});
|
|
439
438
|
|
|
439
|
+
test('notifyGuardianOfAccessRequest for voice channel includes senderName in contextPayload', () => {
|
|
440
|
+
const result = notifyGuardianOfAccessRequest({
|
|
441
|
+
canonicalAssistantId: 'self',
|
|
442
|
+
sourceChannel: 'voice',
|
|
443
|
+
externalChatId: '+15559998888',
|
|
444
|
+
senderExternalUserId: '+15559998888',
|
|
445
|
+
senderName: 'Alice Caller',
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(result.notified).toBe(true);
|
|
449
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
450
|
+
|
|
451
|
+
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
452
|
+
expect(payload.sourceChannel).toBe('voice');
|
|
453
|
+
expect(payload.senderName).toBe('Alice Caller');
|
|
454
|
+
expect(payload.senderExternalUserId).toBe('+15559998888');
|
|
455
|
+
expect(payload.senderIdentifier).toBe('Alice Caller');
|
|
456
|
+
|
|
457
|
+
// Canonical request should exist
|
|
458
|
+
const pending = listCanonicalGuardianRequests({
|
|
459
|
+
status: 'pending',
|
|
460
|
+
requesterExternalUserId: '+15559998888',
|
|
461
|
+
sourceChannel: 'voice',
|
|
462
|
+
kind: 'access_request',
|
|
463
|
+
});
|
|
464
|
+
expect(pending.length).toBe(1);
|
|
465
|
+
});
|
|
466
|
+
|
|
440
467
|
test('notifyGuardianOfAccessRequest includes requestCode in contextPayload', () => {
|
|
441
468
|
const result = notifyGuardianOfAccessRequest({
|
|
442
469
|
canonicalAssistantId: 'self',
|
|
@@ -196,6 +196,50 @@ describe('notification decision strategy', () => {
|
|
|
196
196
|
expect(copy.vellum!.body).toContain('open invite flow');
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
test('ingress.access_request template includes caller name for voice-originated requests', () => {
|
|
200
|
+
// In production, senderIdentifier resolves to senderName for voice
|
|
201
|
+
// calls (senderName || senderUsername || senderExternalUserId), so
|
|
202
|
+
// both values are the caller's name. The phone number arrives via
|
|
203
|
+
// senderExternalUserId and should appear in the parenthetical.
|
|
204
|
+
const signal = makeSignal({
|
|
205
|
+
sourceEventName: 'ingress.access_request',
|
|
206
|
+
contextPayload: {
|
|
207
|
+
senderIdentifier: 'Alice Smith',
|
|
208
|
+
senderName: 'Alice Smith',
|
|
209
|
+
senderExternalUserId: '+15559998888',
|
|
210
|
+
sourceChannel: 'voice',
|
|
211
|
+
requestCode: 'V1C2E3',
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
216
|
+
expect(copy.vellum).toBeDefined();
|
|
217
|
+
expect(copy.vellum!.title).toBe('Access Request');
|
|
218
|
+
// Voice-originated requests should include the caller name and phone number in parentheses
|
|
219
|
+
expect(copy.vellum!.body).toContain('Alice Smith');
|
|
220
|
+
expect(copy.vellum!.body).toContain('(+15559998888)');
|
|
221
|
+
expect(copy.vellum!.body).toContain('calling');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('ingress.access_request template falls back to non-voice copy when sourceChannel is not voice', () => {
|
|
225
|
+
const signal = makeSignal({
|
|
226
|
+
sourceEventName: 'ingress.access_request',
|
|
227
|
+
contextPayload: {
|
|
228
|
+
senderIdentifier: 'user-123',
|
|
229
|
+
senderName: 'Bob Jones',
|
|
230
|
+
sourceChannel: 'telegram',
|
|
231
|
+
requestCode: 'T1G2M3',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
236
|
+
expect(copy.vellum).toBeDefined();
|
|
237
|
+
// Non-voice should use the standard "requesting access" text, not "calling"
|
|
238
|
+
expect(copy.vellum!.body).toContain('user-123');
|
|
239
|
+
expect(copy.vellum!.body).toContain('requesting access');
|
|
240
|
+
expect(copy.vellum!.body).not.toContain('calling');
|
|
241
|
+
});
|
|
242
|
+
|
|
199
243
|
test('ingress.access_request Telegram deliveryText is concise', () => {
|
|
200
244
|
const signal = makeSignal({
|
|
201
245
|
sourceEventName: 'ingress.access_request',
|