@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -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
- afterAll(() => { mock.restore(); });
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
- hasAnyPendingConfirmation: () => false,
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
- hasAnyPendingConfirmation: () => false,
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 null on any failure.
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 | null> {
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 null;
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 null;
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 null;
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, rawChanges } from '../../memory/db.js';
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
- db
111
- .update(reminders)
112
- .set({ status: 'cancelled', updatedAt: now })
113
- .where(and(eq(reminders.id, id), eq(reminders.status, 'pending')))
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
- db
136
- .update(reminders)
137
- .set({ status: 'firing', firedAt: now, updatedAt: now })
138
- .where(and(eq(reminders.id, row.id), eq(reminders.status, 'pending')))
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 (rawChanges() === 0) continue;
137
+ if (changed === 0) continue;
142
138
 
143
139
  claimed.push(parseRow({
144
140
  ...row,