@vellumai/assistant 0.4.0 → 0.4.1
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/package.json +1 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +6 -2
- package/src/__tests__/send-endpoint-busy.test.ts +286 -2
- package/src/daemon/response-tier.ts +6 -5
- package/src/runtime/routes/conversation-routes.ts +164 -0
- package/src/tools/reminder/reminder-store.ts +10 -14
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import type { ToolExecutionResult, ToolLifecycleEvent, ToolPermissionDeniedEvent } from '../tools/types.js';
|
|
4
4
|
|
|
@@ -100,7 +100,11 @@ function makePrompter(): PermissionPrompter {
|
|
|
100
100
|
} as unknown as PermissionPrompter;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
import { resetDb } from '../memory/db.js';
|
|
104
|
+
import { initializeDb } from '../memory/db-init.js';
|
|
105
|
+
|
|
106
|
+
beforeAll(() => { initializeDb(); });
|
|
107
|
+
afterAll(() => { resetDb(); mock.restore(); });
|
|
104
108
|
|
|
105
109
|
// =====================================================================
|
|
106
110
|
// Unit tests: isGuardianControlPlaneInvocation
|
|
@@ -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,7 @@ 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 * as pendingInteractions from '../runtime/pending-interactions.js';
|
|
56
59
|
|
|
57
60
|
initializeDb();
|
|
58
61
|
|
|
@@ -63,6 +66,7 @@ initializeDb();
|
|
|
63
66
|
/** Session that completes its agent loop quickly and emits a text delta + message_complete. */
|
|
64
67
|
function makeCompletingSession(): Session {
|
|
65
68
|
let processing = false;
|
|
69
|
+
const messages: unknown[] = [];
|
|
66
70
|
return {
|
|
67
71
|
isProcessing: () => processing,
|
|
68
72
|
persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
|
|
@@ -77,6 +81,10 @@ function makeCompletingSession(): Session {
|
|
|
77
81
|
setTurnChannelContext: () => {},
|
|
78
82
|
setTurnInterfaceContext: () => {},
|
|
79
83
|
updateClient: () => {},
|
|
84
|
+
hasAnyPendingConfirmation: () => false,
|
|
85
|
+
hasPendingConfirmation: () => false,
|
|
86
|
+
denyAllPendingConfirmations: () => {},
|
|
87
|
+
getQueueDepth: () => 0,
|
|
80
88
|
enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
|
|
81
89
|
runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
|
|
82
90
|
onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
|
|
@@ -85,13 +93,14 @@ function makeCompletingSession(): Session {
|
|
|
85
93
|
},
|
|
86
94
|
handleConfirmationResponse: () => {},
|
|
87
95
|
handleSecretResponse: () => {},
|
|
88
|
-
|
|
96
|
+
getMessages: () => messages as never[],
|
|
89
97
|
} as unknown as Session;
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
/** Session that hangs forever in the agent loop (simulates a busy session). */
|
|
93
101
|
function makeHangingSession(): Session {
|
|
94
102
|
let processing = false;
|
|
103
|
+
const messages: unknown[] = [];
|
|
95
104
|
const enqueuedMessages: Array<{ content: string; onEvent: (msg: ServerMessage) => void; requestId: string }> = [];
|
|
96
105
|
return {
|
|
97
106
|
isProcessing: () => processing,
|
|
@@ -107,6 +116,10 @@ function makeHangingSession(): Session {
|
|
|
107
116
|
setTurnChannelContext: () => {},
|
|
108
117
|
setTurnInterfaceContext: () => {},
|
|
109
118
|
updateClient: () => {},
|
|
119
|
+
hasAnyPendingConfirmation: () => false,
|
|
120
|
+
hasPendingConfirmation: () => false,
|
|
121
|
+
denyAllPendingConfirmations: () => {},
|
|
122
|
+
getQueueDepth: () => enqueuedMessages.length,
|
|
110
123
|
enqueueMessage: (content: string, _attachments: unknown[], onEvent: (msg: ServerMessage) => void, requestId: string) => {
|
|
111
124
|
enqueuedMessages.push({ content, onEvent, requestId });
|
|
112
125
|
return { queued: true, requestId };
|
|
@@ -117,11 +130,63 @@ function makeHangingSession(): Session {
|
|
|
117
130
|
},
|
|
118
131
|
handleConfirmationResponse: () => {},
|
|
119
132
|
handleSecretResponse: () => {},
|
|
120
|
-
|
|
133
|
+
getMessages: () => messages as never[],
|
|
121
134
|
_enqueuedMessages: enqueuedMessages,
|
|
122
135
|
} as unknown as Session;
|
|
123
136
|
}
|
|
124
137
|
|
|
138
|
+
function makePendingApprovalSession(requestId: string, processing: boolean): {
|
|
139
|
+
session: Session;
|
|
140
|
+
runAgentLoopMock: ReturnType<typeof mock>;
|
|
141
|
+
enqueueMessageMock: ReturnType<typeof mock>;
|
|
142
|
+
denyAllPendingConfirmationsMock: ReturnType<typeof mock>;
|
|
143
|
+
handleConfirmationResponseMock: ReturnType<typeof mock>;
|
|
144
|
+
} {
|
|
145
|
+
const pending = new Set([requestId]);
|
|
146
|
+
const messages: unknown[] = [];
|
|
147
|
+
const runAgentLoopMock = mock(async () => {});
|
|
148
|
+
const enqueueMessageMock = mock((_content: string, _attachments: unknown[], _onEvent: (msg: ServerMessage) => void, queuedRequestId: string) => ({
|
|
149
|
+
queued: true,
|
|
150
|
+
requestId: queuedRequestId,
|
|
151
|
+
}));
|
|
152
|
+
const denyAllPendingConfirmationsMock = mock(() => {
|
|
153
|
+
pending.clear();
|
|
154
|
+
});
|
|
155
|
+
const handleConfirmationResponseMock = mock((resolvedRequestId: string) => {
|
|
156
|
+
pending.delete(resolvedRequestId);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const session = {
|
|
160
|
+
isProcessing: () => processing,
|
|
161
|
+
persistUserMessage: (_content: string, _attachments: unknown[], reqId?: string) => reqId ?? 'msg-1',
|
|
162
|
+
memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
|
|
163
|
+
setChannelCapabilities: () => {},
|
|
164
|
+
setAssistantId: () => {},
|
|
165
|
+
setGuardianContext: () => {},
|
|
166
|
+
setCommandIntent: () => {},
|
|
167
|
+
setTurnChannelContext: () => {},
|
|
168
|
+
setTurnInterfaceContext: () => {},
|
|
169
|
+
updateClient: () => {},
|
|
170
|
+
hasAnyPendingConfirmation: () => pending.size > 0,
|
|
171
|
+
hasPendingConfirmation: (candidateRequestId: string) => pending.has(candidateRequestId),
|
|
172
|
+
denyAllPendingConfirmations: denyAllPendingConfirmationsMock,
|
|
173
|
+
getQueueDepth: () => 0,
|
|
174
|
+
enqueueMessage: enqueueMessageMock,
|
|
175
|
+
runAgentLoop: runAgentLoopMock,
|
|
176
|
+
handleConfirmationResponse: handleConfirmationResponseMock,
|
|
177
|
+
handleSecretResponse: () => {},
|
|
178
|
+
getMessages: () => messages as never[],
|
|
179
|
+
} as unknown as Session;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
session,
|
|
183
|
+
runAgentLoopMock,
|
|
184
|
+
enqueueMessageMock,
|
|
185
|
+
denyAllPendingConfirmationsMock,
|
|
186
|
+
handleConfirmationResponseMock,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
125
190
|
// ---------------------------------------------------------------------------
|
|
126
191
|
// Tests
|
|
127
192
|
// ---------------------------------------------------------------------------
|
|
@@ -139,6 +204,9 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
139
204
|
db.run('DELETE FROM messages');
|
|
140
205
|
db.run('DELETE FROM conversations');
|
|
141
206
|
db.run('DELETE FROM conversation_keys');
|
|
207
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
208
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
209
|
+
pendingInteractions.clear();
|
|
142
210
|
eventHub = new AssistantEventHub();
|
|
143
211
|
});
|
|
144
212
|
|
|
@@ -226,6 +294,222 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
|
|
|
226
294
|
await stopServer();
|
|
227
295
|
});
|
|
228
296
|
|
|
297
|
+
test('consumes explicit approval text when a single pending confirmation exists (idle)', async () => {
|
|
298
|
+
const conversationKey = 'conv-inline-idle';
|
|
299
|
+
const { conversationId } = getOrCreateConversation(conversationKey);
|
|
300
|
+
const requestId = 'req-inline-idle';
|
|
301
|
+
const {
|
|
302
|
+
session,
|
|
303
|
+
runAgentLoopMock,
|
|
304
|
+
enqueueMessageMock,
|
|
305
|
+
denyAllPendingConfirmationsMock,
|
|
306
|
+
handleConfirmationResponseMock,
|
|
307
|
+
} = makePendingApprovalSession(requestId, false);
|
|
308
|
+
|
|
309
|
+
pendingInteractions.register(requestId, {
|
|
310
|
+
session,
|
|
311
|
+
conversationId,
|
|
312
|
+
kind: 'confirmation',
|
|
313
|
+
});
|
|
314
|
+
createCanonicalGuardianRequest({
|
|
315
|
+
id: requestId,
|
|
316
|
+
kind: 'tool_approval',
|
|
317
|
+
sourceType: 'desktop',
|
|
318
|
+
sourceChannel: 'vellum',
|
|
319
|
+
conversationId,
|
|
320
|
+
toolName: 'call_start',
|
|
321
|
+
status: 'pending',
|
|
322
|
+
requestCode: 'ABC123',
|
|
323
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await startServer(() => session);
|
|
327
|
+
|
|
328
|
+
const res = await fetch(messagesUrl(), {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
331
|
+
body: JSON.stringify({
|
|
332
|
+
conversationKey,
|
|
333
|
+
content: 'yes',
|
|
334
|
+
sourceChannel: 'vellum',
|
|
335
|
+
interface: 'macos',
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
|
|
339
|
+
|
|
340
|
+
expect(res.status).toBe(202);
|
|
341
|
+
expect(body.accepted).toBe(true);
|
|
342
|
+
expect(body.messageId).toBeDefined();
|
|
343
|
+
expect(body.queued).toBeUndefined();
|
|
344
|
+
expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
|
|
346
|
+
expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
|
|
347
|
+
expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
|
|
348
|
+
|
|
349
|
+
await stopServer();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('consumes explicit approval text while busy instead of auto-denying and queueing', async () => {
|
|
353
|
+
const conversationKey = 'conv-inline-busy';
|
|
354
|
+
const { conversationId } = getOrCreateConversation(conversationKey);
|
|
355
|
+
const requestId = 'req-inline-busy';
|
|
356
|
+
const {
|
|
357
|
+
session,
|
|
358
|
+
runAgentLoopMock,
|
|
359
|
+
enqueueMessageMock,
|
|
360
|
+
denyAllPendingConfirmationsMock,
|
|
361
|
+
handleConfirmationResponseMock,
|
|
362
|
+
} = makePendingApprovalSession(requestId, true);
|
|
363
|
+
|
|
364
|
+
pendingInteractions.register(requestId, {
|
|
365
|
+
session,
|
|
366
|
+
conversationId,
|
|
367
|
+
kind: 'confirmation',
|
|
368
|
+
});
|
|
369
|
+
createCanonicalGuardianRequest({
|
|
370
|
+
id: requestId,
|
|
371
|
+
kind: 'tool_approval',
|
|
372
|
+
sourceType: 'desktop',
|
|
373
|
+
sourceChannel: 'vellum',
|
|
374
|
+
conversationId,
|
|
375
|
+
toolName: 'call_start',
|
|
376
|
+
status: 'pending',
|
|
377
|
+
requestCode: 'DEF456',
|
|
378
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await startServer(() => session);
|
|
382
|
+
|
|
383
|
+
const res = await fetch(messagesUrl(), {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
386
|
+
body: JSON.stringify({
|
|
387
|
+
conversationKey,
|
|
388
|
+
content: 'approve',
|
|
389
|
+
sourceChannel: 'vellum',
|
|
390
|
+
interface: 'macos',
|
|
391
|
+
}),
|
|
392
|
+
});
|
|
393
|
+
const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
|
|
394
|
+
|
|
395
|
+
expect(res.status).toBe(202);
|
|
396
|
+
expect(body.accepted).toBe(true);
|
|
397
|
+
expect(body.messageId).toBeDefined();
|
|
398
|
+
expect(body.queued).toBeUndefined();
|
|
399
|
+
expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
|
|
400
|
+
expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
|
|
401
|
+
expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
|
|
402
|
+
expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
|
|
403
|
+
|
|
404
|
+
await stopServer();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('consumes explicit rejection text when a single pending confirmation exists (idle)', async () => {
|
|
408
|
+
const conversationKey = 'conv-inline-reject';
|
|
409
|
+
const { conversationId } = getOrCreateConversation(conversationKey);
|
|
410
|
+
const requestId = 'req-inline-reject';
|
|
411
|
+
const {
|
|
412
|
+
session,
|
|
413
|
+
runAgentLoopMock,
|
|
414
|
+
enqueueMessageMock,
|
|
415
|
+
denyAllPendingConfirmationsMock,
|
|
416
|
+
handleConfirmationResponseMock,
|
|
417
|
+
} = makePendingApprovalSession(requestId, false);
|
|
418
|
+
|
|
419
|
+
pendingInteractions.register(requestId, {
|
|
420
|
+
session,
|
|
421
|
+
conversationId,
|
|
422
|
+
kind: 'confirmation',
|
|
423
|
+
});
|
|
424
|
+
createCanonicalGuardianRequest({
|
|
425
|
+
id: requestId,
|
|
426
|
+
kind: 'tool_approval',
|
|
427
|
+
sourceType: 'desktop',
|
|
428
|
+
sourceChannel: 'vellum',
|
|
429
|
+
conversationId,
|
|
430
|
+
toolName: 'call_start',
|
|
431
|
+
status: 'pending',
|
|
432
|
+
requestCode: 'GHI789',
|
|
433
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await startServer(() => session);
|
|
437
|
+
|
|
438
|
+
const res = await fetch(messagesUrl(), {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
441
|
+
body: JSON.stringify({
|
|
442
|
+
conversationKey,
|
|
443
|
+
content: 'no',
|
|
444
|
+
sourceChannel: 'vellum',
|
|
445
|
+
interface: 'macos',
|
|
446
|
+
}),
|
|
447
|
+
});
|
|
448
|
+
const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
|
|
449
|
+
|
|
450
|
+
expect(res.status).toBe(202);
|
|
451
|
+
expect(body.accepted).toBe(true);
|
|
452
|
+
expect(body.messageId).toBeDefined();
|
|
453
|
+
expect(body.queued).toBeUndefined();
|
|
454
|
+
// Rejection still flows through handleConfirmationResponse (with reject action)
|
|
455
|
+
expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1);
|
|
456
|
+
expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0);
|
|
457
|
+
expect(enqueueMessageMock).toHaveBeenCalledTimes(0);
|
|
458
|
+
expect(runAgentLoopMock).toHaveBeenCalledTimes(0);
|
|
459
|
+
|
|
460
|
+
await stopServer();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('does not consume ambiguous text — falls through to normal message handling', async () => {
|
|
464
|
+
const conversationKey = 'conv-inline-ambiguous';
|
|
465
|
+
const { conversationId } = getOrCreateConversation(conversationKey);
|
|
466
|
+
const requestId = 'req-inline-ambiguous';
|
|
467
|
+
const {
|
|
468
|
+
session,
|
|
469
|
+
runAgentLoopMock,
|
|
470
|
+
} = makePendingApprovalSession(requestId, false);
|
|
471
|
+
|
|
472
|
+
pendingInteractions.register(requestId, {
|
|
473
|
+
session,
|
|
474
|
+
conversationId,
|
|
475
|
+
kind: 'confirmation',
|
|
476
|
+
});
|
|
477
|
+
createCanonicalGuardianRequest({
|
|
478
|
+
id: requestId,
|
|
479
|
+
kind: 'tool_approval',
|
|
480
|
+
sourceType: 'desktop',
|
|
481
|
+
sourceChannel: 'vellum',
|
|
482
|
+
conversationId,
|
|
483
|
+
toolName: 'call_start',
|
|
484
|
+
status: 'pending',
|
|
485
|
+
requestCode: 'JKL012',
|
|
486
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await startServer(() => session);
|
|
490
|
+
|
|
491
|
+
const res = await fetch(messagesUrl(), {
|
|
492
|
+
method: 'POST',
|
|
493
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
conversationKey,
|
|
496
|
+
content: 'What is the weather today?',
|
|
497
|
+
sourceChannel: 'vellum',
|
|
498
|
+
interface: 'macos',
|
|
499
|
+
}),
|
|
500
|
+
});
|
|
501
|
+
const body = await res.json() as { accepted: boolean; messageId?: string; queued?: boolean };
|
|
502
|
+
|
|
503
|
+
// Ambiguous text should NOT be consumed — falls through to normal send path
|
|
504
|
+
expect(res.status).toBe(202);
|
|
505
|
+
expect(body.accepted).toBe(true);
|
|
506
|
+
expect(body.messageId).toBeDefined();
|
|
507
|
+
// The normal idle send path fires runAgentLoop
|
|
508
|
+
expect(runAgentLoopMock).toHaveBeenCalledTimes(1);
|
|
509
|
+
|
|
510
|
+
await stopServer();
|
|
511
|
+
});
|
|
512
|
+
|
|
229
513
|
// ── Busy session: queue-if-busy ─────────────────────────────────────
|
|
230
514
|
|
|
231
515
|
test('returns 202 with queued: true when session is busy (not 409)', async () => {
|
|
@@ -145,15 +145,16 @@ const TIER_SYSTEM_PROMPT =
|
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* Fire-and-forget Haiku call to classify the conversation trajectory.
|
|
148
|
-
* Returns the classified tier or
|
|
148
|
+
* Returns the classified tier, or undefined when no provider is configured
|
|
149
|
+
* or on any failure.
|
|
149
150
|
*/
|
|
150
151
|
export async function classifyResponseTierAsync(
|
|
151
152
|
recentUserTexts: string[],
|
|
152
|
-
): Promise<ResponseTier |
|
|
153
|
+
): Promise<ResponseTier | undefined> {
|
|
153
154
|
const provider = getConfiguredProvider();
|
|
154
155
|
if (!provider) {
|
|
155
156
|
log.debug('No provider available for async tier classification');
|
|
156
|
-
return
|
|
157
|
+
return undefined;
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
const combined = recentUserTexts
|
|
@@ -186,14 +187,14 @@ export async function classifyResponseTierAsync(
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
log.debug({ raw }, 'Async tier classification returned unexpected value');
|
|
189
|
-
return
|
|
190
|
+
return undefined;
|
|
190
191
|
} finally {
|
|
191
192
|
cleanup();
|
|
192
193
|
}
|
|
193
194
|
} catch (err) {
|
|
194
195
|
const message = err instanceof Error ? err.message : String(err);
|
|
195
196
|
log.debug({ err: message }, 'Async tier classification failed');
|
|
196
|
-
return
|
|
197
|
+
return undefined;
|
|
197
198
|
}
|
|
198
199
|
}
|
|
199
200
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
5
|
import { join, relative } from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { createAssistantMessage, createUserMessage } from '../../agent/message-types.js';
|
|
7
8
|
import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '../../channels/types.js';
|
|
8
9
|
import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
|
|
9
10
|
import type { ServerMessage } from '../../daemon/ipc-protocol.js';
|
|
@@ -11,6 +12,8 @@ import * as attachmentsStore from '../../memory/attachments-store.js';
|
|
|
11
12
|
import {
|
|
12
13
|
createCanonicalGuardianRequest,
|
|
13
14
|
generateCanonicalRequestCode,
|
|
15
|
+
listCanonicalGuardianRequests,
|
|
16
|
+
listPendingCanonicalGuardianRequestsByDestinationConversation,
|
|
14
17
|
} from '../../memory/canonical-guardian-store.js';
|
|
15
18
|
import {
|
|
16
19
|
getConversationByKey,
|
|
@@ -21,6 +24,7 @@ import { getConfiguredProvider } from '../../providers/provider-send-message.js'
|
|
|
21
24
|
import type { Provider } from '../../providers/types.js';
|
|
22
25
|
import { getLogger } from '../../util/logger.js';
|
|
23
26
|
import { buildAssistantEvent } from '../assistant-event.js';
|
|
27
|
+
import { routeGuardianReply } from '../guardian-reply-router.js';
|
|
24
28
|
import { httpError } from '../http-errors.js';
|
|
25
29
|
import type {
|
|
26
30
|
MessageProcessor,
|
|
@@ -35,6 +39,143 @@ const log = getLogger('conversation-routes');
|
|
|
35
39
|
|
|
36
40
|
const SUGGESTION_CACHE_MAX = 100;
|
|
37
41
|
|
|
42
|
+
function collectLivePendingConfirmationRequestIds(
|
|
43
|
+
conversationId: string,
|
|
44
|
+
sourceChannel: string,
|
|
45
|
+
session: import('../../daemon/session.js').Session,
|
|
46
|
+
): string[] {
|
|
47
|
+
const pendingInteractionRequestIds = pendingInteractions
|
|
48
|
+
.getByConversation(conversationId)
|
|
49
|
+
.filter(
|
|
50
|
+
(interaction) =>
|
|
51
|
+
interaction.kind === 'confirmation'
|
|
52
|
+
&& interaction.session === session
|
|
53
|
+
&& session.hasPendingConfirmation(interaction.requestId),
|
|
54
|
+
)
|
|
55
|
+
.map((interaction) => interaction.requestId);
|
|
56
|
+
|
|
57
|
+
// Query both by destination conversation (via deliveries table) and by
|
|
58
|
+
// source conversation (direct field). For desktop/HTTP sessions these
|
|
59
|
+
// often overlap, but the Set dedup below handles that.
|
|
60
|
+
const pendingCanonicalRequestIds = [
|
|
61
|
+
...listPendingCanonicalGuardianRequestsByDestinationConversation(conversationId, sourceChannel)
|
|
62
|
+
.filter((request) => request.kind === 'tool_approval')
|
|
63
|
+
.map((request) => request.id),
|
|
64
|
+
...listCanonicalGuardianRequests({
|
|
65
|
+
status: 'pending',
|
|
66
|
+
conversationId,
|
|
67
|
+
kind: 'tool_approval',
|
|
68
|
+
}).map((request) => request.id),
|
|
69
|
+
].filter((requestId) => session.hasPendingConfirmation(requestId));
|
|
70
|
+
|
|
71
|
+
return Array.from(new Set([
|
|
72
|
+
...pendingInteractionRequestIds,
|
|
73
|
+
...pendingCanonicalRequestIds,
|
|
74
|
+
]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function tryConsumeInlineApprovalReply(params: {
|
|
78
|
+
conversationId: string;
|
|
79
|
+
sourceChannel: string;
|
|
80
|
+
sourceInterface: string;
|
|
81
|
+
content: string;
|
|
82
|
+
attachments: Array<{
|
|
83
|
+
id: string;
|
|
84
|
+
filename: string;
|
|
85
|
+
mimeType: string;
|
|
86
|
+
data: string;
|
|
87
|
+
}>;
|
|
88
|
+
session: import('../../daemon/session.js').Session;
|
|
89
|
+
onEvent: (msg: ServerMessage) => void;
|
|
90
|
+
}): Promise<{ consumed: boolean; messageId?: string }> {
|
|
91
|
+
const {
|
|
92
|
+
conversationId,
|
|
93
|
+
sourceChannel,
|
|
94
|
+
sourceInterface,
|
|
95
|
+
content,
|
|
96
|
+
attachments,
|
|
97
|
+
session,
|
|
98
|
+
onEvent,
|
|
99
|
+
} = params;
|
|
100
|
+
const trimmedContent = content.trim();
|
|
101
|
+
|
|
102
|
+
// Only consume inline replies when there are no queued turns, matching
|
|
103
|
+
// the IPC path guard. With queued messages, "approve"/"no" should be
|
|
104
|
+
// processed in queue order rather than treated as a confirmation reply.
|
|
105
|
+
if (
|
|
106
|
+
!session.hasAnyPendingConfirmation()
|
|
107
|
+
|| session.getQueueDepth() > 0
|
|
108
|
+
|| trimmedContent.length === 0
|
|
109
|
+
) {
|
|
110
|
+
return { consumed: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pendingRequestIds = collectLivePendingConfirmationRequestIds(conversationId, sourceChannel, session);
|
|
114
|
+
if (pendingRequestIds.length === 0) {
|
|
115
|
+
return { consumed: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const routerResult = await routeGuardianReply({
|
|
119
|
+
messageText: trimmedContent,
|
|
120
|
+
channel: sourceChannel,
|
|
121
|
+
actor: {
|
|
122
|
+
externalUserId: undefined,
|
|
123
|
+
channel: sourceChannel,
|
|
124
|
+
isTrusted: true,
|
|
125
|
+
},
|
|
126
|
+
conversationId,
|
|
127
|
+
pendingRequestIds,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
|
|
131
|
+
return { consumed: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Decision has been applied — transcript persistence is best-effort.
|
|
135
|
+
// If DB writes fail, we still return consumed: true so the approval text
|
|
136
|
+
// is not re-processed as a new user turn.
|
|
137
|
+
let messageId: string | undefined;
|
|
138
|
+
try {
|
|
139
|
+
const channelMeta = {
|
|
140
|
+
userMessageChannel: sourceChannel,
|
|
141
|
+
assistantMessageChannel: sourceChannel,
|
|
142
|
+
userMessageInterface: sourceInterface,
|
|
143
|
+
assistantMessageInterface: sourceInterface,
|
|
144
|
+
provenanceActorRole: 'guardian' as const,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const userMessage = createUserMessage(content, attachments);
|
|
148
|
+
const persistedUser = await conversationStore.addMessage(
|
|
149
|
+
conversationId,
|
|
150
|
+
'user',
|
|
151
|
+
JSON.stringify(userMessage.content),
|
|
152
|
+
channelMeta,
|
|
153
|
+
);
|
|
154
|
+
messageId = persistedUser.id;
|
|
155
|
+
|
|
156
|
+
const replyText = (routerResult.replyText?.trim())
|
|
157
|
+
|| (routerResult.decisionApplied ? 'Decision applied.' : 'Request already resolved.');
|
|
158
|
+
const assistantMessage = createAssistantMessage(replyText);
|
|
159
|
+
await conversationStore.addMessage(
|
|
160
|
+
conversationId,
|
|
161
|
+
'assistant',
|
|
162
|
+
JSON.stringify(assistantMessage.content),
|
|
163
|
+
channelMeta,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Avoid mutating in-memory history / emitting stream deltas while a run is active.
|
|
167
|
+
if (!session.isProcessing()) {
|
|
168
|
+
session.getMessages().push(userMessage, assistantMessage);
|
|
169
|
+
onEvent({ type: 'assistant_text_delta', text: replyText, sessionId: conversationId });
|
|
170
|
+
onEvent({ type: 'message_complete', sessionId: conversationId });
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log.warn({ err, conversationId }, 'Failed to persist inline approval transcript entries');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { consumed: true, messageId };
|
|
177
|
+
}
|
|
178
|
+
|
|
38
179
|
function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path: string; mtimeMs: number }> {
|
|
39
180
|
if (!interfacesDir || !existsSync(interfacesDir)) return [];
|
|
40
181
|
const results: Array<{ path: string; mtimeMs: number }> = [];
|
|
@@ -290,6 +431,29 @@ export async function handleSendMessage(
|
|
|
290
431
|
? smDeps.resolveAttachments(attachmentIds)
|
|
291
432
|
: [];
|
|
292
433
|
|
|
434
|
+
// Try to consume the message as an inline approval/rejection reply.
|
|
435
|
+
// On failure, degrade to the existing queue/auto-deny path rather than
|
|
436
|
+
// surfacing a 500 — mirrors the IPC handler's catch-and-fallback.
|
|
437
|
+
try {
|
|
438
|
+
const inlineReplyResult = await tryConsumeInlineApprovalReply({
|
|
439
|
+
conversationId: mapping.conversationId,
|
|
440
|
+
sourceChannel,
|
|
441
|
+
sourceInterface,
|
|
442
|
+
content: content ?? '',
|
|
443
|
+
attachments,
|
|
444
|
+
session,
|
|
445
|
+
onEvent,
|
|
446
|
+
});
|
|
447
|
+
if (inlineReplyResult.consumed) {
|
|
448
|
+
return Response.json(
|
|
449
|
+
{ accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
|
|
450
|
+
{ status: 202 },
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
log.warn({ err, conversationId: mapping.conversationId }, 'Inline approval consumption failed, falling through to normal send path');
|
|
455
|
+
}
|
|
456
|
+
|
|
293
457
|
if (session.isProcessing()) {
|
|
294
458
|
// If a tool confirmation is pending, auto-deny it so the agent
|
|
295
459
|
// can finish the current turn and process this queued message.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, asc, eq, lte } from 'drizzle-orm';
|
|
2
2
|
import { v4 as uuid } from 'uuid';
|
|
3
3
|
|
|
4
|
-
import { getDb,
|
|
4
|
+
import { getDb, rawRun } from '../../memory/db.js';
|
|
5
5
|
import { reminders } from '../../memory/schema.js';
|
|
6
6
|
import { cast,createRowMapper, parseJson } from '../../util/row-mapper.js';
|
|
7
7
|
|
|
@@ -105,14 +105,11 @@ export function listReminders(options?: { pendingOnly?: boolean }): ReminderRow[
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function cancelReminder(id: string): boolean {
|
|
108
|
-
const db = getDb();
|
|
109
108
|
const now = Date.now();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
.run();
|
|
115
|
-
return rawChanges() > 0;
|
|
109
|
+
return rawRun(
|
|
110
|
+
'UPDATE reminders SET status = ?, updated_at = ? WHERE id = ? AND status = ?',
|
|
111
|
+
'cancelled', now, id, 'pending',
|
|
112
|
+
) > 0;
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
/**
|
|
@@ -132,13 +129,12 @@ export function claimDueReminders(now: number): ReminderRow[] {
|
|
|
132
129
|
|
|
133
130
|
const claimed: ReminderRow[] = [];
|
|
134
131
|
for (const row of candidates) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
.run();
|
|
132
|
+
const changed = rawRun(
|
|
133
|
+
'UPDATE reminders SET status = ?, fired_at = ?, updated_at = ? WHERE id = ? AND status = ?',
|
|
134
|
+
'firing', now, now, row.id, 'pending',
|
|
135
|
+
);
|
|
140
136
|
|
|
141
|
-
if (
|
|
137
|
+
if (changed === 0) continue;
|
|
142
138
|
|
|
143
139
|
claimed.push(parseRow({
|
|
144
140
|
...row,
|