@vellumai/assistant 0.11.8 → 0.11.9-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 (318) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/Dockerfile +8 -48
  3. package/docker-entrypoint.sh +5 -1
  4. package/docker-kata-apt-env.sh +3 -0
  5. package/docker-kata-apt-shims.sh +127 -0
  6. package/docker-kata-apt-wrapper.sh +45 -0
  7. package/docker-kata-chroot-exec.sh +35 -0
  8. package/docker-kata-pip.sh +8 -2
  9. package/docs/architecture/memory.md +8 -4
  10. package/docs/guardian-request-flow.md +18 -14
  11. package/docs/trusted-contact-access.md +1 -7
  12. package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
  13. package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  19. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  25. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
  26. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
  27. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
  28. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
  29. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
  30. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
  31. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  32. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  33. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  34. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  35. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  36. package/openapi.yaml +193 -4
  37. package/package.json +2 -2
  38. package/src/__tests__/access-request-card-view.test.ts +6 -5
  39. package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
  40. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
  41. package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
  42. package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
  43. package/src/__tests__/attachments-store.test.ts +27 -0
  44. package/src/__tests__/attachments.test.ts +43 -0
  45. package/src/__tests__/canned-reply-release.test.ts +48 -5
  46. package/src/__tests__/channel-delivery-store.test.ts +129 -18
  47. package/src/__tests__/channel-reply-delivery.test.ts +553 -93
  48. package/src/__tests__/channel-retry-sweep.test.ts +199 -0
  49. package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
  50. package/src/__tests__/conversation-error.test.ts +15 -0
  51. package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
  52. package/src/__tests__/conversation-pairing.test.ts +141 -1
  53. package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
  54. package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
  55. package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
  56. package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
  57. package/src/__tests__/conversation-sync-tags.test.ts +2 -37
  58. package/src/__tests__/credential-health-service.test.ts +98 -10
  59. package/src/__tests__/delete-propagation.test.ts +469 -0
  60. package/src/__tests__/dm-persistence.test.ts +16 -0
  61. package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
  62. package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
  63. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  64. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  65. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  66. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  67. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  68. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  69. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  70. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  71. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  72. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  73. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  74. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  75. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  76. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  77. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  78. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  79. package/src/__tests__/openai-provider.test.ts +5 -5
  80. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  81. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  82. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  83. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  84. package/src/__tests__/process-message-display-content.test.ts +20 -9
  85. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  86. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  87. package/src/__tests__/reaction-persistence.test.ts +22 -198
  88. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  89. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  90. package/src/__tests__/skill-load-tool.test.ts +27 -0
  91. package/src/__tests__/skills.test.ts +34 -1
  92. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  93. package/src/__tests__/terminal-tools.test.ts +9 -0
  94. package/src/__tests__/thread-backfill.test.ts +5 -3
  95. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  96. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  97. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  98. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  99. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  100. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  101. package/src/agent/attachments.ts +13 -2
  102. package/src/agent/loop.ts +3 -19
  103. package/src/api/README.md +9 -5
  104. package/src/api/index.ts +9 -8
  105. package/src/api/package.json +1 -0
  106. package/src/api/responses/conversation-message.ts +8 -0
  107. package/src/api/responses/home.ts +7 -18
  108. package/src/api/surfaces.ts +114 -7
  109. package/src/approvals/AGENTS.md +1 -1
  110. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  111. package/src/channels/__tests__/types.test.ts +22 -1
  112. package/src/channels/gateway-guardian-requests.ts +0 -22
  113. package/src/channels/types.ts +8 -6
  114. package/src/cli/__tests__/catalog-search-help.test.ts +12 -0
  115. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  116. package/src/cli/commands/channels/index.help.ts +15 -1
  117. package/src/cli/commands/channels/index.ts +6 -2
  118. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  119. package/src/cli/commands/db/index.help.ts +1 -1
  120. package/src/cli/commands/db/status.ts +172 -1
  121. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  122. package/src/cli/commands/platform/connect.ts +14 -5
  123. package/src/cli/commands/plugins.help.ts +6 -5
  124. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  125. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  126. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  127. package/src/cli/lib/bundled-marketplace.json +14 -0
  128. package/src/cli/lib/install-from-github.ts +9 -5
  129. package/src/cli/lib/install-from-platform.ts +12 -1
  130. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  131. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  132. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  133. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  134. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  135. package/src/config/call-site-defaults.ts +4 -1
  136. package/src/config/feature-flag-registry.json +21 -4
  137. package/src/config/schemas/memory-v3.ts +4 -3
  138. package/src/context/strip-injections.ts +17 -56
  139. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  140. package/src/conversations/message-consolidation.ts +4 -3
  141. package/src/credential-health/credential-health-service.ts +130 -0
  142. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  143. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  144. package/src/daemon/conversation-error.ts +2 -0
  145. package/src/daemon/conversation-messaging.ts +195 -47
  146. package/src/daemon/conversation-process.ts +20 -5
  147. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  148. package/src/daemon/conversation-store.ts +159 -11
  149. package/src/daemon/conversation-tool-setup.ts +9 -2
  150. package/src/daemon/conversation.ts +290 -19
  151. package/src/daemon/dictation-text-processing.ts +2 -7
  152. package/src/daemon/handlers/config-channels.ts +33 -3
  153. package/src/daemon/handlers/shared.ts +9 -0
  154. package/src/daemon/port-oversized-content.test.ts +117 -0
  155. package/src/daemon/port-oversized-content.ts +110 -0
  156. package/src/daemon/process-message.ts +87 -16
  157. package/src/daemon/reaction-record.test.ts +23 -11
  158. package/src/daemon/reaction-record.ts +6 -9
  159. package/src/documents/document-store.ts +1 -5
  160. package/src/home/conversation-starter-validation.ts +1 -5
  161. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  162. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  163. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  164. package/src/live-voice/live-voice-photo.ts +544 -61
  165. package/src/live-voice/live-voice-session.ts +6 -2
  166. package/src/live-voice/protocol.ts +20 -1
  167. package/src/messaging/provider-message-metadata.ts +40 -1
  168. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  169. package/src/messaging/providers/channel-transport.ts +28 -0
  170. package/src/messaging/providers/discord/send.ts +3 -2
  171. package/src/messaging/providers/slack/api.ts +11 -1
  172. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  173. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  174. package/src/messaging/providers/slack/send.test.ts +58 -0
  175. package/src/messaging/providers/slack/send.ts +4 -4
  176. package/src/messaging/providers/slack/transport.ts +28 -2
  177. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  178. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  179. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  180. package/src/messaging/reaction-envelopes.test.ts +73 -0
  181. package/src/messaging/reaction-envelopes.ts +33 -5
  182. package/src/messaging/read-provider-metadata.ts +4 -3
  183. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  184. package/src/monitoring/recovery/db.ts +35 -0
  185. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  186. package/src/monitoring/recovery/run-recovery.ts +6 -2
  187. package/src/monitoring/recovery/stale-processing.ts +3 -20
  188. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  189. package/src/notifications/AGENTS.md +2 -2
  190. package/src/notifications/README.md +2 -2
  191. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  192. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  193. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  194. package/src/notifications/access-request-copy.ts +126 -114
  195. package/src/notifications/adapters/discord.ts +9 -5
  196. package/src/notifications/adapters/shared.ts +17 -4
  197. package/src/notifications/adapters/slack.ts +14 -4
  198. package/src/notifications/adapters/telegram.ts +8 -4
  199. package/src/notifications/approval-card-data.ts +5 -2
  200. package/src/notifications/assistant-reply-producer.ts +7 -7
  201. package/src/notifications/broadcaster.ts +57 -25
  202. package/src/notifications/conversation-pairing.ts +68 -2
  203. package/src/notifications/copy-composer.ts +13 -32
  204. package/src/notifications/decision-engine.ts +56 -237
  205. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  206. package/src/notifications/guardian-feed-projection.ts +0 -15
  207. package/src/notifications/guardian-question-mode.ts +147 -6
  208. package/src/notifications/notification-utils.ts +117 -1
  209. package/src/permissions/confirmation-guardian-request.ts +2 -2
  210. package/src/permissions/prompter.ts +1 -1
  211. package/src/persistence/conversation-crud.ts +93 -14
  212. package/src/persistence/conversation-queries.ts +144 -17
  213. package/src/persistence/conversation-types.ts +39 -5
  214. package/src/persistence/delivery-crud.ts +224 -28
  215. package/src/persistence/delivery-status.ts +21 -2
  216. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  217. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  218. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  219. package/src/persistence/schema/conversations.ts +71 -2
  220. package/src/persistence/schema-contract.test.ts +78 -0
  221. package/src/persistence/schema-contract.ts +96 -0
  222. package/src/persistence/steps.ts +4 -0
  223. package/src/platform/client.ts +55 -0
  224. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  225. package/src/plugins/defaults/injector-order.ts +2 -1
  226. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  227. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  228. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  229. package/src/plugins/defaults/memory/injectors.ts +1 -1
  230. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  231. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  232. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  233. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  234. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  235. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  236. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  237. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  238. package/src/plugins/mcp-servers.ts +14 -2
  239. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  240. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  241. package/src/providers/anthropic/client.ts +21 -24
  242. package/src/providers/call-site-routing.ts +5 -4
  243. package/src/providers/connection-resolution.ts +29 -1
  244. package/src/providers/content-block-size.test.ts +82 -0
  245. package/src/providers/content-block-size.ts +99 -0
  246. package/src/providers/file-block-text.test.ts +64 -0
  247. package/src/providers/file-block-text.ts +28 -0
  248. package/src/providers/gemini/client.ts +12 -7
  249. package/src/providers/inference/auth.ts +6 -6
  250. package/src/providers/media-resolve.ts +14 -0
  251. package/src/providers/model-catalog.ts +31 -34
  252. package/src/providers/openai/chat-completions-provider.ts +9 -25
  253. package/src/providers/openai/responses-provider.ts +11 -18
  254. package/src/providers/registry.ts +10 -7
  255. package/src/providers/routing-identity.ts +2 -1
  256. package/src/providers/types.ts +1 -4
  257. package/src/providers/vellum-model-routing.ts +3 -2
  258. package/src/runtime/AGENTS.md +40 -15
  259. package/src/runtime/approval-message-composer.ts +1 -6
  260. package/src/runtime/assistant-event-hub.ts +2 -39
  261. package/src/runtime/channel-approval-types.ts +1 -5
  262. package/src/runtime/channel-reply-delivery.ts +182 -115
  263. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  264. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  265. package/src/runtime/channel-retry-sweep.ts +102 -1
  266. package/src/runtime/finalize-event-delivery.ts +4 -4
  267. package/src/runtime/guardian-action-message-composer.ts +1 -4
  268. package/src/runtime/guardian-reply-router.ts +4 -75
  269. package/src/runtime/http-router.ts +1 -5
  270. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  271. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  272. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  273. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  274. package/src/runtime/routes/acp-routes.ts +1 -1
  275. package/src/runtime/routes/canned-reply-release.ts +19 -9
  276. package/src/runtime/routes/channel-route-shared.ts +0 -39
  277. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  278. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  279. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  280. package/src/runtime/routes/conversation-routes.ts +138 -59
  281. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  282. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  283. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  284. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  285. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  286. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  287. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  288. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  289. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  290. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  291. package/src/runtime/routes/index.ts +2 -0
  292. package/src/runtime/routes/platform-routes.ts +99 -10
  293. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  294. package/src/runtime/sync/resource-sync-events.ts +14 -37
  295. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  296. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  297. package/src/tools/client-os.ts +11 -1
  298. package/src/tools/host-filesystem/edit.ts +2 -2
  299. package/src/tools/host-filesystem/read.ts +2 -2
  300. package/src/tools/host-filesystem/transfer.ts +2 -2
  301. package/src/tools/host-filesystem/write.ts +2 -2
  302. package/src/tools/host-terminal/host-shell.ts +2 -2
  303. package/src/tools/skills/load.ts +1 -1
  304. package/src/tools/terminal/safe-env.ts +4 -0
  305. package/src/tools/tool-input-schemas.ts +2 -0
  306. package/src/tools/tool-manifest.ts +2 -0
  307. package/src/tools/ui-surface/definitions.ts +4 -3
  308. package/src/tools/watch/watch-retro-report.ts +205 -0
  309. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  310. package/src/watch/watch-retro.ts +190 -77
  311. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  312. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  313. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  314. package/src/workspace/migrations/registry.ts +4 -0
  315. package/docker-kata-pip-chroot.sh +0 -22
  316. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  317. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  318. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -16,10 +16,23 @@ mock.module("../config/env.js", () => ({
16
16
  getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
17
17
  }));
18
18
 
19
+ // A client showing the conversation learns of the deletion through the
20
+ // messages-changed invalidation; capture it rather than opening the SSE hub.
21
+ const publishedConversationIds: string[] = [];
22
+ const actualSyncEvents =
23
+ await import("../runtime/sync/resource-sync-events.js");
24
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
25
+ ...actualSyncEvents,
26
+ publishConversationMessagesChanged: (conversationId: string) => {
27
+ publishedConversationIds.push(conversationId);
28
+ },
29
+ }));
30
+
19
31
  import { eq } from "drizzle-orm";
20
32
 
21
33
  import {
22
34
  readSlackMetadata,
35
+ readSlackMetadataFromMessageMetadata,
23
36
  writeSlackMetadata,
24
37
  } from "../messaging/providers/slack/message-metadata.js";
25
38
  import { getDb } from "../persistence/db-connection.js";
@@ -43,6 +56,7 @@ const SLACK_DELETE_ACTOR_ID = "U_DELETE_ACTOR";
43
56
  const SLACK_DELETE_ACTOR_DISPLAY_NAME = "Delete Actor";
44
57
 
45
58
  function resetState(): void {
59
+ publishedConversationIds.length = 0;
46
60
  const db = getDb();
47
61
  db.run("DELETE FROM channel_inbound_events");
48
62
  db.run("DELETE FROM messages");
@@ -223,6 +237,307 @@ describe("Discord delete propagation (unattributed)", () => {
223
237
  expect(neutral!.deletedAt).toBeDefined();
224
238
  });
225
239
 
240
+ test("deleting the assistant's own post stamps its row via the stored provider id", async () => {
241
+ // A moderator removing the assistant's reply: the post opened no inbound
242
+ // event, so resolution can only go through the provider id its row
243
+ // carries (stamped at reserve, reconciled at delivery). The retry window
244
+ // is not paid: no inbound event exists to wait on.
245
+ const chatId = "999888777666555444";
246
+ const sentId = "111222333444555777";
247
+ const minted = recordInbound("discord", chatId, "evt-earlier-user-msg");
248
+ const messageId = "assistant-reply-1";
249
+ getDb()
250
+ .insert(messages)
251
+ .values({
252
+ id: messageId,
253
+ conversationId: minted.conversationId,
254
+ role: "assistant",
255
+ content: "The reply a moderator later removed",
256
+ metadata: JSON.stringify({
257
+ assistantMessageChannel: "discord",
258
+ providerMeta: JSON.stringify({
259
+ source: "discord",
260
+ conversationExternalId: chatId,
261
+ messageId: sentId,
262
+ eventKind: "message",
263
+ }),
264
+ }),
265
+ createdAt: Date.now(),
266
+ })
267
+ .run();
268
+
269
+ const req = new Request("http://localhost:8080/channels/inbound", {
270
+ method: "POST",
271
+ headers: {
272
+ "Content-Type": "application/json",
273
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
274
+ },
275
+ body: JSON.stringify({
276
+ sourceChannel: "discord",
277
+ interface: "discord",
278
+ conversationExternalId: chatId,
279
+ externalMessageId: `${sentId}:delete`,
280
+ eventKind: "delete",
281
+ content: "",
282
+ actorExternalId: "discord-system",
283
+ sourceMetadata: {
284
+ messageId: sentId,
285
+ actorUnattributed: true,
286
+ },
287
+ }),
288
+ });
289
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
290
+ const json = (await resp.json()) as Record<string, unknown>;
291
+
292
+ expect(json.accepted).toBe(true);
293
+ expect(json.deleted).toBe(true);
294
+ expect(json.messageId).toBe(messageId);
295
+
296
+ const row = getDb()
297
+ .select()
298
+ .from(messages)
299
+ .where(eq(messages.id, messageId))
300
+ .get();
301
+ // Content survives: the stamp records loss of channel visibility, not an
302
+ // erasure of what the assistant said.
303
+ expect(row!.content).toBe("The reply a moderator later removed");
304
+ const neutral = readProviderMetadata(row!.metadata);
305
+ expect(neutral!.deletedAt).toBeDefined();
306
+ expect(neutral!.messageId).toBe(sentId);
307
+ expect(publishedConversationIds).toEqual([minted.conversationId]);
308
+ });
309
+
310
+ test("deleting one post of a split reply keeps the surviving posts live", async () => {
311
+ const chatId = "999888777666555444";
312
+ const minted = recordInbound("discord", chatId, "evt-split-seed");
313
+ const primaryId = "111222333444555801";
314
+ const additionalId = "111222333444555802";
315
+ getDb()
316
+ .insert(messages)
317
+ .values({
318
+ id: "assistant-split-reply",
319
+ conversationId: minted.conversationId,
320
+ role: "assistant",
321
+ content: "part one and part two",
322
+ metadata: JSON.stringify({
323
+ providerMeta: JSON.stringify({
324
+ source: "discord",
325
+ conversationExternalId: chatId,
326
+ messageId: primaryId,
327
+ additionalMessageIds: [additionalId],
328
+ eventKind: "message",
329
+ }),
330
+ }),
331
+ createdAt: Date.now(),
332
+ })
333
+ .run();
334
+
335
+ const deleteReq = (postId: string) =>
336
+ new Request("http://localhost:8080/channels/inbound", {
337
+ method: "POST",
338
+ headers: {
339
+ "Content-Type": "application/json",
340
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
341
+ },
342
+ body: JSON.stringify({
343
+ sourceChannel: "discord",
344
+ interface: "discord",
345
+ conversationExternalId: chatId,
346
+ externalMessageId: `${postId}:delete`,
347
+ eventKind: "delete",
348
+ content: "",
349
+ actorExternalId: "discord-system",
350
+ sourceMetadata: { messageId: postId, actorUnattributed: true },
351
+ }),
352
+ });
353
+
354
+ // Deleting the ADDITIONAL post: tracked per id, the row stays visible.
355
+ const first = await handleChannelInbound(
356
+ deleteReq(additionalId),
357
+ undefined,
358
+ TEST_BEARER_TOKEN,
359
+ );
360
+ expect(((await first.json()) as Record<string, unknown>).deleted).toBe(
361
+ true,
362
+ );
363
+ const afterFirst = readProviderMetadata(
364
+ getDb()
365
+ .select()
366
+ .from(messages)
367
+ .where(eq(messages.id, "assistant-split-reply"))
368
+ .get()!.metadata,
369
+ );
370
+ expect(afterFirst!.deletedMessageIds).toEqual([additionalId]);
371
+ expect(afterFirst!.deletedAt).toBeUndefined();
372
+
373
+ // Deleting the PRIMARY post too: every post is gone, the row-level
374
+ // marker lands.
375
+ const second = await handleChannelInbound(
376
+ deleteReq(primaryId),
377
+ undefined,
378
+ TEST_BEARER_TOKEN,
379
+ );
380
+ expect(((await second.json()) as Record<string, unknown>).deleted).toBe(
381
+ true,
382
+ );
383
+ const afterSecond = readProviderMetadata(
384
+ getDb()
385
+ .select()
386
+ .from(messages)
387
+ .where(eq(messages.id, "assistant-split-reply"))
388
+ .get()!.metadata,
389
+ );
390
+ expect(new Set(afterSecond!.deletedMessageIds)).toEqual(
391
+ new Set([additionalId, primaryId]),
392
+ );
393
+ expect(afterSecond!.deletedAt).toBeDefined();
394
+ });
395
+
396
+ test("a delete racing the outbound id reconciliation resolves on retry", async () => {
397
+ _setDeleteLookupConfigForTests(3, 30);
398
+ const chatId = "999888777666555444";
399
+ const sentId = "111222333444555803";
400
+ const minted = recordInbound("discord", chatId, "evt-race-seed");
401
+ // The pre-send stamp: the envelope names no id yet, exactly the state a
402
+ // fast automated deletion races.
403
+ getDb()
404
+ .insert(messages)
405
+ .values({
406
+ id: "assistant-racing-reply",
407
+ conversationId: minted.conversationId,
408
+ role: "assistant",
409
+ content: "posted a moment ago",
410
+ metadata: JSON.stringify({
411
+ providerMeta: JSON.stringify({
412
+ source: "discord",
413
+ conversationExternalId: chatId,
414
+ eventKind: "message",
415
+ }),
416
+ }),
417
+ createdAt: Date.now(),
418
+ })
419
+ .run();
420
+ // The post-send reconciliation lands while the delete lookup is in its
421
+ // backoff window.
422
+ setTimeout(() => {
423
+ getDb()
424
+ .update(messages)
425
+ .set({
426
+ metadata: JSON.stringify({
427
+ providerMeta: JSON.stringify({
428
+ source: "discord",
429
+ conversationExternalId: chatId,
430
+ messageId: sentId,
431
+ eventKind: "message",
432
+ }),
433
+ }),
434
+ })
435
+ .where(eq(messages.id, "assistant-racing-reply"))
436
+ .run();
437
+ }, 45);
438
+
439
+ const resp = await handleChannelInbound(
440
+ new Request("http://localhost:8080/channels/inbound", {
441
+ method: "POST",
442
+ headers: {
443
+ "Content-Type": "application/json",
444
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
445
+ },
446
+ body: JSON.stringify({
447
+ sourceChannel: "discord",
448
+ interface: "discord",
449
+ conversationExternalId: chatId,
450
+ externalMessageId: `${sentId}:delete`,
451
+ eventKind: "delete",
452
+ content: "",
453
+ actorExternalId: "discord-system",
454
+ sourceMetadata: { messageId: sentId, actorUnattributed: true },
455
+ }),
456
+ }),
457
+ undefined,
458
+ TEST_BEARER_TOKEN,
459
+ );
460
+ expect(((await resp.json()) as Record<string, unknown>).deleted).toBe(true);
461
+ const meta = readProviderMetadata(
462
+ getDb()
463
+ .select()
464
+ .from(messages)
465
+ .where(eq(messages.id, "assistant-racing-reply"))
466
+ .get()!.metadata,
467
+ );
468
+ expect(meta!.deletedAt).toBeDefined();
469
+ });
470
+
471
+ test("deleting an old post beyond the recency window resolves via the deep scan", async () => {
472
+ const chatId = "999888777666555444";
473
+ const oldId = "111222333444555804";
474
+ const minted = recordInbound("discord", chatId, "evt-old-seed");
475
+ const db = getDb();
476
+ db.insert(messages)
477
+ .values({
478
+ id: "assistant-old-post",
479
+ conversationId: minted.conversationId,
480
+ role: "assistant",
481
+ content: "an old reply",
482
+ metadata: JSON.stringify({
483
+ providerMeta: JSON.stringify({
484
+ source: "discord",
485
+ conversationExternalId: chatId,
486
+ messageId: oldId,
487
+ eventKind: "message",
488
+ }),
489
+ }),
490
+ createdAt: 1_000_000,
491
+ })
492
+ .run();
493
+ // Bury it under more metadata-bearing rows than the recency-capped
494
+ // reaction lookup examines.
495
+ const insert = db.$client.prepare(
496
+ `INSERT INTO messages (id, conversation_id, role, content, created_at, metadata)
497
+ VALUES (?, ?, 'user', 'filler', ?, ?)`,
498
+ );
499
+ for (let i = 0; i < 450; i++) {
500
+ insert.run(
501
+ `filler-${i}`,
502
+ minted.conversationId,
503
+ 2_000_000 + i,
504
+ JSON.stringify({
505
+ providerMeta: JSON.stringify({
506
+ source: "discord",
507
+ conversationExternalId: chatId,
508
+ messageId: `filler-post-${i}`,
509
+ eventKind: "message",
510
+ }),
511
+ }),
512
+ );
513
+ }
514
+
515
+ const resp = await handleChannelInbound(
516
+ new Request("http://localhost:8080/channels/inbound", {
517
+ method: "POST",
518
+ headers: {
519
+ "Content-Type": "application/json",
520
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
521
+ },
522
+ body: JSON.stringify({
523
+ sourceChannel: "discord",
524
+ interface: "discord",
525
+ conversationExternalId: chatId,
526
+ externalMessageId: `${oldId}:delete`,
527
+ eventKind: "delete",
528
+ content: "",
529
+ actorExternalId: "discord-system",
530
+ sourceMetadata: { messageId: oldId, actorUnattributed: true },
531
+ }),
532
+ }),
533
+ undefined,
534
+ TEST_BEARER_TOKEN,
535
+ );
536
+ const json = (await resp.json()) as Record<string, unknown>;
537
+ expect(json.deleted).toBe(true);
538
+ expect(json.messageId).toBe("assistant-old-post");
539
+ });
540
+
226
541
  test("an unattributed delete for a never-ingested message is a no-op", async () => {
227
542
  const req = new Request("http://localhost:8080/channels/inbound", {
228
543
  method: "POST",
@@ -313,6 +628,160 @@ describe("Slack delete propagation", () => {
313
628
  expect(slackMeta!.channelTs).toBe("1234.5678");
314
629
  expect(slackMeta!.eventKind).toBe("message");
315
630
  expect(slackMeta!.displayName).toBe("Test User");
631
+ // A client showing the conversation is told to refetch.
632
+ expect(publishedConversationIds).toEqual([seeded.conversationId]);
633
+ });
634
+
635
+ test("deleting the assistant's own Slack post is recorded without an actor", async () => {
636
+ // Slack names a deleted post's author and never who deleted it, so the
637
+ // gateway forwards a deletion of the assistant's own post unattributed
638
+ // (LUM-3521). The post opened no inbound event; the row resolves through
639
+ // the `channelTs` its Slack envelope carries, and the stamp lands on
640
+ // that envelope, where the Slack renderers read it.
641
+ const chatId = "C0123CHANNEL";
642
+ const postTs = "1725100000.000900";
643
+ const minted = recordInbound("slack", chatId, "evt-earlier-user-msg");
644
+ const messageId = "assistant-slack-reply-1";
645
+ getDb()
646
+ .insert(messages)
647
+ .values({
648
+ id: messageId,
649
+ conversationId: minted.conversationId,
650
+ role: "assistant",
651
+ content: "The reply that was later deleted from the thread",
652
+ metadata: JSON.stringify({
653
+ assistantMessageChannel: "slack",
654
+ slackMeta: writeSlackMetadata({
655
+ source: "slack",
656
+ channelId: chatId,
657
+ channelTs: postTs,
658
+ threadTs: "1725100000.000100",
659
+ eventKind: "message",
660
+ }),
661
+ }),
662
+ createdAt: Date.now(),
663
+ })
664
+ .run();
665
+
666
+ const req = new Request("http://localhost:8080/channels/inbound", {
667
+ method: "POST",
668
+ headers: {
669
+ "Content-Type": "application/json",
670
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
671
+ },
672
+ body: JSON.stringify({
673
+ sourceChannel: "slack",
674
+ interface: "slack",
675
+ conversationExternalId: chatId,
676
+ externalMessageId: "evt-del-own-post",
677
+ eventKind: "delete",
678
+ content: "",
679
+ actorExternalId: "slack-system",
680
+ sourceMetadata: {
681
+ messageId: postTs,
682
+ threadId: "1725100000.000100",
683
+ actorUnattributed: true,
684
+ },
685
+ }),
686
+ });
687
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
688
+ const json = (await resp.json()) as Record<string, unknown>;
689
+
690
+ expect(json.accepted).toBe(true);
691
+ expect(json.deleted).toBe(true);
692
+ expect(json.messageId).toBe(messageId);
693
+
694
+ const row = getDb()
695
+ .select()
696
+ .from(messages)
697
+ .where(eq(messages.id, messageId))
698
+ .get();
699
+ // Content survives: the stamp records loss of channel visibility, not an
700
+ // erasure of what the assistant said.
701
+ expect(row!.content).toBe(
702
+ "The reply that was later deleted from the thread",
703
+ );
704
+ const parsed = JSON.parse(row!.metadata!) as Record<string, unknown>;
705
+ const slackMeta = readSlackMetadata(parsed.slackMeta as string);
706
+ expect(slackMeta!.deletedAt).toBeDefined();
707
+ expect(slackMeta!.channelTs).toBe(postTs);
708
+ expect(readProviderMetadata(row!.metadata)!.deletedAt).toBeDefined();
709
+ });
710
+
711
+ test("deleting the assistant's own Slack post stamps its neutral envelope per post", async () => {
712
+ // A Slack reply row carries the neutral envelope every channel writes,
713
+ // so its deletion takes the same per-post path as any channel's: the
714
+ // gateway forwards the delete unattributed (Slack names the author,
715
+ // never the deleter), the row resolves through the id its envelope
716
+ // names, and the Slack renderers read the stamp through the Slack view.
717
+ const chatId = "C0123CHANNEL";
718
+ const postTs = "1725100000.000900";
719
+ const minted = recordInbound("slack", chatId, "evt-earlier-user-msg");
720
+ const messageId = "assistant-slack-reply-1";
721
+ getDb()
722
+ .insert(messages)
723
+ .values({
724
+ id: messageId,
725
+ conversationId: minted.conversationId,
726
+ role: "assistant",
727
+ content: "The reply that was later deleted from the thread",
728
+ metadata: JSON.stringify({
729
+ assistantMessageChannel: "slack",
730
+ providerMeta: JSON.stringify({
731
+ source: "slack",
732
+ conversationExternalId: chatId,
733
+ messageId: postTs,
734
+ threadId: "1725100000.000100",
735
+ eventKind: "message",
736
+ timestampTimezone: "America/New_York",
737
+ }),
738
+ }),
739
+ createdAt: Date.now(),
740
+ })
741
+ .run();
742
+
743
+ const req = new Request("http://localhost:8080/channels/inbound", {
744
+ method: "POST",
745
+ headers: {
746
+ "Content-Type": "application/json",
747
+ "X-Gateway-Origin": TEST_BEARER_TOKEN,
748
+ },
749
+ body: JSON.stringify({
750
+ sourceChannel: "slack",
751
+ interface: "slack",
752
+ conversationExternalId: chatId,
753
+ externalMessageId: "evt-del-own-post",
754
+ eventKind: "delete",
755
+ content: "",
756
+ actorExternalId: "slack-system",
757
+ sourceMetadata: {
758
+ messageId: postTs,
759
+ threadId: "1725100000.000100",
760
+ actorUnattributed: true,
761
+ },
762
+ }),
763
+ });
764
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
765
+ const json = (await resp.json()) as Record<string, unknown>;
766
+ expect(json.accepted).toBe(true);
767
+ expect(json.deleted).toBe(true);
768
+ expect(json.messageId).toBe(messageId);
769
+
770
+ const row = getDb()
771
+ .select()
772
+ .from(messages)
773
+ .where(eq(messages.id, messageId))
774
+ .get();
775
+ expect(row!.content).toBe(
776
+ "The reply that was later deleted from the thread",
777
+ );
778
+ const neutral = readProviderMetadata(row!.metadata);
779
+ expect(neutral!.deletedMessageIds).toEqual([postTs]);
780
+ expect(neutral!.deletedAt).toBeDefined();
781
+ const view = readSlackMetadataFromMessageMetadata(row!.metadata);
782
+ expect(view!.channelTs).toBe(postTs);
783
+ expect(view!.deletedAt).toBe(neutral!.deletedAt);
784
+ expect(view!.timestampTimezone).toBe("America/New_York");
316
785
  });
317
786
 
318
787
  test("delete for unknown ts is a no-op", async () => {
@@ -92,6 +92,7 @@ function createSlackTurnContext(): MessagingConversationContext {
92
92
  size: () => 0,
93
93
  } as unknown as MessageQueue;
94
94
  let processing = false;
95
+ let owner = 0;
95
96
  return {
96
97
  conversationId: "conv-dm-test",
97
98
  messages: [],
@@ -99,6 +100,21 @@ function createSlackTurnContext(): MessagingConversationContext {
99
100
  setProcessing: (value: boolean) => {
100
101
  processing = value;
101
102
  },
103
+ acquireProcessingFenced: async () => {
104
+ if (processing) {
105
+ return null;
106
+ }
107
+ processing = true;
108
+ owner += 1;
109
+ return owner;
110
+ },
111
+ releaseProcessing: (claim: number) => {
112
+ if (claim !== owner) {
113
+ return false;
114
+ }
115
+ processing = false;
116
+ return true;
117
+ },
102
118
  abortController: null,
103
119
  queue: queueStub,
104
120
  getTurnChannelContext: () => channel,
@@ -0,0 +1,135 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { basename, join } from "node:path";
13
+ import { afterEach, describe, expect, test } from "bun:test";
14
+
15
+ const scriptPath = join(
16
+ import.meta.dir,
17
+ "..",
18
+ "..",
19
+ "docker-kata-apt-shims.sh",
20
+ );
21
+ const testRoots: string[] = [];
22
+
23
+ afterEach(() => {
24
+ for (const root of testRoots.splice(0)) {
25
+ rmSync(root, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ function writeExecutable(path: string, contents: string): void {
30
+ writeFileSync(path, contents);
31
+ chmodSync(path, 0o755);
32
+ }
33
+
34
+ function createDataRoot(): string {
35
+ const dataRoot = mkdtempSync(join(tmpdir(), "kata-apt-shims-"));
36
+ testRoots.push(dataRoot);
37
+ for (const dir of [
38
+ "usr/bin",
39
+ "usr/sbin",
40
+ "usr/games",
41
+ "usr/local/bin",
42
+ "usr/local/sbin",
43
+ "var/lib/dpkg",
44
+ ]) {
45
+ mkdirSync(join(dataRoot, dir), { recursive: true });
46
+ }
47
+ writeFileSync(join(dataRoot, "var/lib/dpkg/status"), "unchanged\n");
48
+ return dataRoot;
49
+ }
50
+
51
+ function refreshShims(dataRoot: string, pathPrefix?: string): void {
52
+ execFileSync("/bin/sh", [scriptPath], {
53
+ env: {
54
+ ...process.env,
55
+ PATH: pathPrefix
56
+ ? `${pathPrefix}:${process.env.PATH ?? ""}`
57
+ : process.env.PATH,
58
+ VELLUM_APT_DATA_ROOT: dataRoot,
59
+ },
60
+ });
61
+ }
62
+
63
+ describe("docker-kata-apt-shims", () => {
64
+ test.skipIf(process.platform !== "linux")(
65
+ "refreshes a shim when a same-version reinstall retargets its trampoline",
66
+ () => {
67
+ const dataRoot = createDataRoot();
68
+
69
+ const suffix = basename(dataRoot);
70
+ const firstTarget = `/vellum-apt-shim-test/${suffix}-1`;
71
+ const secondTarget = `/vellum-apt-shim-test/${suffix}-2`;
72
+ const source = join(dataRoot, "usr/bin/example-tool");
73
+ const shim = join(dataRoot, ".host-shims/example-tool");
74
+
75
+ for (const target of [firstTarget, secondTarget]) {
76
+ const chrootTarget = `${dataRoot}${target}`;
77
+ mkdirSync(join(chrootTarget, ".."), { recursive: true });
78
+ writeExecutable(chrootTarget, "#!/bin/sh\nexit 0\n");
79
+ }
80
+
81
+ writeExecutable(source, `#!/bin/sh\nexec ${firstTarget} "$@"\n`);
82
+ refreshShims(dataRoot);
83
+ expect(readFileSync(shim, "utf8")).toContain(firstTarget);
84
+
85
+ writeExecutable(source, `#!/bin/sh\nexec ${secondTarget} "$@"\n`);
86
+ const fakeBin = join(dataRoot, "fake-bin");
87
+ const failedOnce = join(dataRoot, "mv-failed-once");
88
+ mkdirSync(fakeBin);
89
+ writeExecutable(
90
+ join(fakeBin, "mv"),
91
+ `#!/bin/sh
92
+ if [ ! -e "${failedOnce}" ]; then
93
+ touch "${failedOnce}"
94
+ exit 91
95
+ fi
96
+ [ -f "$3" ] || exit 92
97
+ exec /bin/mv "$@"
98
+ `,
99
+ );
100
+
101
+ expect(() => refreshShims(dataRoot, fakeBin)).toThrow();
102
+ expect(readFileSync(shim, "utf8")).toContain(firstTarget);
103
+ refreshShims(dataRoot, fakeBin);
104
+
105
+ const refreshedShim = readFileSync(shim, "utf8");
106
+ expect(refreshedShim).toContain(secondTarget);
107
+ expect(refreshedShim).not.toContain(firstTarget);
108
+ },
109
+ );
110
+
111
+ test.skipIf(process.platform !== "linux")(
112
+ "ignores non-executable entries and refreshes when they become runnable",
113
+ () => {
114
+ const dataRoot = createDataRoot();
115
+ const suffix = basename(dataRoot);
116
+ const target = `/vellum-apt-shim-test/${suffix}`;
117
+ const chrootTarget = `${dataRoot}${target}`;
118
+ const lowerSource = join(dataRoot, "usr/sbin/example-tool");
119
+ const higherSource = join(dataRoot, "usr/local/bin/example-tool");
120
+ const shim = join(dataRoot, ".host-shims/example-tool");
121
+
122
+ mkdirSync(join(chrootTarget, ".."), { recursive: true });
123
+ writeExecutable(chrootTarget, "#!/bin/sh\nexit 0\n");
124
+ writeExecutable(lowerSource, `#!/bin/sh\nexec ${target} "$@"\n`);
125
+ writeFileSync(higherSource, "#!/bin/sh\nexit 0\n", { mode: 0o644 });
126
+
127
+ refreshShims(dataRoot);
128
+ expect(readFileSync(shim, "utf8")).toContain(target);
129
+
130
+ chmodSync(higherSource, 0o755);
131
+ refreshShims(dataRoot);
132
+ expect(existsSync(shim)).toBe(false);
133
+ },
134
+ );
135
+ });
@@ -34,11 +34,23 @@ describe("forbidden legacy symbols", () => {
34
34
  "twilio_webhook_config",
35
35
  "calls.webhookBaseUrl",
36
36
  ];
37
+ // This pattern is POSIX ERE for `git grep -E`, not a JavaScript RegExp:
38
+ // ERE has no `\x` escapes, so `RegExp.escape` would turn the pattern
39
+ // into one that matches nothing and the test would pass empty.
37
40
  const escapedPattern = forbiddenSymbols
38
41
  .map((symbol) => symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
39
42
  .join("|");
40
43
 
41
44
  const repoRoot = resolve(__dirname, "..", "..", "..");
45
+ // Positive control: the pattern must still match the symbols as written
46
+ // in this very file, so a broken pattern fails loudly instead of
47
+ // matching nothing.
48
+ const selfMatches = execSync(
49
+ `git grep -c -E "${escapedPattern}" -- "assistant/src/__tests__/forbidden-legacy-symbols.test.ts"`,
50
+ { cwd: repoRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
51
+ );
52
+ expect(selfMatches.trim()).not.toBe("");
53
+
42
54
  let matches = "";
43
55
  try {
44
56
  // Use git grep so only tracked files are searched. This automatically