@vellumai/assistant 0.4.0 → 0.4.2

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.
@@ -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
  });