@vellumai/assistant 0.4.1 → 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
  });
@@ -55,6 +55,7 @@ import { getDb, initializeDb, resetDb } from '../memory/db.js';
55
55
  import type { AssistantEvent } from '../runtime/assistant-event.js';
56
56
  import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
57
57
  import { RuntimeHttpServer } from '../runtime/http-server.js';
58
+ import type { ApprovalConversationGenerator } from '../runtime/http-types.js';
58
59
  import * as pendingInteractions from '../runtime/pending-interactions.js';
59
60
 
60
61
  initializeDb();
@@ -135,13 +136,18 @@ function makeHangingSession(): Session {
135
136
  } as unknown as Session;
136
137
  }
137
138
 
138
- function makePendingApprovalSession(requestId: string, processing: boolean): {
139
+ function makePendingApprovalSession(
140
+ requestId: string,
141
+ processing: boolean,
142
+ options?: { queueDepth?: number },
143
+ ): {
139
144
  session: Session;
140
145
  runAgentLoopMock: ReturnType<typeof mock>;
141
146
  enqueueMessageMock: ReturnType<typeof mock>;
142
147
  denyAllPendingConfirmationsMock: ReturnType<typeof mock>;
143
148
  handleConfirmationResponseMock: ReturnType<typeof mock>;
144
149
  } {
150
+ const queueDepth = options?.queueDepth ?? 0;
145
151
  const pending = new Set([requestId]);
146
152
  const messages: unknown[] = [];
147
153
  const runAgentLoopMock = mock(async () => {});
@@ -170,7 +176,7 @@ function makePendingApprovalSession(requestId: string, processing: boolean): {
170
176
  hasAnyPendingConfirmation: () => pending.size > 0,
171
177
  hasPendingConfirmation: (candidateRequestId: string) => pending.has(candidateRequestId),
172
178
  denyAllPendingConfirmations: denyAllPendingConfirmationsMock,
173
- getQueueDepth: () => 0,
179
+ getQueueDepth: () => queueDepth,
174
180
  enqueueMessage: enqueueMessageMock,
175
181
  runAgentLoop: runAgentLoopMock,
176
182
  handleConfirmationResponse: handleConfirmationResponseMock,
@@ -215,11 +221,15 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
215
221
  try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
216
222
  });
217
223
 
218
- async function startServer(sessionFactory: () => Session): Promise<void> {
224
+ async function startServer(
225
+ sessionFactory: () => Session,
226
+ options?: { approvalConversationGenerator?: ApprovalConversationGenerator },
227
+ ): Promise<void> {
219
228
  port = 19000 + Math.floor(Math.random() * 1000);
220
229
  server = new RuntimeHttpServer({
221
230
  port,
222
231
  bearerToken: TEST_TOKEN,
232
+ approvalConversationGenerator: options?.approvalConversationGenerator,
223
233
  sendMessageDeps: {
224
234
  getOrCreateSession: async () => sessionFactory(),
225
235
  assistantEventHub: eventHub,
@@ -349,6 +359,67 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
349
359
  await stopServer();
350
360
  });
351
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
+
352
423
  test('consumes explicit approval text while busy instead of auto-denying and queueing', async () => {
353
424
  const conversationKey = 'conv-inline-busy';
354
425
  const { conversationId } = getOrCreateConversation(conversationKey);
@@ -404,6 +475,61 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
404
475
  await stopServer();
405
476
  });
406
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
+
407
533
  test('consumes explicit rejection text when a single pending confirmation exists (idle)', async () => {
408
534
  const conversationKey = 'conv-inline-reject';
409
535
  const { conversationId } = getOrCreateConversation(conversationKey);
@@ -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 &&
@@ -7,8 +7,11 @@ import { type InterfaceId,isChannelId, parseChannelId, parseInterfaceId } from '
7
7
  import { getConfig } from '../../config/loader.js';
8
8
  import { getAttachmentsForMessage, getFilePathForAttachment, setAttachmentThumbnail } from '../../memory/attachments-store.js';
9
9
  import {
10
+ createCanonicalGuardianRequest,
11
+ generateCanonicalRequestCode,
10
12
  listCanonicalGuardianRequests,
11
13
  listPendingCanonicalGuardianRequestsByDestinationConversation,
14
+ resolveCanonicalGuardianRequest,
12
15
  } from '../../memory/canonical-guardian-store.js';
13
16
  import { getAttentionStateByConversationIds } from '../../memory/conversation-attention-store.js';
14
17
  import * as conversationStore from '../../memory/conversation-store.js';
@@ -47,6 +50,7 @@ import { normalizeThreadType } from '../ipc-protocol.js';
47
50
  import { executeRecordingIntent } from '../recording-executor.js';
48
51
  import { resolveRecordingIntent } from '../recording-intent.js';
49
52
  import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
53
+ import type { Session } from '../session.js';
50
54
  import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
51
55
  import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
52
56
  import { generateVideoThumbnail } from '../video-thumbnail.js';
@@ -66,6 +70,86 @@ import {
66
70
 
67
71
  const desktopApprovalConversationGenerator = createApprovalConversationGenerator();
68
72
 
73
+ function syncCanonicalStatusFromIpcConfirmationDecision(
74
+ requestId: string,
75
+ decision: ConfirmationResponse['decision'],
76
+ ): void {
77
+ const targetStatus = decision === 'deny' || decision === 'always_deny'
78
+ ? 'denied' as const
79
+ : 'approved' as const;
80
+
81
+ try {
82
+ resolveCanonicalGuardianRequest(requestId, 'pending', { status: targetStatus });
83
+ } catch (err) {
84
+ log.debug(
85
+ { err, requestId, targetStatus },
86
+ 'Failed to resolve canonical request from IPC confirmation response',
87
+ );
88
+ }
89
+ }
90
+
91
+ function makeIpcEventSender(params: {
92
+ ctx: HandlerContext;
93
+ socket: net.Socket;
94
+ session: Session;
95
+ conversationId: string;
96
+ sourceChannel: string;
97
+ }): (event: ServerMessage) => void {
98
+ const {
99
+ ctx,
100
+ socket,
101
+ session,
102
+ conversationId,
103
+ sourceChannel,
104
+ } = params;
105
+
106
+ return (event: ServerMessage) => {
107
+ if (event.type === 'confirmation_request') {
108
+ pendingInteractions.register(event.requestId, {
109
+ session,
110
+ conversationId,
111
+ kind: 'confirmation',
112
+ confirmationDetails: {
113
+ toolName: event.toolName,
114
+ input: event.input,
115
+ riskLevel: event.riskLevel,
116
+ executionTarget: event.executionTarget,
117
+ allowlistOptions: event.allowlistOptions,
118
+ scopeOptions: event.scopeOptions,
119
+ persistentDecisionsAllowed: event.persistentDecisionsAllowed,
120
+ },
121
+ });
122
+
123
+ try {
124
+ createCanonicalGuardianRequest({
125
+ id: event.requestId,
126
+ kind: 'tool_approval',
127
+ sourceType: 'desktop',
128
+ sourceChannel,
129
+ conversationId,
130
+ toolName: event.toolName,
131
+ status: 'pending',
132
+ requestCode: generateCanonicalRequestCode(),
133
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
134
+ });
135
+ } catch (err) {
136
+ log.debug(
137
+ { err, requestId: event.requestId, conversationId },
138
+ 'Failed to create canonical request from IPC confirmation event',
139
+ );
140
+ }
141
+ } else if (event.type === 'secret_request') {
142
+ pendingInteractions.register(event.requestId, {
143
+ session,
144
+ conversationId,
145
+ kind: 'secret',
146
+ });
147
+ }
148
+
149
+ ctx.send(socket, event);
150
+ };
151
+ }
152
+
69
153
  export async function handleUserMessage(
70
154
  msg: UserMessage,
71
155
  socket: net.Socket,
@@ -83,8 +167,14 @@ export async function handleUserMessage(
83
167
  wireEscalationHandler(session, socket, ctx);
84
168
  }
85
169
 
86
- const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
87
170
  const ipcChannel = parseChannelId(msg.channel) ?? 'vellum';
171
+ const sendEvent = makeIpcEventSender({
172
+ ctx,
173
+ socket,
174
+ session,
175
+ conversationId: msg.sessionId,
176
+ sourceChannel: ipcChannel,
177
+ });
88
178
  const ipcInterface = parseInterfaceId(msg.interface);
89
179
  if (!ipcInterface) {
90
180
  ctx.send(socket, {
@@ -461,11 +551,13 @@ export async function handleUserMessage(
461
551
  }
462
552
  }
463
553
 
464
- // If exactly one live turn is waiting on confirmation (no queued turns),
465
- // try to consume this text as an inline approval decision first.
554
+ // If a live turn is waiting on confirmation, try to consume this text as
555
+ // an inline approval decision before auto-deny. We intentionally do not
556
+ // gate on queue depth: users often retry "approve"/"yes" while the queue
557
+ // is draining after a prior denial, and requiring an empty queue causes a
558
+ // deny/retry cascade where natural-language approvals never land.
466
559
  if (
467
560
  session.hasAnyPendingConfirmation()
468
- && session.getQueueDepth() === 0
469
561
  && messageText.trim().length > 0
470
562
  ) {
471
563
  try {
@@ -598,6 +690,7 @@ export async function handleUserMessage(
598
690
  // stale request IDs are not reused as routing candidates.
599
691
  for (const interaction of pendingInteractions.getByConversation(msg.sessionId)) {
600
692
  if (interaction.session === session && interaction.kind === 'confirmation') {
693
+ syncCanonicalStatusFromIpcConfirmationDecision(interaction.requestId, 'deny');
601
694
  pendingInteractions.resolve(interaction.requestId);
602
695
  }
603
696
  }
@@ -638,6 +731,8 @@ export function handleConfirmationResponse(
638
731
  msg.selectedPattern,
639
732
  msg.selectedScope,
640
733
  );
734
+ syncCanonicalStatusFromIpcConfirmationDecision(msg.requestId, msg.decision);
735
+ pendingInteractions.resolve(msg.requestId);
641
736
  return;
642
737
  }
643
738
  }
@@ -651,6 +746,8 @@ export function handleConfirmationResponse(
651
746
  msg.selectedPattern,
652
747
  msg.selectedScope,
653
748
  );
749
+ syncCanonicalStatusFromIpcConfirmationDecision(msg.requestId, msg.decision);
750
+ pendingInteractions.resolve(msg.requestId);
654
751
  return;
655
752
  }
656
753
  }
@@ -670,6 +767,7 @@ export function handleSecretResponse(
670
767
  clearTimeout(standalone.timer);
671
768
  pendingStandaloneSecrets.delete(msg.requestId);
672
769
  standalone.resolve({ value: msg.value ?? null, delivery: msg.delivery ?? 'store' });
770
+ pendingInteractions.resolve(msg.requestId);
673
771
  return;
674
772
  }
675
773
 
@@ -680,6 +778,7 @@ export function handleSecretResponse(
680
778
  if (session.hasPendingSecret(msg.requestId)) {
681
779
  ctx.touchSession(sessionId);
682
780
  session.handleSecretResponse(msg.requestId, msg.value, msg.delivery);
781
+ pendingInteractions.resolve(msg.requestId);
683
782
  return;
684
783
  }
685
784
  }
@@ -780,11 +879,11 @@ export async function handleSessionCreate(
780
879
 
781
880
  // Auto-send the initial message if provided, kick-starting the skill.
782
881
  if (msg.initialMessage) {
783
- // Queue title generation immediately (matches all other creation paths).
784
- // The agent loop success path will also attempt title generation, but
785
- // queueGenerateConversationTitle is safe to call redundantly the
786
- // replaceability check prevents double-writes. This ensures the title
787
- // is generated even if the agent loop fails or is cancelled.
882
+ // Queue title generation eagerly some processMessage paths (guardian
883
+ // replies, unknown slash commands) bypass the agent loop entirely, so
884
+ // we can't rely on the agent loop's early title generation alone.
885
+ // The agent loop also queues title generation, but isReplaceableTitle
886
+ // prevents double-writes since the first to complete sets a real title.
788
887
  if (title === GENERATING_TITLE) {
789
888
  queueGenerateConversationTitle({
790
889
  conversationId: conversation.id,
@@ -801,9 +900,15 @@ export async function handleSessionCreate(
801
900
  }
802
901
 
803
902
  ctx.socketToSession.set(socket, conversation.id);
804
- const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
805
903
  const requestId = uuid();
806
904
  const transportChannel = parseChannelId(msg.transport?.channelId) ?? 'vellum';
905
+ const sendEvent = makeIpcEventSender({
906
+ ctx,
907
+ socket,
908
+ session,
909
+ conversationId: conversation.id,
910
+ sourceChannel: transportChannel,
911
+ });
807
912
  session.setTurnChannelContext({
808
913
  userMessageChannel: transportChannel,
809
914
  assistantMessageChannel: transportChannel,
@@ -1136,7 +1241,16 @@ export async function handleRegenerate(
1136
1241
  }
1137
1242
  ctx.touchSession(msg.sessionId);
1138
1243
 
1139
- const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
1244
+ const regenerateChannel = parseChannelId(
1245
+ session.getTurnChannelContext()?.assistantMessageChannel,
1246
+ ) ?? 'vellum';
1247
+ const sendEvent = makeIpcEventSender({
1248
+ ctx,
1249
+ socket,
1250
+ session,
1251
+ conversationId: msg.sessionId,
1252
+ sourceChannel: regenerateChannel,
1253
+ });
1140
1254
  const requestId = uuid();
1141
1255
  session.traceEmitter.emit('request_received', 'Regenerate requested', {
1142
1256
  requestId,