@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.
- 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-control-plane-policy.test.ts +6 -2
- 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 +413 -3
- package/src/approvals/guardian-decision-primitive.ts +22 -1
- package/src/daemon/handlers/sessions.ts +125 -11
- package/src/daemon/response-tier.ts +6 -5
- 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 +187 -2
- package/src/runtime/routes/inbound-message-handler.ts +12 -1
- package/src/tools/apps/executors.ts +15 -0
- package/src/tools/reminder/reminder-store.ts +10 -14
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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 &&
|