@vellumai/assistant 0.3.28 → 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.
Files changed (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -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
 
@@ -38,6 +40,8 @@ mock.module('../util/logger.js', () => ({
38
40
 
39
41
  mock.module('../config/loader.js', () => ({
40
42
  getConfig: () => ({
43
+ ui: {},
44
+
41
45
  model: 'test',
42
46
  provider: 'test',
43
47
  apiKeys: {},
@@ -51,6 +55,7 @@ import { getDb, initializeDb, resetDb } from '../memory/db.js';
51
55
  import type { AssistantEvent } from '../runtime/assistant-event.js';
52
56
  import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
53
57
  import { RuntimeHttpServer } from '../runtime/http-server.js';
58
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
54
59
 
55
60
  initializeDb();
56
61
 
@@ -61,6 +66,7 @@ initializeDb();
61
66
  /** Session that completes its agent loop quickly and emits a text delta + message_complete. */
62
67
  function makeCompletingSession(): Session {
63
68
  let processing = false;
69
+ const messages: unknown[] = [];
64
70
  return {
65
71
  isProcessing: () => processing,
66
72
  persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
@@ -75,6 +81,10 @@ function makeCompletingSession(): Session {
75
81
  setTurnChannelContext: () => {},
76
82
  setTurnInterfaceContext: () => {},
77
83
  updateClient: () => {},
84
+ hasAnyPendingConfirmation: () => false,
85
+ hasPendingConfirmation: () => false,
86
+ denyAllPendingConfirmations: () => {},
87
+ getQueueDepth: () => 0,
78
88
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
79
89
  runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
80
90
  onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
@@ -83,12 +93,14 @@ function makeCompletingSession(): Session {
83
93
  },
84
94
  handleConfirmationResponse: () => {},
85
95
  handleSecretResponse: () => {},
96
+ getMessages: () => messages as never[],
86
97
  } as unknown as Session;
87
98
  }
88
99
 
89
100
  /** Session that hangs forever in the agent loop (simulates a busy session). */
90
101
  function makeHangingSession(): Session {
91
102
  let processing = false;
103
+ const messages: unknown[] = [];
92
104
  const enqueuedMessages: Array<{ content: string; onEvent: (msg: ServerMessage) => void; requestId: string }> = [];
93
105
  return {
94
106
  isProcessing: () => processing,
@@ -104,6 +116,10 @@ function makeHangingSession(): Session {
104
116
  setTurnChannelContext: () => {},
105
117
  setTurnInterfaceContext: () => {},
106
118
  updateClient: () => {},
119
+ hasAnyPendingConfirmation: () => false,
120
+ hasPendingConfirmation: () => false,
121
+ denyAllPendingConfirmations: () => {},
122
+ getQueueDepth: () => enqueuedMessages.length,
107
123
  enqueueMessage: (content: string, _attachments: unknown[], onEvent: (msg: ServerMessage) => void, requestId: string) => {
108
124
  enqueuedMessages.push({ content, onEvent, requestId });
109
125
  return { queued: true, requestId };
@@ -114,10 +130,63 @@ function makeHangingSession(): Session {
114
130
  },
115
131
  handleConfirmationResponse: () => {},
116
132
  handleSecretResponse: () => {},
133
+ getMessages: () => messages as never[],
117
134
  _enqueuedMessages: enqueuedMessages,
118
135
  } as unknown as Session;
119
136
  }
120
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
+
121
190
  // ---------------------------------------------------------------------------
122
191
  // Tests
123
192
  // ---------------------------------------------------------------------------
@@ -135,6 +204,9 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
135
204
  db.run('DELETE FROM messages');
136
205
  db.run('DELETE FROM conversations');
137
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();
138
210
  eventHub = new AssistantEventHub();
139
211
  });
140
212
 
@@ -222,6 +294,222 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
222
294
  await stopServer();
223
295
  });
224
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
+
225
513
  // ── Busy session: queue-if-busy ─────────────────────────────────────
226
514
 
227
515
  test('returns 202 with queued: true when session is busy (not 409)', async () => {
@@ -25,6 +25,8 @@ mock.module('../providers/registry.js', () => ({
25
25
 
26
26
  mock.module('../config/loader.js', () => ({
27
27
  getConfig: () => ({
28
+ ui: {},
29
+
28
30
  provider: 'mock-provider',
29
31
  maxTokens: 4096,
30
32
  thinking: false,
@@ -66,6 +68,13 @@ mock.module('../memory/admin.js', () => ({
66
68
  let persistedMessages: Array<{ role: string; content: string }> = [];
67
69
 
68
70
  mock.module('../memory/conversation-store.js', () => ({
71
+ getConversationThreadType: () => 'default',
72
+ setConversationOriginChannelIfUnset: () => {},
73
+ updateConversationContextWindow: () => {},
74
+ deleteMessageById: () => {},
75
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
76
+ getConversationOriginInterface: () => null,
77
+ getConversationOriginChannel: () => null,
69
78
  getMessages: () => [],
70
79
  getConversation: () => ({
71
80
  id: 'conv-1',
@@ -148,6 +157,20 @@ mock.module('../agent/loop.js', () => ({
148
157
  }
149
158
  },
150
159
  }));
160
+ mock.module('../memory/canonical-guardian-store.js', () => ({
161
+ listPendingCanonicalGuardianRequestsByDestinationConversation: () => [],
162
+ listCanonicalGuardianRequests: () => [],
163
+ createCanonicalGuardianRequest: () => ({ id: 'mock-cg-id', code: 'MOCK', status: 'pending' }),
164
+ getCanonicalGuardianRequest: () => null,
165
+ getCanonicalGuardianRequestByCode: () => null,
166
+ updateCanonicalGuardianRequest: () => {},
167
+ resolveCanonicalGuardianRequest: () => {},
168
+ createCanonicalGuardianDelivery: () => ({ id: 'mock-cgd-id' }),
169
+ listCanonicalGuardianDeliveries: () => [],
170
+ listPendingCanonicalGuardianRequestsByDestinationChat: () => [],
171
+ updateCanonicalGuardianDelivery: () => {},
172
+ generateCanonicalRequestCode: () => 'MOCK-CODE',
173
+ }));
151
174
 
152
175
  import { Session } from '../daemon/session.js';
153
176
 
@@ -52,6 +52,21 @@ mock.module('../hooks/manager.js', () => ({
52
52
  }));
53
53
 
54
54
  mock.module('../memory/conversation-store.js', () => ({
55
+ getConversationThreadType: () => 'default',
56
+ setConversationOriginChannelIfUnset: () => {},
57
+ updateConversationUsage: () => {},
58
+ getMessages: () => [],
59
+ getConversation: () => ({
60
+ id: 'conv-1',
61
+ contextSummary: null,
62
+ contextCompactedMessageCount: 0,
63
+ totalInputTokens: 0,
64
+ totalOutputTokens: 0,
65
+ totalEstimatedCost: 0,
66
+ title: null,
67
+ }),
68
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
69
+ getConversationOriginInterface: () => null,
55
70
  addMessage: () => ({ id: 'mock-msg-id' }),
56
71
  deleteMessageById: () => {},
57
72
  updateConversationContextWindow: () => {},
@@ -300,6 +315,7 @@ function makeCtx(overrides?: Partial<AgentLoopSessionContext> & { agentLoopRun?:
300
315
  hasQueuedMessages: () => false,
301
316
  canHandoffAtCheckpoint: () => false,
302
317
  drainQueue: () => {},
318
+ getTurnInterfaceContext: () => null,
303
319
  getTurnChannelContext: () => ({
304
320
  userMessageChannel: 'vellum' as const,
305
321
  assistantMessageChannel: 'vellum' as const,
@@ -76,6 +76,8 @@ mock.module('../providers/registry.js', () => ({
76
76
 
77
77
  mock.module('../config/loader.js', () => ({
78
78
  getConfig: () => ({
79
+ ui: {},
80
+
79
81
  provider: 'mock-provider',
80
82
  maxTokens: 4096,
81
83
  thinking: false,
@@ -149,6 +151,11 @@ mock.module('../security/secret-allowlist.js', () => ({
149
151
  }));
150
152
 
151
153
  mock.module('../memory/conversation-store.js', () => ({
154
+ getConversationThreadType: () => 'default',
155
+ setConversationOriginChannelIfUnset: () => {},
156
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
157
+ getConversationOriginInterface: () => null,
158
+ getConversationOriginChannel: () => null,
152
159
  getMessages: () => persistedMessages,
153
160
  getConversation: () => ({
154
161
  id: 'conv-1',
@@ -270,6 +277,20 @@ mock.module('../agent/loop.js', () => ({
270
277
  }
271
278
  },
272
279
  }));
280
+ mock.module('../memory/canonical-guardian-store.js', () => ({
281
+ listPendingCanonicalGuardianRequestsByDestinationConversation: () => [],
282
+ listCanonicalGuardianRequests: () => [],
283
+ createCanonicalGuardianRequest: () => ({ id: 'mock-cg-id', code: 'MOCK', status: 'pending' }),
284
+ getCanonicalGuardianRequest: () => null,
285
+ getCanonicalGuardianRequestByCode: () => null,
286
+ updateCanonicalGuardianRequest: () => {},
287
+ resolveCanonicalGuardianRequest: () => {},
288
+ createCanonicalGuardianDelivery: () => ({ id: 'mock-cgd-id' }),
289
+ listCanonicalGuardianDeliveries: () => [],
290
+ listPendingCanonicalGuardianRequestsByDestinationChat: () => [],
291
+ updateCanonicalGuardianDelivery: () => {},
292
+ generateCanonicalRequestCode: () => 'MOCK-CODE',
293
+ }));
273
294
 
274
295
  import { Session, type SessionMemoryPolicy } from '../daemon/session.js';
275
296
  import { ConflictGate, looksLikeClarificationReply } from '../daemon/session-conflict-gate.js';
@@ -19,6 +19,8 @@ mock.module('../providers/registry.js', () => ({
19
19
 
20
20
  mock.module('../config/loader.js', () => ({
21
21
  getConfig: () => ({
22
+ ui: {},
23
+
22
24
  provider: 'mock-provider',
23
25
  maxTokens: 4096,
24
26
  thinking: false,
@@ -54,6 +56,14 @@ let mockConversation: Record<string, unknown> | null = null;
54
56
  let nextMockMessageId = 1;
55
57
 
56
58
  mock.module('../memory/conversation-store.js', () => ({
59
+ getConversationThreadType: () => 'default',
60
+ updateConversationContextWindow: () => {},
61
+ deleteMessageById: () => {},
62
+ updateConversationTitle: () => {},
63
+ updateConversationUsage: () => {},
64
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
65
+ getConversationOriginInterface: () => null,
66
+ getConversationOriginChannel: () => null,
57
67
  getMessages: () => mockDbMessages,
58
68
  getConversation: () => mockConversation,
59
69
  createConversation: () => ({ id: 'conv-1' }),
@@ -252,30 +262,30 @@ describe('loadFromDb history repair', () => {
252
262
  id: 'm1',
253
263
  role: 'user',
254
264
  content: JSON.stringify([{ type: 'text', text: 'Guardian secret question' }]),
255
- metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
265
+ metadata: JSON.stringify({ provenanceTrustClass: 'guardian', provenanceSourceChannel: 'telegram' }),
256
266
  },
257
267
  {
258
268
  id: 'm2',
259
269
  role: 'assistant',
260
270
  content: JSON.stringify([{ type: 'text', text: 'Guardian-only answer' }]),
261
- metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
271
+ metadata: JSON.stringify({ provenanceTrustClass: 'guardian', provenanceSourceChannel: 'telegram' }),
262
272
  },
263
273
  {
264
274
  id: 'm3',
265
275
  role: 'user',
266
276
  content: JSON.stringify([{ type: 'text', text: 'Untrusted follow-up' }]),
267
- metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
277
+ metadata: JSON.stringify({ provenanceTrustClass: 'unknown', provenanceSourceChannel: 'telegram' }),
268
278
  },
269
279
  {
270
280
  id: 'm4',
271
281
  role: 'assistant',
272
282
  content: JSON.stringify([{ type: 'text', text: 'Untrusted-safe reply' }]),
273
- metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
283
+ metadata: JSON.stringify({ provenanceTrustClass: 'unknown', provenanceSourceChannel: 'telegram' }),
274
284
  },
275
285
  ];
276
286
 
277
287
  const session = makeSession();
278
- session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
288
+ session.setGuardianContext({ trustClass: 'unknown', sourceChannel: 'telegram' });
279
289
  await session.loadFromDb();
280
290
  const messages = session.getMessages();
281
291
 
@@ -300,35 +310,35 @@ describe('loadFromDb history repair', () => {
300
310
  id: 'm1',
301
311
  role: 'user',
302
312
  content: JSON.stringify([{ type: 'text', text: 'Guardian question' }]),
303
- metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
313
+ metadata: JSON.stringify({ provenanceTrustClass: 'guardian', provenanceSourceChannel: 'telegram' }),
304
314
  },
305
315
  {
306
316
  id: 'm2',
307
317
  role: 'assistant',
308
318
  content: JSON.stringify([{ type: 'text', text: 'Guardian answer' }]),
309
- metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
319
+ metadata: JSON.stringify({ provenanceTrustClass: 'guardian', provenanceSourceChannel: 'telegram' }),
310
320
  },
311
321
  {
312
322
  id: 'm3',
313
323
  role: 'user',
314
324
  content: JSON.stringify([{ type: 'text', text: 'Unverified ping' }]),
315
- metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
325
+ metadata: JSON.stringify({ provenanceTrustClass: 'unknown', provenanceSourceChannel: 'telegram' }),
316
326
  },
317
327
  {
318
328
  id: 'm4',
319
329
  role: 'assistant',
320
330
  content: JSON.stringify([{ type: 'text', text: 'Unverified reply' }]),
321
- metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
331
+ metadata: JSON.stringify({ provenanceTrustClass: 'unknown', provenanceSourceChannel: 'telegram' }),
322
332
  },
323
333
  ];
324
334
 
325
335
  const session = makeSession();
326
336
 
327
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: 'telegram' });
337
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: 'telegram' });
328
338
  await session.ensureActorScopedHistory();
329
339
  expect(session.getMessages()).toHaveLength(4);
330
340
 
331
- session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
341
+ session.setGuardianContext({ trustClass: 'unknown', sourceChannel: 'telegram' });
332
342
  await session.ensureActorScopedHistory();
333
343
  const downgradedMessages = session.getMessages();
334
344
  expect(downgradedMessages).toHaveLength(2);
@@ -350,35 +360,35 @@ describe('loadFromDb history repair', () => {
350
360
  id: 'm1',
351
361
  role: 'user',
352
362
  content: JSON.stringify([{ type: 'text', text: 'Guardian-only question' }]),
353
- metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
363
+ metadata: JSON.stringify({ provenanceTrustClass: 'guardian', provenanceSourceChannel: 'telegram' }),
354
364
  },
355
365
  {
356
366
  id: 'm2',
357
367
  role: 'assistant',
358
368
  content: JSON.stringify([{ type: 'text', text: 'Guardian-only answer' }]),
359
- metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
369
+ metadata: JSON.stringify({ provenanceTrustClass: 'guardian', provenanceSourceChannel: 'telegram' }),
360
370
  },
361
371
  {
362
372
  id: 'm3',
363
373
  role: 'user',
364
374
  content: JSON.stringify([{ type: 'text', text: 'Unverified ping' }]),
365
- metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
375
+ metadata: JSON.stringify({ provenanceTrustClass: 'unknown', provenanceSourceChannel: 'telegram' }),
366
376
  },
367
377
  {
368
378
  id: 'm4',
369
379
  role: 'assistant',
370
380
  content: JSON.stringify([{ type: 'text', text: 'Unverified reply' }]),
371
- metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
381
+ metadata: JSON.stringify({ provenanceTrustClass: 'unknown', provenanceSourceChannel: 'telegram' }),
372
382
  },
373
383
  ];
374
384
 
375
385
  const session = makeSession();
376
386
 
377
- session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
387
+ session.setGuardianContext({ trustClass: 'unknown', sourceChannel: 'telegram' });
378
388
  await session.ensureActorScopedHistory();
379
389
  expect(session.getMessages()).toHaveLength(2);
380
390
 
381
- session.setGuardianContext({ actorRole: 'guardian', sourceChannel: 'telegram' });
391
+ session.setGuardianContext({ trustClass: 'guardian', sourceChannel: 'telegram' });
382
392
  await session.persistUserMessage('Guardian follow-up', []);
383
393
  const messagesAfterPersist = session.getMessages();
384
394
 
@@ -27,6 +27,8 @@ mock.module('../providers/registry.js', () => ({
27
27
 
28
28
  mock.module('../config/loader.js', () => ({
29
29
  getConfig: () => ({
30
+ ui: {},
31
+
30
32
  provider: 'mock-provider',
31
33
  maxTokens: 4096,
32
34
  thinking: false,
@@ -69,6 +71,13 @@ let mockDbMessages: Array<{ id: string; role: string; content: string }> = [];
69
71
  let mockConversation: Record<string, unknown> | null = null;
70
72
 
71
73
  mock.module('../memory/conversation-store.js', () => ({
74
+ getConversationThreadType: () => 'default',
75
+ setConversationOriginChannelIfUnset: () => {},
76
+ updateConversationContextWindow: () => {},
77
+ deleteMessageById: () => {},
78
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
79
+ getConversationOriginInterface: () => null,
80
+ getConversationOriginChannel: () => null,
72
81
  getMessages: () => mockDbMessages,
73
82
  getConversation: () => mockConversation,
74
83
  createConversation: () => ({ id: 'conv-1' }),
@@ -120,6 +129,20 @@ mock.module('../context/window-manager.js', () => ({
120
129
  createContextSummaryMessage: () => ({ role: 'user', content: [{ type: 'text', text: 'summary' }] }),
121
130
  getSummaryFromContextMessage: () => null,
122
131
  }));
132
+ mock.module('../memory/canonical-guardian-store.js', () => ({
133
+ listPendingCanonicalGuardianRequestsByDestinationConversation: () => [],
134
+ listCanonicalGuardianRequests: () => [],
135
+ createCanonicalGuardianRequest: () => ({ id: 'mock-cg-id', code: 'MOCK', status: 'pending' }),
136
+ getCanonicalGuardianRequest: () => null,
137
+ getCanonicalGuardianRequestByCode: () => null,
138
+ updateCanonicalGuardianRequest: () => {},
139
+ resolveCanonicalGuardianRequest: () => {},
140
+ createCanonicalGuardianDelivery: () => ({ id: 'mock-cgd-id' }),
141
+ listCanonicalGuardianDeliveries: () => [],
142
+ listPendingCanonicalGuardianRequestsByDestinationChat: () => [],
143
+ updateCanonicalGuardianDelivery: () => {},
144
+ generateCanonicalRequestCode: () => 'MOCK-CODE',
145
+ }));
123
146
 
124
147
  import { Session } from '../daemon/session.js';
125
148