@vellumai/assistant 0.10.3 → 0.10.4-staging.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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Cold-cache member-reaction regression.
3
+ *
4
+ * Slack reactions carry the gateway-stamped verdict on `sourceMetadata` but
5
+ * skip `getInboundTrustVerdict`, which is what warms the member-verdict cache
6
+ * the sync trust resolver reads. `handleSlackReactionIntercept` therefore seeds
7
+ * the cache from the stamped verdict before the sync resolve, so an active
8
+ * non-guardian contact's reaction classifies as `trusted_contact` instead of
9
+ * failing closed to `unknown` (and being dropped) on a cold process.
10
+ */
11
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
12
+
13
+ const MEMBER_USER_ID = "U_MEMBER_COLD";
14
+ const MEMBER_CONTACT_ID = "member-contact";
15
+ const MEMBER_CHANNEL_ID = "member-channel";
16
+ const SLACK_CHANNEL_ID = "C0MEMBERCOLD";
17
+
18
+ const MEMBER_CONTACT = {
19
+ id: MEMBER_CONTACT_ID,
20
+ displayName: "Member",
21
+ notes: null,
22
+ lastInteraction: null,
23
+ interactionCount: 0,
24
+ createdAt: 0,
25
+ updatedAt: 0,
26
+ contactType: "human",
27
+ userFile: null,
28
+ channels: [
29
+ {
30
+ id: MEMBER_CHANNEL_ID,
31
+ contactId: MEMBER_CONTACT_ID,
32
+ type: "slack",
33
+ address: MEMBER_USER_ID,
34
+ isPrimary: false,
35
+ externalChatId: SLACK_CHANNEL_ID,
36
+ inviteId: null,
37
+ lastSeenAt: null,
38
+ interactionCount: 0,
39
+ lastInteraction: null,
40
+ updatedAt: null,
41
+ createdAt: 0,
42
+ },
43
+ ],
44
+ };
45
+
46
+ // No guardian for slack — the reactor is a member, not the guardian.
47
+ mock.module("../ipc/gateway-client.js", () => ({
48
+ ipcCall: async () => ({ guardians: [] }),
49
+ }));
50
+
51
+ // The reactor resolves to a local member channel by address.
52
+ mock.module("../contacts/contact-store.js", () => ({
53
+ findContactByAddress: (_channelType: string, address: string) =>
54
+ address === MEMBER_USER_ID ? MEMBER_CONTACT : null,
55
+ }));
56
+
57
+ // Stub downstream side effects so the test isolates trust classification.
58
+ mock.module("../memory/conversation-crud.js", () => ({
59
+ addMessage: async () => ({ id: "msg-1" }),
60
+ }));
61
+ mock.module("../memory/delivery-crud.js", () => ({
62
+ recordInbound: () => ({
63
+ eventId: "evt-1",
64
+ conversationId: "conv-1",
65
+ accepted: true,
66
+ duplicate: false,
67
+ }),
68
+ clearPayload: () => {},
69
+ linkMessage: () => {},
70
+ }));
71
+ mock.module("../memory/delivery-status.js", () => ({
72
+ markProcessed: () => {},
73
+ }));
74
+ mock.module("../memory/external-conversation-store.js", () => ({
75
+ upsertBinding: () => {},
76
+ }));
77
+ mock.module("../daemon/disk-pressure-guard.js", () => ({
78
+ getDiskPressureStatus: () => ({ level: "ok" }),
79
+ }));
80
+ mock.module("../daemon/disk-pressure-policy.js", () => ({
81
+ classifyDiskPressureTurnPolicy: () => ({ action: "allow" }),
82
+ }));
83
+
84
+ let receivedTrustClass: string | undefined;
85
+ mock.module(
86
+ "../runtime/routes/inbound-stages/guardian-reply-intercept.js",
87
+ () => ({
88
+ handleGuardianReplyIntercept: async (params: { trustClass: string }) => {
89
+ receivedTrustClass = params.trustClass;
90
+ return { response: { accepted: true, canonicalRouter: "applied" } };
91
+ },
92
+ }),
93
+ );
94
+
95
+ import { __resetMemberVerdictCacheForTest } from "../runtime/member-verdict-cache.js";
96
+ import { handleSlackReactionIntercept } from "../runtime/routes/inbound-stages/reaction-intercept.js";
97
+
98
+ function buildParams(withStampedVerdict: boolean) {
99
+ return {
100
+ callbackData: "reaction:white_check_mark",
101
+ sourceChannel: "slack" as const,
102
+ sourceInterface: "slack" as const,
103
+ conversationExternalId: SLACK_CHANNEL_ID,
104
+ externalMessageId: `${SLACK_CHANNEL_ID}:1700000000.2:cold`,
105
+ canonicalAssistantId: "assistant-1",
106
+ rawSenderId: MEMBER_USER_ID,
107
+ canonicalSenderId: MEMBER_USER_ID,
108
+ actorDisplayName: "Member",
109
+ actorUsername: undefined,
110
+ replyCallbackUrl: "http://localhost:7830/deliver/slack",
111
+ sourceMetadata: {
112
+ messageId: "1700000000.2",
113
+ chatType: "channel",
114
+ ...(withStampedVerdict
115
+ ? {
116
+ trustVerdict: {
117
+ trustClass: "trusted_contact",
118
+ canonicalSenderId: MEMBER_USER_ID,
119
+ contactId: MEMBER_CONTACT_ID,
120
+ channelId: MEMBER_CHANNEL_ID,
121
+ type: "slack",
122
+ address: MEMBER_USER_ID,
123
+ status: "active",
124
+ policy: "allow",
125
+ },
126
+ }
127
+ : {}),
128
+ } as never,
129
+ slackChannelName: "general",
130
+ approvalConversationGenerator: undefined,
131
+ };
132
+ }
133
+
134
+ describe("reaction intercept seeds the member-verdict cache from the stamped verdict", () => {
135
+ beforeEach(() => {
136
+ __resetMemberVerdictCacheForTest();
137
+ receivedTrustClass = undefined;
138
+ });
139
+
140
+ test("cold cache + stamped verdict: active member reaction classifies as trusted_contact", async () => {
141
+ const result = await handleSlackReactionIntercept(buildParams(true));
142
+
143
+ // Not dropped — the stamped verdict warmed the cache, so the sync resolve
144
+ // found the member as active.
145
+ expect(result.reaction).not.toBe("dropped_unknown_actor");
146
+ expect(receivedTrustClass).toBe("trusted_contact");
147
+ });
148
+
149
+ test("cold cache + no stamped verdict: same member reaction fails closed to unknown", async () => {
150
+ const result = await handleSlackReactionIntercept(buildParams(false));
151
+
152
+ // Negative control: with no stamped verdict and a cold cache, the member
153
+ // can't be classified, so the reaction is dropped — proving the warm above
154
+ // is load-bearing.
155
+ expect(result.reaction).toBe("dropped_unknown_actor");
156
+ expect(receivedTrustClass).toBeUndefined();
157
+ });
158
+ });
@@ -42,9 +42,39 @@ mock.module("../runtime/gateway-client.js", () => ({
42
42
  deliverChannelReply: async () => {},
43
43
  }));
44
44
 
45
+ // Guardian identity resolves via the gateway delivery cache, not the local
46
+ // contacts DB. Seed it per-test via seedGatewayGuardian so the guardian
47
+ // reactor classifies as `trustClass === "guardian"`.
48
+ interface GatewayGuardian {
49
+ channelType: string;
50
+ address: string;
51
+ principalId?: string | null;
52
+ externalChatId?: string | null;
53
+ status: string;
54
+ }
55
+ let gatewayGuardians: GatewayGuardian[] = [];
56
+ mock.module("../contacts/guardian-delivery-reader.js", () => ({
57
+ getGuardianDelivery: async () => gatewayGuardians,
58
+ peekCachedGuardianDelivery: (input?: { channelTypes?: string[] }) => {
59
+ if (!input?.channelTypes) return gatewayGuardians;
60
+ return gatewayGuardians.filter((g) =>
61
+ input.channelTypes!.includes(g.channelType),
62
+ );
63
+ },
64
+ guardianForChannel: (list: GatewayGuardian[], channelType: string) =>
65
+ list.find((g) => g.channelType === channelType && g.status === "active"),
66
+ anyGuardian: (list: GatewayGuardian[]) => list[0],
67
+ }));
68
+
69
+ function seedGatewayGuardian(g: Partial<GatewayGuardian> & {
70
+ channelType: string;
71
+ address: string;
72
+ }): void {
73
+ gatewayGuardians.push({ status: "active", ...g });
74
+ }
75
+
45
76
  import { eq } from "drizzle-orm";
46
77
 
47
- import { upsertContactChannel } from "../contacts/contacts-write.js";
48
78
  import type { Conversation } from "../daemon/conversation.js";
49
79
  import {
50
80
  createCanonicalGuardianDelivery,
@@ -60,7 +90,10 @@ import {
60
90
  isSlackReactionEvent,
61
91
  parseSlackReactionCallbackData,
62
92
  } from "../runtime/routes/inbound-stages/reaction-intercept.js";
63
- import { handleChannelInbound } from "./helpers/channel-test-adapter.js";
93
+ import {
94
+ handleChannelInbound,
95
+ seedContactChannel,
96
+ } from "./helpers/channel-test-adapter.js";
64
97
  import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
65
98
 
66
99
  await initializeDb();
@@ -81,10 +114,11 @@ function resetState(): void {
81
114
  db.run("DELETE FROM conversations");
82
115
  db.run("DELETE FROM contact_channels");
83
116
  db.run("DELETE FROM contacts");
117
+ gatewayGuardians = [];
84
118
  }
85
119
 
86
120
  function seedActiveMember(): void {
87
- upsertContactChannel({
121
+ seedContactChannel({
88
122
  sourceChannel: "slack",
89
123
  externalUserId: SLACK_USER_ID,
90
124
  externalChatId: SLACK_CHANNEL_ID,
@@ -406,6 +440,12 @@ const GUARDIAN_REACTION_TOOL = "execute_shell";
406
440
  const GUARDIAN_REACTION_INPUT = { command: "rm -rf /tmp/test" };
407
441
 
408
442
  function seedGuardianForChannel(): void {
443
+ seedGatewayGuardian({
444
+ channelType: "slack",
445
+ address: GUARDIAN_USER_ID,
446
+ principalId: GUARDIAN_USER_ID,
447
+ externalChatId: SLACK_CHANNEL_ID,
448
+ });
409
449
  createGuardianBinding({
410
450
  channel: "slack",
411
451
  guardianExternalUserId: GUARDIAN_USER_ID,
@@ -478,6 +518,7 @@ describe("guardian approval-by-reaction integration via handleChannelInbound", (
478
518
  db.run("DELETE FROM contacts");
479
519
  db.run("DELETE FROM canonical_guardian_requests");
480
520
  db.run("DELETE FROM canonical_guardian_deliveries");
521
+ gatewayGuardians = [];
481
522
  pendingInteractions.clear();
482
523
  msgCounter = 0;
483
524
  });
@@ -608,6 +649,12 @@ describe("reaction access control (no verification handshake)", () => {
608
649
  getDb().run("DELETE FROM channel_verification_sessions");
609
650
  // The assistant has a guardian (as in production); the reactors below are
610
651
  // different users.
652
+ seedGatewayGuardian({
653
+ channelType: "slack",
654
+ address: GUARDIAN_USER_ID,
655
+ principalId: GUARDIAN_USER_ID,
656
+ externalChatId: GUARDIAN_DM_CHAT,
657
+ });
611
658
  createGuardianBinding({
612
659
  channel: "slack",
613
660
  guardianExternalUserId: GUARDIAN_USER_ID,
@@ -655,7 +702,7 @@ describe("reaction access control (no verification handshake)", () => {
655
702
  // A pending contact classifies as `unverified_contact` — a known tier, so
656
703
  // its reactions are recorded. On a real message it would be re-challenged,
657
704
  // but a reaction must not trigger that.
658
- upsertContactChannel({
705
+ seedContactChannel({
659
706
  sourceChannel: "slack",
660
707
  externalUserId: CONTACT_USER_ID,
661
708
  externalChatId: SLACK_CHANNEL_ID,
@@ -26,6 +26,8 @@ import {
26
26
  test,
27
27
  } from "bun:test";
28
28
 
29
+ import { and, desc, eq } from "drizzle-orm";
30
+
29
31
  // ── Platform + logger mocks (must come before any source imports) ────
30
32
 
31
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -50,12 +52,32 @@ let inviteClaimCalls = 0;
50
52
  let inviteClaimGate: Promise<void> | null = null;
51
53
  mock.module("../ipc/gateway-client.js", () => ({
52
54
  ipcCall: async () => undefined,
53
- ipcCallPersistent: async (method: string) => {
55
+ ipcCallPersistent: async (
56
+ method: string,
57
+ params?: Record<string, unknown>,
58
+ ) => {
54
59
  if (method === "record_invite_redemption") {
55
60
  inviteClaimCalls += 1;
56
61
  if (inviteClaimGate) await inviteClaimGate;
57
62
  return { ok: true, updated: true, mirrored: true };
58
63
  }
64
+ // The gateway owns the ACL verdict; activation fails closed when the relay
65
+ // does not land, so model a verified upsert for the redemption paths.
66
+ if (method === "upsert_verified_channel") {
67
+ return {
68
+ ok: true,
69
+ verified: true,
70
+ channel: {
71
+ id: "gw-channel-id",
72
+ contactId: (params?.contactId as string) ?? "gw-contact",
73
+ type: (params?.type as string) ?? "phone",
74
+ address: (params?.address as string) ?? "gw-addr",
75
+ status: "active",
76
+ verifiedAt: 1,
77
+ verifiedVia: (params?.verifiedVia as string) ?? "invite",
78
+ },
79
+ };
80
+ }
59
81
  return undefined;
60
82
  },
61
83
  }));
@@ -97,7 +119,12 @@ mock.module("../prompts/user-reference.js", () => ({
97
119
  // the DB-seeded createGuardianBinding setup. Single mock registration lives
98
120
  // below since `mock.module` is process-global and last-write-wins in Bun.
99
121
  let mockGuardianDeliveryList:
100
- | Array<{ channelType: string; status: string; displayName: string | null }>
122
+ | Array<{
123
+ channelType: string;
124
+ status: string;
125
+ displayName?: string | null;
126
+ address?: string;
127
+ }>
101
128
  | null = null;
102
129
 
103
130
  // ── Config mock ─────────────────────────────────────────────────────
@@ -224,28 +251,54 @@ mock.module("../calls/inbound-trust-reader.js", () => ({
224
251
  //
225
252
  // Guardian identity now resolves via the gateway delivery reader. Derive the
226
253
  // list from the DB-seeded guardian bindings so the existing createGuardianBinding
227
- // setup keeps driving guardian resolution without per-test changes.
228
- const realContactStoreModule = {
229
- ...(await import("../contacts/contact-store.js")),
230
- };
231
- mock.module("../contacts/guardian-delivery-reader.js", () => ({
232
- getGuardianDelivery: async () => {
233
- // Tests that set mockGuardianDeliveryList drive the binding directly;
234
- // otherwise derive from the DB-seeded createGuardianBinding bindings.
235
- if (mockGuardianDeliveryList) return mockGuardianDeliveryList;
236
- const guardians = realContactStoreModule.listGuardianChannels();
237
- if (!guardians) return [];
238
- return guardians.channels.map((ch) => ({
239
- channelType: ch.type,
240
- contactId: guardians.contact.id,
241
- principalId: guardians.contact.principalId ?? null,
242
- displayName: guardians.contact.displayName ?? null,
243
- address: ch.address,
244
- externalChatId: ch.externalChatId ?? null,
245
- status: ch.status,
246
- verifiedAt: ch.verifiedAt ?? null,
254
+ // setup keeps driving guardian resolution without per-test changes. Both the
255
+ // async read and the sync cache peek (read by resolveActorTrust) share the same
256
+ // DB-derived snapshot mirroring the gateway's active-guardian-channel query.
257
+ function deriveGuardianDeliveries(
258
+ channelTypes?: string[],
259
+ ): Array<Record<string, unknown>> {
260
+ if (mockGuardianDeliveryList) {
261
+ return channelTypes
262
+ ? mockGuardianDeliveryList.filter((g) =>
263
+ channelTypes.includes(g.channelType as string),
264
+ )
265
+ : mockGuardianDeliveryList;
266
+ }
267
+ const rows = getDb()
268
+ .select({ contact: contacts, channel: contactChannels })
269
+ .from(contacts)
270
+ .innerJoin(contactChannels, eq(contacts.id, contactChannels.contactId))
271
+ .where(
272
+ and(eq(contacts.role, "guardian"), eq(contactChannels.status, "active")),
273
+ )
274
+ .orderBy(desc(contactChannels.verifiedAt))
275
+ .all();
276
+ if (rows.length === 0) return [];
277
+ const guardianId = rows[0].contact.id;
278
+ const list = rows
279
+ .filter((r) => r.contact.id === guardianId)
280
+ .map((r) => ({
281
+ channelType: r.channel.type,
282
+ contactId: r.contact.id,
283
+ principalId: r.contact.principalId ?? null,
284
+ displayName: r.contact.displayName ?? null,
285
+ address: r.channel.address,
286
+ externalChatId: r.channel.externalChatId ?? null,
287
+ status: r.channel.status,
288
+ verifiedAt: r.channel.verifiedAt ?? null,
247
289
  }));
248
- },
290
+ return channelTypes
291
+ ? list.filter((g) =>
292
+ channelTypes.includes(g.channelType),
293
+ )
294
+ : list;
295
+ }
296
+
297
+ mock.module("../contacts/guardian-delivery-reader.js", () => ({
298
+ getGuardianDelivery: async (input?: { channelTypes?: string[] }) =>
299
+ deriveGuardianDeliveries(input?.channelTypes),
300
+ peekCachedGuardianDelivery: (input?: { channelTypes?: string[] }) =>
301
+ deriveGuardianDeliveries(input?.channelTypes),
249
302
  guardianForChannel: (
250
303
  list: Array<{ channelType: string; status: string }>,
251
304
  channelType: string,
@@ -388,7 +441,6 @@ import {
388
441
  } from "../calls/relay-server.js";
389
442
  import { setVoiceBridgeDeps } from "../calls/voice-session-bridge.js";
390
443
  import { upsertContact } from "../contacts/contact-store.js";
391
- import { upsertContactChannel } from "../contacts/contacts-write.js";
392
444
  import {
393
445
  listCanonicalGuardianRequests,
394
446
  resolveCanonicalGuardianRequest,
@@ -402,7 +454,11 @@ import { getDb } from "../memory/db-connection.js";
402
454
  import { initializeDb } from "../memory/db-init.js";
403
455
  import { createInvite } from "../memory/invite-store.js";
404
456
  import { resetTestTables } from "../memory/raw-query.js";
405
- import { conversations } from "../memory/schema.js";
457
+ import {
458
+ contactChannels,
459
+ contacts,
460
+ conversations,
461
+ } from "../memory/schema.js";
406
462
  import {
407
463
  createOutboundSession,
408
464
  getGuardianBinding,
@@ -410,6 +466,7 @@ import {
410
466
  import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
411
467
  import { resetDbForTesting } from "./db-test-helpers.js";
412
468
  import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
469
+ import { seedContactChannel } from "./helpers/seed-contact-channel.js";
413
470
 
414
471
  await initializeDb();
415
472
 
@@ -500,7 +557,7 @@ function createTargetContact(displayName = "Test Contact"): string {
500
557
  }
501
558
 
502
559
  function addTrustedVoiceContact(phoneNumber: string): void {
503
- upsertContactChannel({
560
+ seedContactChannel({
504
561
  sourceChannel: "phone",
505
562
  externalUserId: phoneNumber,
506
563
  externalChatId: phoneNumber,
@@ -2811,7 +2868,7 @@ describe("relay-server", () => {
2811
2868
  toNumber: "+15551111111",
2812
2869
  });
2813
2870
 
2814
- upsertContactChannel({
2871
+ seedContactChannel({
2815
2872
  sourceChannel: "phone",
2816
2873
  externalUserId: "+15558886666",
2817
2874
  externalChatId: "+15558886666",
@@ -2874,7 +2931,7 @@ describe("relay-server", () => {
2874
2931
  toNumber: "+15551111111",
2875
2932
  });
2876
2933
 
2877
- upsertContactChannel({
2934
+ seedContactChannel({
2878
2935
  sourceChannel: "phone",
2879
2936
  externalUserId: "+15558887777",
2880
2937
  externalChatId: "+15558887777",
@@ -3173,7 +3230,7 @@ describe("relay-server", () => {
3173
3230
  });
3174
3231
 
3175
3232
  // Create a blocked member
3176
- upsertContactChannel({
3233
+ seedContactChannel({
3177
3234
  sourceChannel: "phone",
3178
3235
  externalUserId: "+15558881111",
3179
3236
  externalChatId: "+15558881111",
@@ -5071,7 +5128,7 @@ describe("relay-server", () => {
5071
5128
 
5072
5129
  // Gateway binding carries a different displayName
5073
5130
  mockGuardianDeliveryList = [
5074
- { channelType: "phone", status: "active", displayName: "Bob" },
5131
+ { channelType: "phone", status: "active", address: "+15550000001", displayName: "Bob" },
5075
5132
  ];
5076
5133
 
5077
5134
  ensureConversation("conv-label-user-md");
@@ -5111,7 +5168,7 @@ describe("relay-server", () => {
5111
5168
 
5112
5169
  // Gateway binding carries the guardian displayName
5113
5170
  mockGuardianDeliveryList = [
5114
- { channelType: "phone", status: "active", displayName: "Charlie" },
5171
+ { channelType: "phone", status: "active", address: "+15550000002", displayName: "Charlie" },
5115
5172
  ];
5116
5173
 
5117
5174
  ensureConversation("conv-label-contact");
@@ -44,7 +44,6 @@ mock.module("../daemon/approval-generators.js", () => ({
44
44
  createApprovalConversationGenerator: () => undefined,
45
45
  }));
46
46
 
47
- import { upsertContact } from "../contacts/contact-store.js";
48
47
  import {
49
48
  linkAttachmentToMessage,
50
49
  uploadAttachment,
@@ -58,7 +57,10 @@ import { resetTestTables } from "../memory/raw-query.js";
58
57
  import { RuntimeHttpServer } from "../runtime/http-server.js";
59
58
  import * as pendingInteractions from "../runtime/pending-interactions.js";
60
59
  import { resetDbForTesting } from "./db-test-helpers.js";
61
- import { resolveLocalTrustVerdict } from "./helpers/channel-test-adapter.js";
60
+ import {
61
+ resolveLocalTrustVerdict,
62
+ seedContactChannel,
63
+ } from "./helpers/channel-test-adapter.js";
62
64
 
63
65
  await initializeDb();
64
66
 
@@ -251,16 +253,12 @@ describe("WhatsApp channel ingress attachment resolution", () => {
251
253
  }
252
254
 
253
255
  function ensureWhatsAppContact(): void {
254
- upsertContact({
256
+ seedContactChannel({
257
+ sourceChannel: "whatsapp",
258
+ externalUserId: WHATSAPP_USER_ID,
255
259
  displayName: "WhatsApp Test User",
256
- channels: [
257
- {
258
- type: "whatsapp",
259
- address: WHATSAPP_USER_ID,
260
- status: "active",
261
- policy: "allow",
262
- },
263
- ],
260
+ status: "active",
261
+ policy: "allow",
264
262
  });
265
263
  }
266
264
 
@@ -17,6 +17,34 @@ mock.module("../util/logger.js", () => ({
17
17
  }),
18
18
  }));
19
19
 
20
+ // Guardian identity resolves via the gateway delivery cache, not the local
21
+ // contacts DB. Seed it per-test via seedGatewayGuardian; persona resolution
22
+ // joins the local contact (userFile) by the delivery's channelType + address.
23
+ interface GatewayGuardian {
24
+ channelType: string;
25
+ address: string;
26
+ status: string;
27
+ }
28
+ let gatewayGuardians: GatewayGuardian[] = [];
29
+ mock.module("../contacts/guardian-delivery-reader.js", () => ({
30
+ peekCachedGuardianDelivery: (input?: { channelTypes?: string[] }) => {
31
+ if (!input?.channelTypes) return gatewayGuardians;
32
+ return gatewayGuardians.filter((g) =>
33
+ input.channelTypes!.includes(g.channelType),
34
+ );
35
+ },
36
+ guardianForChannel: (list: GatewayGuardian[], channelType: string) =>
37
+ list.find((g) => g.channelType === channelType && g.status === "active"),
38
+ anyGuardian: (list: GatewayGuardian[]) => list[0],
39
+ }));
40
+
41
+ function seedGatewayGuardian(g: {
42
+ channelType: string;
43
+ address: string;
44
+ }): void {
45
+ gatewayGuardians.push({ status: "active", ...g });
46
+ }
47
+
20
48
  import { getSqlite } from "../memory/db-connection.js";
21
49
  import { initializeDb } from "../memory/db-init.js";
22
50
  import { BadRequestError, NotFoundError } from "../runtime/routes/errors.js";
@@ -32,6 +60,7 @@ function resetContactTables(): void {
32
60
  const sqlite = getSqlite();
33
61
  sqlite.run("DELETE FROM contact_channels");
34
62
  sqlite.run("DELETE FROM contacts");
63
+ gatewayGuardians = [];
35
64
  }
36
65
 
37
66
  // ---------------------------------------------------------------------------
@@ -76,6 +105,7 @@ describe("GET /workspace-files", () => {
76
105
  });
77
106
 
78
107
  test("with a guardian: includes users/<slug>.md", async () => {
108
+ seedGatewayGuardian({ channelType: "telegram", address: "Alice" });
79
109
  createGuardianBinding({
80
110
  channel: "telegram",
81
111
  guardianExternalUserId: "Alice",
@@ -103,6 +133,7 @@ describe("GET /workspace-files", () => {
103
133
  };
104
134
  expect(result.files.map((f) => f.path)).not.toContain("users/alice.md");
105
135
 
136
+ seedGatewayGuardian({ channelType: "telegram", address: "Alice" });
106
137
  createGuardianBinding({
107
138
  channel: "telegram",
108
139
  guardianExternalUserId: "Alice",
@@ -130,6 +161,7 @@ describe("GET /workspace-files/read", () => {
130
161
  });
131
162
 
132
163
  test("reads a guardian users/<slug>.md file", async () => {
164
+ seedGatewayGuardian({ channelType: "telegram", address: "Alice" });
133
165
  createGuardianBinding({
134
166
  channel: "telegram",
135
167
  guardianExternalUserId: "Alice",
@@ -1,9 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import {
4
- isSlackCallbackUrl,
5
- textToSlackBlocks,
6
- } from "../runtime/slack-block-formatting.js";
3
+ import { textToSlackBlocks } from "../runtime/slack-block-formatting.js";
7
4
 
8
5
  describe("textToSlackBlocks", () => {
9
6
  test("returns undefined for empty text", () => {
@@ -174,37 +171,3 @@ describe("textToSlackBlocks", () => {
174
171
  expect(section.text.text).toContain("|");
175
172
  });
176
173
  });
177
-
178
- describe("isSlackCallbackUrl", () => {
179
- test("returns true for Slack deliver URLs", () => {
180
- expect(
181
- isSlackCallbackUrl(
182
- "http://127.0.0.1:7830/deliver/slack?threadTs=123&channel=C456",
183
- ),
184
- ).toBe(true);
185
- });
186
-
187
- test("returns true for bare Slack deliver path", () => {
188
- expect(isSlackCallbackUrl("http://localhost:7830/deliver/slack")).toBe(
189
- true,
190
- );
191
- });
192
-
193
- test("returns false for non-Slack URLs", () => {
194
- expect(isSlackCallbackUrl("http://localhost:7830/deliver/telegram")).toBe(
195
- false,
196
- );
197
- });
198
-
199
- test("returns false for invalid URLs", () => {
200
- expect(isSlackCallbackUrl("not-a-url")).toBe(false);
201
- });
202
-
203
- test("returns false for managed outbound URLs", () => {
204
- expect(
205
- isSlackCallbackUrl(
206
- "http://localhost:7830/v1/internal/managed-gateway/outbound-send/?route_id=r1&assistant_id=a1&source_channel=phone",
207
- ),
208
- ).toBe(false);
209
- });
210
- });