@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.
@@ -14,6 +14,8 @@ import { afterAll, beforeEach, describe, expect, mock,test } from 'bun:test';
14
14
 
15
15
  import type { ServerMessage } from '../daemon/ipc-protocol.js';
16
16
  import type { Session } from '../daemon/session.js';
17
+ import { createCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
18
+ import { getOrCreateConversation } from '../memory/conversation-key-store.js';
17
19
 
18
20
  const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'send-endpoint-busy-test-')));
19
21
 
@@ -53,6 +55,8 @@ import { getDb, initializeDb, resetDb } from '../memory/db.js';
53
55
  import type { AssistantEvent } from '../runtime/assistant-event.js';
54
56
  import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
55
57
  import { RuntimeHttpServer } from '../runtime/http-server.js';
58
+ import type { ApprovalConversationGenerator } from '../runtime/http-types.js';
59
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
56
60
 
57
61
  initializeDb();
58
62
 
@@ -63,6 +67,7 @@ initializeDb();
63
67
  /** Session that completes its agent loop quickly and emits a text delta + message_complete. */
64
68
  function makeCompletingSession(): Session {
65
69
  let processing = false;
70
+ const messages: unknown[] = [];
66
71
  return {
67
72
  isProcessing: () => processing,
68
73
  persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
@@ -77,6 +82,10 @@ function makeCompletingSession(): Session {
77
82
  setTurnChannelContext: () => {},
78
83
  setTurnInterfaceContext: () => {},
79
84
  updateClient: () => {},
85
+ hasAnyPendingConfirmation: () => false,
86
+ hasPendingConfirmation: () => false,
87
+ denyAllPendingConfirmations: () => {},
88
+ getQueueDepth: () => 0,
80
89
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
81
90
  runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
82
91
  onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
@@ -85,13 +94,14 @@ function makeCompletingSession(): Session {
85
94
  },
86
95
  handleConfirmationResponse: () => {},
87
96
  handleSecretResponse: () => {},
88
- hasAnyPendingConfirmation: () => false,
97
+ getMessages: () => messages as never[],
89
98
  } as unknown as Session;
90
99
  }
91
100
 
92
101
  /** Session that hangs forever in the agent loop (simulates a busy session). */
93
102
  function makeHangingSession(): Session {
94
103
  let processing = false;
104
+ const messages: unknown[] = [];
95
105
  const enqueuedMessages: Array<{ content: string; onEvent: (msg: ServerMessage) => void; requestId: string }> = [];
96
106
  return {
97
107
  isProcessing: () => processing,
@@ -107,6 +117,10 @@ function makeHangingSession(): Session {
107
117
  setTurnChannelContext: () => {},
108
118
  setTurnInterfaceContext: () => {},
109
119
  updateClient: () => {},
120
+ hasAnyPendingConfirmation: () => false,
121
+ hasPendingConfirmation: () => false,
122
+ denyAllPendingConfirmations: () => {},
123
+ getQueueDepth: () => enqueuedMessages.length,
110
124
  enqueueMessage: (content: string, _attachments: unknown[], onEvent: (msg: ServerMessage) => void, requestId: string) => {
111
125
  enqueuedMessages.push({ content, onEvent, requestId });
112
126
  return { queued: true, requestId };
@@ -117,11 +131,68 @@ function makeHangingSession(): Session {
117
131
  },
118
132
  handleConfirmationResponse: () => {},
119
133
  handleSecretResponse: () => {},
120
- hasAnyPendingConfirmation: () => false,
134
+ getMessages: () => messages as never[],
121
135
  _enqueuedMessages: enqueuedMessages,
122
136
  } as unknown as Session;
123
137
  }
124
138
 
139
+ function makePendingApprovalSession(
140
+ requestId: string,
141
+ processing: boolean,
142
+ options?: { queueDepth?: number },
143
+ ): {
144
+ session: Session;
145
+ runAgentLoopMock: ReturnType<typeof mock>;
146
+ enqueueMessageMock: ReturnType<typeof mock>;
147
+ denyAllPendingConfirmationsMock: ReturnType<typeof mock>;
148
+ handleConfirmationResponseMock: ReturnType<typeof mock>;
149
+ } {
150
+ const queueDepth = options?.queueDepth ?? 0;
151
+ const pending = new Set([requestId]);
152
+ const messages: unknown[] = [];
153
+ const runAgentLoopMock = mock(async () => {});
154
+ const enqueueMessageMock = mock((_content: string, _attachments: unknown[], _onEvent: (msg: ServerMessage) => void, queuedRequestId: string) => ({
155
+ queued: true,
156
+ requestId: queuedRequestId,
157
+ }));
158
+ const denyAllPendingConfirmationsMock = mock(() => {
159
+ pending.clear();
160
+ });
161
+ const handleConfirmationResponseMock = mock((resolvedRequestId: string) => {
162
+ pending.delete(resolvedRequestId);
163
+ });
164
+
165
+ const session = {
166
+ isProcessing: () => processing,
167
+ persistUserMessage: (_content: string, _attachments: unknown[], reqId?: string) => reqId ?? 'msg-1',
168
+ memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
169
+ setChannelCapabilities: () => {},
170
+ setAssistantId: () => {},
171
+ setGuardianContext: () => {},
172
+ setCommandIntent: () => {},
173
+ setTurnChannelContext: () => {},
174
+ setTurnInterfaceContext: () => {},
175
+ updateClient: () => {},
176
+ hasAnyPendingConfirmation: () => pending.size > 0,
177
+ hasPendingConfirmation: (candidateRequestId: string) => pending.has(candidateRequestId),
178
+ denyAllPendingConfirmations: denyAllPendingConfirmationsMock,
179
+ getQueueDepth: () => queueDepth,
180
+ enqueueMessage: enqueueMessageMock,
181
+ runAgentLoop: runAgentLoopMock,
182
+ handleConfirmationResponse: handleConfirmationResponseMock,
183
+ handleSecretResponse: () => {},
184
+ getMessages: () => messages as never[],
185
+ } as unknown as Session;
186
+
187
+ return {
188
+ session,
189
+ runAgentLoopMock,
190
+ enqueueMessageMock,
191
+ denyAllPendingConfirmationsMock,
192
+ handleConfirmationResponseMock,
193
+ };
194
+ }
195
+
125
196
  // ---------------------------------------------------------------------------
126
197
  // Tests
127
198
  // ---------------------------------------------------------------------------
@@ -139,6 +210,9 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
139
210
  db.run('DELETE FROM messages');
140
211
  db.run('DELETE FROM conversations');
141
212
  db.run('DELETE FROM conversation_keys');
213
+ db.run('DELETE FROM canonical_guardian_deliveries');
214
+ db.run('DELETE FROM canonical_guardian_requests');
215
+ pendingInteractions.clear();
142
216
  eventHub = new AssistantEventHub();
143
217
  });
144
218
 
@@ -147,11 +221,15 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
147
221
  try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
148
222
  });
149
223
 
150
- async function startServer(sessionFactory: () => Session): Promise<void> {
224
+ async function startServer(
225
+ sessionFactory: () => Session,
226
+ options?: { approvalConversationGenerator?: ApprovalConversationGenerator },
227
+ ): Promise<void> {
151
228
  port = 19000 + Math.floor(Math.random() * 1000);
152
229
  server = new RuntimeHttpServer({
153
230
  port,
154
231
  bearerToken: TEST_TOKEN,
232
+ approvalConversationGenerator: options?.approvalConversationGenerator,
155
233
  sendMessageDeps: {
156
234
  getOrCreateSession: async () => sessionFactory(),
157
235
  assistantEventHub: eventHub,
@@ -226,6 +304,338 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
226
304
  await stopServer();
227
305
  });
228
306
 
307
+ test('consumes explicit approval text when a single pending confirmation exists (idle)', async () => {
308
+ const conversationKey = 'conv-inline-idle';
309
+ const { conversationId } = getOrCreateConversation(conversationKey);
310
+ const requestId = 'req-inline-idle';
311
+ const {
312
+ session,
313
+ runAgentLoopMock,
314
+ enqueueMessageMock,
315
+ denyAllPendingConfirmationsMock,
316
+ handleConfirmationResponseMock,
317
+ } = makePendingApprovalSession(requestId, false);
318
+
319
+ pendingInteractions.register(requestId, {
320
+ session,
321
+ conversationId,
322
+ kind: 'confirmation',
323
+ });
324
+ createCanonicalGuardianRequest({
325
+ id: requestId,
326
+ kind: 'tool_approval',
327
+ sourceType: 'desktop',
328
+ sourceChannel: 'vellum',
329
+ conversationId,
330
+ toolName: 'call_start',
331
+ status: 'pending',
332
+ requestCode: 'ABC123',
333
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
334
+ });
335
+
336
+ await startServer(() => session);
337
+
338
+ const res = await fetch(messagesUrl(), {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
341
+ body: JSON.stringify({
342
+ conversationKey,
343
+ content: 'yes',
344
+ sourceChannel: 'vellum',
345
+ interface: 'macos',
346
+ }),
347
+ });
348
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
349
+
350
+ expect(res.status).toBe(202);
351
+ expect(body.accepted).toBe(true);
352
+ expect(body.messageId).toBeDefined();
353
+ expect(body.queued).toBeUndefined();
354
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
355
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
356
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
357
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
358
+
359
+ await stopServer();
360
+ });
361
+
362
+ test('consumes natural-language approval text when approval conversation generator is configured', async () => {
363
+ const conversationKey = 'conv-inline-nl';
364
+ const { conversationId } = getOrCreateConversation(conversationKey);
365
+ const requestId = 'req-inline-nl';
366
+ const {
367
+ session,
368
+ runAgentLoopMock,
369
+ enqueueMessageMock,
370
+ denyAllPendingConfirmationsMock,
371
+ handleConfirmationResponseMock,
372
+ } = makePendingApprovalSession(requestId, false);
373
+
374
+ pendingInteractions.register(requestId, {
375
+ session,
376
+ conversationId,
377
+ kind: 'confirmation',
378
+ });
379
+ createCanonicalGuardianRequest({
380
+ id: requestId,
381
+ kind: 'tool_approval',
382
+ sourceType: 'desktop',
383
+ sourceChannel: 'vellum',
384
+ conversationId,
385
+ toolName: 'call_start',
386
+ status: 'pending',
387
+ requestCode: 'C0FFEE',
388
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
389
+ });
390
+
391
+ const approvalConversationGenerator: ApprovalConversationGenerator = async (context) => ({
392
+ disposition: 'approve_once',
393
+ replyText: 'Approved.',
394
+ targetRequestId: context.pendingApprovals[0]?.requestId,
395
+ });
396
+
397
+ await startServer(() => session, { approvalConversationGenerator });
398
+
399
+ const res = await fetch(messagesUrl(), {
400
+ method: 'POST',
401
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
402
+ body: JSON.stringify({
403
+ conversationKey,
404
+ content: "sure let's do that",
405
+ sourceChannel: 'vellum',
406
+ interface: 'macos',
407
+ }),
408
+ });
409
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
410
+
411
+ expect(res.status).toBe(202);
412
+ expect(body.accepted).toBe(true);
413
+ expect(body.messageId).toBeDefined();
414
+ expect(body.queued).toBeUndefined();
415
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
416
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
417
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
418
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
419
+
420
+ await stopServer();
421
+ });
422
+
423
+ test('consumes explicit approval text while busy instead of auto-denying and queueing', async () => {
424
+ const conversationKey = 'conv-inline-busy';
425
+ const { conversationId } = getOrCreateConversation(conversationKey);
426
+ const requestId = 'req-inline-busy';
427
+ const {
428
+ session,
429
+ runAgentLoopMock,
430
+ enqueueMessageMock,
431
+ denyAllPendingConfirmationsMock,
432
+ handleConfirmationResponseMock,
433
+ } = makePendingApprovalSession(requestId, true);
434
+
435
+ pendingInteractions.register(requestId, {
436
+ session,
437
+ conversationId,
438
+ kind: 'confirmation',
439
+ });
440
+ createCanonicalGuardianRequest({
441
+ id: requestId,
442
+ kind: 'tool_approval',
443
+ sourceType: 'desktop',
444
+ sourceChannel: 'vellum',
445
+ conversationId,
446
+ toolName: 'call_start',
447
+ status: 'pending',
448
+ requestCode: 'DEF456',
449
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
450
+ });
451
+
452
+ await startServer(() => session);
453
+
454
+ const res = await fetch(messagesUrl(), {
455
+ method: 'POST',
456
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
457
+ body: JSON.stringify({
458
+ conversationKey,
459
+ content: 'approve',
460
+ sourceChannel: 'vellum',
461
+ interface: 'macos',
462
+ }),
463
+ });
464
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
465
+
466
+ expect(res.status).toBe(202);
467
+ expect(body.accepted).toBe(true);
468
+ expect(body.messageId).toBeDefined();
469
+ expect(body.queued).toBeUndefined();
470
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
471
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
472
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
473
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
474
+
475
+ await stopServer();
476
+ });
477
+
478
+ test('consumes explicit approval text while busy even when queue depth is non-zero', async () => {
479
+ const conversationKey = 'conv-inline-busy-queued';
480
+ const { conversationId } = getOrCreateConversation(conversationKey);
481
+ const requestId = 'req-inline-busy-queued';
482
+ const {
483
+ session,
484
+ runAgentLoopMock,
485
+ enqueueMessageMock,
486
+ denyAllPendingConfirmationsMock,
487
+ handleConfirmationResponseMock,
488
+ } = makePendingApprovalSession(requestId, true, { queueDepth: 2 });
489
+
490
+ pendingInteractions.register(requestId, {
491
+ session,
492
+ conversationId,
493
+ kind: 'confirmation',
494
+ });
495
+ createCanonicalGuardianRequest({
496
+ id: requestId,
497
+ kind: 'tool_approval',
498
+ sourceType: 'desktop',
499
+ sourceChannel: 'vellum',
500
+ conversationId,
501
+ toolName: 'call_start',
502
+ status: 'pending',
503
+ requestCode: 'Q2D456',
504
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
505
+ });
506
+
507
+ await startServer(() => session);
508
+
509
+ const res = await fetch(messagesUrl(), {
510
+ method: 'POST',
511
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
512
+ body: JSON.stringify({
513
+ conversationKey,
514
+ content: 'approve',
515
+ sourceChannel: 'vellum',
516
+ interface: 'macos',
517
+ }),
518
+ });
519
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
520
+
521
+ expect(res.status).toBe(202);
522
+ expect(body.accepted).toBe(true);
523
+ expect(body.messageId).toBeDefined();
524
+ expect(body.queued).toBeUndefined();
525
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
526
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
527
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
528
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
529
+
530
+ await stopServer();
531
+ });
532
+
533
+ test('consumes explicit rejection text when a single pending confirmation exists (idle)', async () => {
534
+ const conversationKey = 'conv-inline-reject';
535
+ const { conversationId } = getOrCreateConversation(conversationKey);
536
+ const requestId = 'req-inline-reject';
537
+ const {
538
+ session,
539
+ runAgentLoopMock,
540
+ enqueueMessageMock,
541
+ denyAllPendingConfirmationsMock,
542
+ handleConfirmationResponseMock,
543
+ } = makePendingApprovalSession(requestId, false);
544
+
545
+ pendingInteractions.register(requestId, {
546
+ session,
547
+ conversationId,
548
+ kind: 'confirmation',
549
+ });
550
+ createCanonicalGuardianRequest({
551
+ id: requestId,
552
+ kind: 'tool_approval',
553
+ sourceType: 'desktop',
554
+ sourceChannel: 'vellum',
555
+ conversationId,
556
+ toolName: 'call_start',
557
+ status: 'pending',
558
+ requestCode: 'GHI789',
559
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
560
+ });
561
+
562
+ await startServer(() => session);
563
+
564
+ const res = await fetch(messagesUrl(), {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
567
+ body: JSON.stringify({
568
+ conversationKey,
569
+ content: 'no',
570
+ sourceChannel: 'vellum',
571
+ interface: 'macos',
572
+ }),
573
+ });
574
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
575
+
576
+ expect(res.status).toBe(202);
577
+ expect(body.accepted).toBe(true);
578
+ expect(body.messageId).toBeDefined();
579
+ expect(body.queued).toBeUndefined();
580
+ // Rejection still flows through handleConfirmationResponse (with reject action)
581
+ expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
582
+ expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
583
+ expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
584
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
585
+
586
+ await stopServer();
587
+ });
588
+
589
+ test('does not consume ambiguous text — falls through to normal message handling', async () => {
590
+ const conversationKey = 'conv-inline-ambiguous';
591
+ const { conversationId } = getOrCreateConversation(conversationKey);
592
+ const requestId = 'req-inline-ambiguous';
593
+ const {
594
+ session,
595
+ runAgentLoopMock,
596
+ } = makePendingApprovalSession(requestId, false);
597
+
598
+ pendingInteractions.register(requestId, {
599
+ session,
600
+ conversationId,
601
+ kind: 'confirmation',
602
+ });
603
+ createCanonicalGuardianRequest({
604
+ id: requestId,
605
+ kind: 'tool_approval',
606
+ sourceType: 'desktop',
607
+ sourceChannel: 'vellum',
608
+ conversationId,
609
+ toolName: 'call_start',
610
+ status: 'pending',
611
+ requestCode: 'JKL012',
612
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
613
+ });
614
+
615
+ await startServer(() => session);
616
+
617
+ const res = await fetch(messagesUrl(), {
618
+ method: 'POST',
619
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
620
+ body: JSON.stringify({
621
+ conversationKey,
622
+ content: 'What is the weather today?',
623
+ sourceChannel: 'vellum',
624
+ interface: 'macos',
625
+ }),
626
+ });
627
+ const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
628
+
629
+ // Ambiguous text should NOT be consumed — falls through to normal send path
630
+ expect(res.status).toBe(202);
631
+ expect(body.accepted).toBe(true);
632
+ expect(body.messageId).toBeDefined();
633
+ // The normal idle send path fires runAgentLoop
634
+ expect(runAgentLoopMock).toHaveBeenCalledTimes(1);
635
+
636
+ await stopServer();
637
+ });
638
+
229
639
  // ── Busy session: queue-if-busy ─────────────────────────────────────
230
640
 
231
641
  test('returns 202 with queued: true when session is busy (not 409)', async () => {
@@ -349,7 +349,28 @@ export async function applyCanonicalGuardianDecision(
349
349
  }
350
350
 
351
351
  // 2c. Validate identity: actor must match guardian_external_user_id
352
- // unless the actor is trusted (desktop) or the request has no guardian binding.
352
+ // unless the actor is trusted (desktop).
353
+ //
354
+ // Channel tool-approval requests must always be identity-bound. Treat
355
+ // missing guardianExternalUserId as unauthorized (fail-closed) so a
356
+ // non-guardian actor can never approve an unbound request.
357
+ if (
358
+ !actorContext.isTrusted &&
359
+ request.kind === 'tool_approval' &&
360
+ !request.guardianExternalUserId
361
+ ) {
362
+ log.warn(
363
+ {
364
+ event: 'canonical_decision_missing_guardian_binding',
365
+ requestId,
366
+ kind: request.kind,
367
+ sourceType: request.sourceType,
368
+ },
369
+ 'Canonical tool approval missing guardian binding; rejecting decision',
370
+ );
371
+ return { applied: false, reason: 'identity_mismatch', detail: 'missing guardian binding' };
372
+ }
373
+
353
374
  if (
354
375
  request.guardianExternalUserId &&
355
376
  !actorContext.isTrusted &&