@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.
- package/bun.lock +0 -83
- package/package.json +2 -3
- package/src/__tests__/channel-approval-routes.test.ts +55 -5
- package/src/__tests__/daemon-server-session-init.test.ts +54 -1
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
- 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__/send-endpoint-busy.test.ts +129 -3
- package/src/approvals/guardian-decision-primitive.ts +22 -1
- package/src/daemon/handlers/sessions.ts +125 -11
- package/src/daemon/server.ts +17 -2
- package/src/daemon/session-agent-loop.ts +33 -22
- package/src/memory/app-store.ts +6 -0
- package/src/memory/embedding-local.ts +25 -13
- package/src/memory/embedding-runtime-manager.ts +24 -6
- package/src/runtime/guardian-context-resolver.ts +5 -1
- package/src/runtime/guardian-reply-router.ts +12 -0
- package/src/runtime/http-server.ts +1 -0
- package/src/runtime/routes/conversation-routes.ts +31 -10
- package/src/runtime/routes/inbound-message-handler.ts +12 -1
- package/src/tools/apps/executors.ts +15 -0
|
@@ -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(
|
|
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: () =>
|
|
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(
|
|
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)
|
|
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
|
|
465
|
-
//
|
|
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
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
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
|
|
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,
|