@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
@@ -0,0 +1,208 @@
1
+ import { rmSync } from "node:fs";
2
+ import { beforeEach, describe, expect, test } from "bun:test";
3
+
4
+ import { getDb, getSqliteFrom } from "../../../persistence/db-connection.js";
5
+ import { initializeDb } from "../../../persistence/db-init.js";
6
+ import {
7
+ getDaemonBootTimePath,
8
+ recordDaemonBootTime,
9
+ } from "../../daemon-boot-time.js";
10
+ import { recoverStrandedDeliveryEvents } from "../stranded-delivery-events.js";
11
+
12
+ await initializeDb();
13
+
14
+ /** Payload shape of an event whose turn ran and whose delivery is owed. */
15
+ const DELIVERABLE_PAYLOAD = JSON.stringify({
16
+ content: "hello",
17
+ replyCallbackUrl: "https://gateway.local/deliver/slack?threadTs=1.2",
18
+ externalChatId: "C123",
19
+ replyMessageId: "assistant-row-1",
20
+ });
21
+
22
+ /** Payload shape of an intercept-settled event (no reply delivery owed). */
23
+ const INTERCEPT_PAYLOAD = JSON.stringify({
24
+ content: "reaction",
25
+ replyCallbackUrl: "https://gateway.local/deliver/slack?threadTs=1.2",
26
+ });
27
+
28
+ // Seed rows via raw SQL rather than delivery-crud / conversation-crud helpers:
29
+ // those modules are `mock.module`-replaced by other test files, and bun's mocks
30
+ // are process-global, so depending on them here makes this file fail when
31
+ // co-run with a mocking file. Raw SQL against the shared DB is isolation-proof.
32
+ function seedEvent(
33
+ id: string,
34
+ opts: {
35
+ createdAt: number;
36
+ processingStatus: string;
37
+ deliveryStatus: string;
38
+ rawPayload: string | null;
39
+ },
40
+ ): string {
41
+ const db = getSqliteFrom(getDb());
42
+ const conversationId = `conv-${id}`;
43
+ db.query(
44
+ `INSERT INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)`,
45
+ ).run(conversationId, opts.createdAt, opts.createdAt);
46
+ db.query(
47
+ `INSERT INTO channel_inbound_events
48
+ (id, source_channel, external_chat_id, external_message_id,
49
+ conversation_id, delivery_status, processing_status, processing_attempts,
50
+ delivery_attempts, retry_after, raw_payload, delivered_segment_count,
51
+ created_at, updated_at)
52
+ VALUES (?, 'slack', ?, ?, ?, ?, ?, 0, 0, NULL, ?, 0, ?, ?)`,
53
+ ).run(
54
+ id,
55
+ `chat-${id}`,
56
+ `msg-${id}`,
57
+ conversationId,
58
+ opts.deliveryStatus,
59
+ opts.processingStatus,
60
+ opts.rawPayload,
61
+ opts.createdAt,
62
+ opts.createdAt,
63
+ );
64
+ return id;
65
+ }
66
+
67
+ function statusOf(eventId: string): {
68
+ delivery_status: string;
69
+ retry_after: number | null;
70
+ } {
71
+ return getSqliteFrom(getDb())
72
+ .query(
73
+ `SELECT delivery_status, retry_after
74
+ FROM channel_inbound_events WHERE id = ?`,
75
+ )
76
+ .get(eventId) as { delivery_status: string; retry_after: number | null };
77
+ }
78
+
79
+ describe("recoverStrandedDeliveryEvents", () => {
80
+ beforeEach(() => {
81
+ const db = getDb();
82
+ db.run("DELETE FROM channel_inbound_events");
83
+ db.run("DELETE FROM messages");
84
+ db.run("DELETE FROM conversation_keys");
85
+ db.run("DELETE FROM conversations");
86
+ rmSync(getDaemonBootTimePath(), { force: true });
87
+ });
88
+
89
+ test("promotes a pre-boot processed event whose delivery never finalized", () => {
90
+ const bootTime = Date.now();
91
+ recordDaemonBootTime(bootTime);
92
+
93
+ const eventId = seedEvent("stranded", {
94
+ createdAt: bootTime - 5_000,
95
+ processingStatus: "processed",
96
+ deliveryStatus: "pending",
97
+ rawPayload: DELIVERABLE_PAYLOAD,
98
+ });
99
+
100
+ recoverStrandedDeliveryEvents();
101
+
102
+ const row = statusOf(eventId);
103
+ expect(row.delivery_status).toBe("failed"); // the delivery-retry arm selects it
104
+ expect(row.retry_after).not.toBeNull();
105
+ });
106
+
107
+ test("leaves a processed+pending event created after boot (a live delivery owns it)", () => {
108
+ const bootTime = Date.now();
109
+ recordDaemonBootTime(bootTime);
110
+
111
+ const eventId = seedEvent("live", {
112
+ createdAt: bootTime + 5_000,
113
+ processingStatus: "processed",
114
+ deliveryStatus: "pending",
115
+ rawPayload: DELIVERABLE_PAYLOAD,
116
+ });
117
+
118
+ recoverStrandedDeliveryEvents();
119
+
120
+ expect(statusOf(eventId).delivery_status).toBe("pending");
121
+ });
122
+
123
+ test("leaves intercept-settled events whose payload names no reply (no delivery owed)", () => {
124
+ const bootTime = Date.now();
125
+ recordDaemonBootTime(bootTime);
126
+
127
+ const eventId = seedEvent("intercept", {
128
+ createdAt: bootTime - 5_000,
129
+ processingStatus: "processed",
130
+ deliveryStatus: "pending",
131
+ rawPayload: INTERCEPT_PAYLOAD,
132
+ });
133
+
134
+ recoverStrandedDeliveryEvents();
135
+
136
+ expect(statusOf(eventId).delivery_status).toBe("pending");
137
+ });
138
+
139
+ test("leaves a stranded-looking event with no stored payload (cannot be redelivered)", () => {
140
+ const bootTime = Date.now();
141
+ recordDaemonBootTime(bootTime);
142
+
143
+ const eventId = seedEvent("no-payload", {
144
+ createdAt: bootTime - 5_000,
145
+ processingStatus: "processed",
146
+ deliveryStatus: "pending",
147
+ rawPayload: null,
148
+ });
149
+
150
+ recoverStrandedDeliveryEvents();
151
+
152
+ expect(statusOf(eventId).delivery_status).toBe("pending");
153
+ });
154
+
155
+ test("never touches events with a terminal delivery state", () => {
156
+ const bootTime = Date.now();
157
+ recordDaemonBootTime(bootTime);
158
+
159
+ const delivered = seedEvent("delivered", {
160
+ createdAt: bootTime - 5_000,
161
+ processingStatus: "processed",
162
+ deliveryStatus: "delivered",
163
+ rawPayload: DELIVERABLE_PAYLOAD,
164
+ });
165
+ const deadLetter = seedEvent("dead-letter", {
166
+ createdAt: bootTime - 5_000,
167
+ processingStatus: "processed",
168
+ deliveryStatus: "dead_letter",
169
+ rawPayload: DELIVERABLE_PAYLOAD,
170
+ });
171
+
172
+ recoverStrandedDeliveryEvents();
173
+
174
+ expect(statusOf(delivered).delivery_status).toBe("delivered");
175
+ expect(statusOf(deadLetter).delivery_status).toBe("dead_letter");
176
+ });
177
+
178
+ test("leaves unprocessed events to the orphaned-channel-events step", () => {
179
+ const bootTime = Date.now();
180
+ recordDaemonBootTime(bootTime);
181
+
182
+ const eventId = seedEvent("still-pending", {
183
+ createdAt: bootTime - 5_000,
184
+ processingStatus: "pending",
185
+ deliveryStatus: "pending",
186
+ rawPayload: DELIVERABLE_PAYLOAD,
187
+ });
188
+
189
+ recoverStrandedDeliveryEvents();
190
+
191
+ expect(statusOf(eventId).delivery_status).toBe("pending");
192
+ });
193
+
194
+ test("skips recovery when the daemon boot time is unavailable", () => {
195
+ // No recordDaemonBootTime(): without the fence a live daemon's in-flight
196
+ // delivery is indistinguishable from a dead process's stranded row.
197
+ const eventId = seedEvent("no-fence", {
198
+ createdAt: Date.now() - 5_000,
199
+ processingStatus: "processed",
200
+ deliveryStatus: "pending",
201
+ rawPayload: DELIVERABLE_PAYLOAD,
202
+ });
203
+
204
+ recoverStrandedDeliveryEvents();
205
+
206
+ expect(statusOf(eventId).delivery_status).toBe("pending");
207
+ });
208
+ });
@@ -12,6 +12,7 @@ import { Database } from "bun:sqlite";
12
12
 
13
13
  import { getLogger } from "../../util/logger.js";
14
14
  import { getDbPath } from "../../util/platform.js";
15
+ import { readDaemonBootTime } from "../daemon-boot-time.js";
15
16
 
16
17
  const log = getLogger("recovery-db");
17
18
 
@@ -37,3 +38,37 @@ export function openRecoveryDb(): Database | null {
37
38
  return null;
38
39
  }
39
40
  }
41
+
42
+ /**
43
+ * Run one recovery step against a boot-fenced database handle.
44
+ *
45
+ * Every step reconciles rows a dead process left behind, so every step needs
46
+ * the same two guards first: a daemon boot time to fence against (without it
47
+ * a live daemon's in-flight row is indistinguishable from a dead process's
48
+ * orphan) and an openable database. `run` is invoked only when both hold, and
49
+ * the handle is closed however it returns.
50
+ *
51
+ * Steps keep their own logger and their own result logging: this owns the
52
+ * preconditions and the handle's lifetime, not what a step does or says.
53
+ * A throw propagates to the orchestrator, which treats it as "schema not
54
+ * ready yet" and retries on the next monitor run.
55
+ */
56
+ export function withBootFencedRecoveryDb(
57
+ step: string,
58
+ run: (db: Database, bootTime: number) => void,
59
+ ): void {
60
+ const bootTime = readDaemonBootTime();
61
+ if (bootTime == null) {
62
+ log.warn({ step }, "Skipping recovery step: daemon boot time unavailable");
63
+ return;
64
+ }
65
+ const db = openRecoveryDb();
66
+ if (db == null) {
67
+ return;
68
+ }
69
+ try {
70
+ run(db, bootTime);
71
+ } finally {
72
+ db.close();
73
+ }
74
+ }
@@ -25,30 +25,13 @@
25
25
  */
26
26
 
27
27
  import { getLogger } from "../../util/logger.js";
28
- import { readDaemonBootTime } from "../daemon-boot-time.js";
29
- import { openRecoveryDb } from "./db.js";
28
+ import { withBootFencedRecoveryDb } from "./db.js";
30
29
 
31
30
  const log = getLogger("recovery-orphaned-channel-events");
32
31
 
33
32
  export function recoverOrphanedChannelEvents(): void {
34
- const bootTime = readDaemonBootTime();
35
- if (bootTime == null) {
36
- // Without the fence we cannot tell a dead process's orphan from a live
37
- // daemon's just-arrived pending row, so skip; the next restart reconciles.
38
- log.warn(
39
- "Skipping orphaned-channel-event recovery — daemon boot time unavailable",
40
- );
41
- return;
42
- }
43
-
44
- const db = openRecoveryDb();
45
- if (db == null) {
46
- return;
47
- }
48
- try {
33
+ withBootFencedRecoveryDb("orphaned-channel-events", (db, bootTime) => {
49
34
  const now = Date.now();
50
- // Throws here (missing column) propagate to the orchestrator as "schema not
51
- // ready yet" and retry on the next monitor run.
52
35
  const result = db
53
36
  .query(
54
37
  `UPDATE channel_inbound_events
@@ -66,7 +49,5 @@ export function recoverOrphanedChannelEvents(): void {
66
49
  "Promoted orphaned pending channel events onto the retry sweep",
67
50
  );
68
51
  }
69
- } finally {
70
- db.close();
71
- }
52
+ });
72
53
  }
@@ -5,9 +5,11 @@
5
5
  * path runs here instead, in the resource-monitor process, off the daemon's
6
6
  * event loop. It lists a set of recovery steps and runs each ONCE, shortly
7
7
  * after the monitor starts — this is crash recovery, not a periodic sweep.
8
- * Each step owns the database file it needs and its own logging; the
8
+ * Each step owns its own reconciliation query and its own logging; the
9
9
  * orchestrator only sequences them and isolates one step's failure from the
10
- * next.
10
+ * next. The preconditions every step shares, a daemon boot time to fence
11
+ * against and an openable handle to close afterwards, live in
12
+ * `withBootFencedRecoveryDb`.
11
13
  *
12
14
  * Steps run in order; new reconciliations plug in by adding a
13
15
  * {@link RecoveryStep} to `RECOVERY_STEPS`. `clear-stale-processing` runs
@@ -19,6 +21,7 @@ import { getLogger } from "../../util/logger.js";
19
21
  import { recoverInflightContent } from "./inflight-content.js";
20
22
  import { recoverOrphanedChannelEvents } from "./orphaned-channel-events.js";
21
23
  import { clearStaleProcessing } from "./stale-processing.js";
24
+ import { recoverStrandedDeliveryEvents } from "./stranded-delivery-events.js";
22
25
 
23
26
  const log = getLogger("recovery");
24
27
 
@@ -38,6 +41,7 @@ const RECOVERY_STEPS: RecoveryStep[] = [
38
41
  { name: "clear-stale-processing", run: clearStaleProcessing },
39
42
  { name: "inflight-content", run: recoverInflightContent },
40
43
  { name: "orphaned-channel-events", run: recoverOrphanedChannelEvents },
44
+ { name: "stranded-delivery-events", run: recoverStrandedDeliveryEvents },
41
45
  ];
42
46
 
43
47
  /**
@@ -24,27 +24,12 @@
24
24
  */
25
25
 
26
26
  import { getLogger } from "../../util/logger.js";
27
- import { readDaemonBootTime } from "../daemon-boot-time.js";
28
- import { openRecoveryDb } from "./db.js";
27
+ import { withBootFencedRecoveryDb } from "./db.js";
29
28
 
30
29
  const log = getLogger("recovery-stale-processing");
31
30
 
32
31
  export function clearStaleProcessing(): void {
33
- const bootTime = readDaemonBootTime();
34
- if (bootTime == null) {
35
- // Without the boot-time fence we can't distinguish a dead process's flag
36
- // from a live turn's, so skip; the next daemon restart reconciles.
37
- log.warn("Skipping stale-processing clear — daemon boot time unavailable");
38
- return;
39
- }
40
-
41
- const db = openRecoveryDb();
42
- if (db == null) {
43
- return;
44
- }
45
- try {
46
- // Throws here (missing `processing_started_at` column) propagate to the
47
- // orchestrator as "schema not ready yet" and retry on the next monitor run.
32
+ withBootFencedRecoveryDb("stale-processing", (db, bootTime) => {
48
33
  const result = db
49
34
  .query(
50
35
  `UPDATE conversations
@@ -59,7 +44,5 @@ export function clearStaleProcessing(): void {
59
44
  "Cleared stale conversation processing flags from a previous process",
60
45
  );
61
46
  }
62
- } finally {
63
- db.close();
64
- }
47
+ });
65
48
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Recovery step: promote channel inbound events stranded between processing
3
+ * and delivery onto the delivery-retry sweep after a crash.
4
+ *
5
+ * A channel turn is marked `processing_status = 'processed'` the moment its
6
+ * agent turn persists (`markProcessed` in `inbound-stages/background-dispatch.ts`),
7
+ * but its reply delivery finalizes afterwards, in memory, ending in
8
+ * `markDeliveryDelivered` / `recordDeliveryFailure`. A daemon that dies inside
9
+ * that window leaves the row `processed` + `delivery_status = 'pending'`, and
10
+ * nothing recovers it: the delivery-retry sweep selects only
11
+ * `delivery_status = 'failed'`, and `recoverOrphanedChannelEvents` only
12
+ * promotes `processing_status = 'pending'` rows. The user-visible symptom is
13
+ * either a reply that never reaches the channel (durable delivery had not run
14
+ * yet) or, when the reply had already streamed live into Slack, a delivered
15
+ * message whose assistant row never gains its sent-message id
16
+ * (`slackMeta.channelTs` / `providerMeta.messageId`), which drops the row out
17
+ * of every projection keyed on that id.
18
+ *
19
+ * This step, run once from the monitor process at startup, promotes those
20
+ * orphans to `delivery_status = 'failed'` with an immediate `retry_after` so
21
+ * the existing delivery-retry arm of the sweep re-delivers from the stored
22
+ * payload. That arm is idempotent against a reply that already streamed: the
23
+ * `slackStreamMessageTs` breadcrumb makes it edit the visible message in
24
+ * place, and the sent-message-id reconciliation stamps the row. Guards:
25
+ *
26
+ * - **Boot-time fence.** Only rows created BEFORE this daemon booted are
27
+ * touched. A newer row belongs to a live turn on the running daemon;
28
+ * promoting it would let the sweep race the in-flight delivery. Mirrors
29
+ * `recoverOrphanedChannelEvents`.
30
+ * - **Delivery must actually be owed.** `processed` + `pending` is the
31
+ * normal terminal state for events the inbound intercepts settle without
32
+ * a reply delivery (reactions, edits, deletes, admission denials), so the
33
+ * promotion requires the payload keys only the turn-and-deliver path
34
+ * writes: `replyCallbackUrl` (stored at ingress for deliverable turns)
35
+ * AND `replyMessageId` (stored by `storeReplyMessageId` right after the
36
+ * turn persists its reply row).
37
+ */
38
+
39
+ import { getLogger } from "../../util/logger.js";
40
+ import { withBootFencedRecoveryDb } from "./db.js";
41
+
42
+ const log = getLogger("recovery-stranded-delivery-events");
43
+
44
+ export function recoverStrandedDeliveryEvents(): void {
45
+ withBootFencedRecoveryDb("stranded-delivery-events", (db, bootTime) => {
46
+ const now = Date.now();
47
+ const result = db
48
+ .query(
49
+ `UPDATE channel_inbound_events
50
+ SET delivery_status = 'failed',
51
+ retry_after = ?,
52
+ updated_at = ?
53
+ WHERE processing_status = 'processed'
54
+ AND delivery_status = 'pending'
55
+ AND raw_payload IS NOT NULL
56
+ AND json_extract(raw_payload, '$.replyCallbackUrl') IS NOT NULL
57
+ AND json_extract(raw_payload, '$.replyMessageId') IS NOT NULL
58
+ AND created_at < ?`,
59
+ )
60
+ .run(now, now, bootTime);
61
+ if (result.changes > 0) {
62
+ log.info(
63
+ { promoted: result.changes, bootTime },
64
+ "Promoted stranded processed channel events onto the delivery-retry sweep",
65
+ );
66
+ }
67
+ });
68
+ }
@@ -2,11 +2,11 @@
2
2
 
3
3
  All notification producers **MUST** go through `emitNotificationSignal()` in `notifications/emit-signal.ts`. Do not bypass the pipeline by broadcasting events directly -- the pipeline handles event persistence, deduplication, decision routing, and delivery audit.
4
4
 
5
- Guardian-request cards (approvals, questions) ride this pipeline end to end -- the full lifecycle map is [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md). Card actions (`actions[]`) are built **once, centrally** in the broadcaster's context resolvers (`resolveApprovalContext` / `resolveQuestionOptionsContext`); channel adapters render only. Adding buttons for a new request kind = a broadcaster context branch, never adapter parsing.
5
+ Guardian-request cards (approvals, questions) ride this pipeline end to end -- the full lifecycle map is [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md). Card actions (`actions[]`) are built **once, centrally** in the broadcaster's context resolvers (`resolveApprovalContext` / `resolveQuestionContext`); channel adapters render only. Adding buttons for a new request kind = a broadcaster context branch, never adapter parsing.
6
6
 
7
7
  When a notification flow creates a server-side conversation (e.g. guardian question conversations, task run conversations), the conversation and initial message **MUST** be persisted before the conversation-created event is emitted. This ensures the macOS/iOS client can immediately fetch the conversation contents when it receives the event.
8
8
 
9
- Guardian-request producers (access requests, tool approvals, tool-grant escalations, voice questions, trusted-contact confirmations) **MUST** record approval-card deliveries through `recordApprovalCardDelivery` / `recordGuardianRequestDeliveries` in `guardian-delivery-recorder.ts` -- never by calling the gateway client's `createGuardianRequestDelivery` directly. That sink is the single place the card-to-request addressing convention lives (conversation id for the in-app card; chat + channel-native `ts` for channel cards), so the path that writes a delivery row cannot drift from the paths that read it back to withdraw a card or resolve an emoji reaction -- the drift that previously fanned this logic into four divergent copies. (Tests may seed delivery rows directly for fixtures.)
9
+ Guardian-request producers (access requests, tool approvals, tool-grant escalations, voice questions, trusted-contact confirmations) **MUST** record approval-card deliveries through `recordApprovalCardDelivery` / `recordGuardianRequestDeliveries` in `guardian-delivery-recorder.ts` -- never by calling the gateway client's `createGuardianRequestDelivery` directly. That sink is the single place the card-to-request addressing convention lives (conversation id for the in-app card; chat + channel-native `ts` for channel cards), so the path that writes a delivery row cannot drift from the paths that read it back to withdraw a card -- the drift that previously fanned this logic into four divergent copies. (Tests may seed delivery rows directly for fixtures.)
10
10
 
11
11
  Approval-card **source references** (the link back to the channel message that triggered a request) resolve only through `resolveApprovalSourceReference()` in `runtime/approval-source-link.ts` -- producers spread the result into the `guardian.question` context payload and never hand-build links. Channel-format knowledge (id shapes, permalinks, mrkdwn) lives only in `messaging/providers/<channel>/` and `notifications/adapters/<channel>`; the four-layer ownership map is documented at the top of `approval-source-link.ts`. Exception: access-request cards predate the registry and still derive their Slack permalink from payload `messageTs` in `access-request-copy.ts` -- converge them onto the registry rather than adding a third resolution path.
12
12
 
@@ -57,8 +57,8 @@ Hard invariants that the LLM cannot override:
57
57
 
58
58
  **Post-generation enforcement** (`decision-engine.ts`):
59
59
 
60
- - **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them. Exception: for approval-mode questions the Slack adapter renders an interactive card with Approve/Reject buttons, so Slack copy is instead **stripped** of request-code instructions and bare code mentions (`stripGuardianRequestCodeInstructions()`).
61
- - **Access-request instruction enforcement** — `enforceAccessRequestInstructions()` validates that `ingress.access_request` copy contains: (1) the request-code approve/reject directive, (2) the exact "open invite flow" phrase. If any required element is missing, the full deterministic contract text is appended. This prevents model-generated copy from dropping security-critical action directives.
60
+ - **Guardian question reply mechanics**: `enforceGuardianRequestCode()` applies one per-channel rule, `guardianCopyCarriesReplyMechanics()` in `guardian-question-mode.ts`, to every channel's `guardian.question` copy. A channel the guardian answers by typing (Telegram, Discord, Slack answer-mode questions) gets the request-code instruction (approve/reject or free-text answer) **enforced in**, even when the LLM omitted it. A channel that acts through the request card gets it **stripped**, bare code mentions included: the vellum copy (read by the bell detail, the OS banner, and the delivery audit row), the platform push, and Slack approval-mode cards with Approve/Reject buttons. The template composer applies the same rule, so copy reaches the same shape on the fallback path.
61
+ - **Access-request copy**: `enforceAccessRequestCopy()` strips a model's echo of the request-code directive (`Reply "X trust"`, `Reply "X verify"`, and the rest) from every channel's `ingress.access_request` copy, as whole sentences, so the directive lives only in the card's `plainTextFallback` (`buildAccessRequestReplyMechanics`), appended by a transport when it sends text without buttons. The invite-flow directive is context, not mechanics: no surface has a button for it, so it stays in `buildAccessRequestContextText` and is ensured into model-composed copy that leaves it out.
62
62
 
63
63
  **Pre-send gate checks** (`deterministic-checks.ts`) — these all depend on the decision, so they run here, after it:
64
64
 
@@ -609,20 +609,22 @@ describe("emitAssistantReplyNotification", () => {
609
609
  });
610
610
 
611
611
  // Control characters would otherwise ride into the APNs payload verbatim.
612
- test("strips control characters and newlines out of the preview", async () => {
612
+ // Newlines stay: iOS (and other lock screens) render them as line breaks.
613
+ test("strips control characters and keeps line breaks in the preview", async () => {
613
614
  assistantRow = makeAssistantRow([
614
615
  { type: "text", text: "Hello\n\tthere" },
615
616
  ] as ContentBlock[]);
616
617
 
617
618
  await run();
618
619
 
619
- expect(emitCalls[0].contextPayload.requestedMessage).toBe("Hello there");
620
+ expect(emitCalls[0].contextPayload.requestedMessage).toBe("Hello\nthere");
620
621
  });
621
622
 
622
- // Blank lines and list indentation would otherwise spend the preview's
623
- // length budget on whitespace. The bullets go with them: a lock screen
624
- // renders the marker as literal punctuation, not as a list.
625
- test("collapses whitespace runs in the preview", async () => {
623
+ // List indentation and extra blank lines would otherwise spend the preview's
624
+ // length budget on whitespace. Markdown list markers are already gone after
625
+ // flattening; the remaining line breaks are the structure a lock screen can
626
+ // render.
627
+ test("keeps paragraph breaks after collapsing horizontal whitespace", async () => {
626
628
  assistantRow = makeAssistantRow([
627
629
  { type: "text", text: " Here:\n\n - item\n - other " },
628
630
  ] as ContentBlock[]);
@@ -630,7 +632,7 @@ describe("emitAssistantReplyNotification", () => {
630
632
  await run();
631
633
 
632
634
  expect(emitCalls[0].contextPayload.requestedMessage).toBe(
633
- "Here: item other",
635
+ "Here:\n\nitem\nother",
634
636
  );
635
637
  });
636
638
 
@@ -684,7 +686,7 @@ describe("emitAssistantReplyNotification", () => {
684
686
  await run();
685
687
 
686
688
  expect(emitCalls[0].contextPayload.requestedMessage).toBe(
687
- "Fixed it: const a = 1;",
689
+ "Fixed it:\n\nconst a = 1;",
688
690
  );
689
691
  });
690
692
 
@@ -699,7 +701,7 @@ describe("emitAssistantReplyNotification", () => {
699
701
  await run();
700
702
 
701
703
  expect(emitCalls[0].contextPayload.requestedMessage).toBe(
702
- "Keys Env Key dev 4Y4L",
704
+ "Keys\n\nEnv Key\ndev 4Y4L",
703
705
  );
704
706
  });
705
707
 
@@ -718,7 +718,7 @@ describe("NotificationBroadcaster question option actions", () => {
718
718
  expect(approval?.plainTextFallback).toContain("your answer");
719
719
  });
720
720
 
721
- test("an option-less pending_question (voice) carries no card actions", async () => {
721
+ test("an option-less pending_question (voice) carries the typed-reply instruction and no actions", async () => {
722
722
  const { adapter, sends } = makeCapturingAdapter("platform");
723
723
  const broadcaster = new NotificationBroadcaster([adapter]);
724
724
 
@@ -735,9 +735,40 @@ describe("NotificationBroadcaster question option actions", () => {
735
735
  );
736
736
 
737
737
  expect(sends.length).toBe(1);
738
- // Answer-mode without options renders as plain text with request-code
739
- // instructions — no approve/reject pair is ever attached to a question.
740
- expect(sends[0]?.payload.approvalContext).toBeUndefined();
738
+ // No buttons to draw, so the transports send text and append the
739
+ // instruction; an approve/reject pair is never attached to a question.
740
+ const approval = sends[0]?.payload.approvalContext;
741
+ expect(approval?.requestId).toBe("req-v1");
742
+ expect(approval?.actions).toEqual([]);
743
+ expect(approval?.intent).toBe("question");
744
+ expect(approval?.plainTextFallback).toBe(
745
+ 'Reference code: DEF456. Reply "DEF456 <your answer>".',
746
+ );
747
+ });
748
+
749
+ test("a coded question that fails strict parsing still carries its typed-reply instruction", async () => {
750
+ const { adapter, sends } = makeCapturingAdapter("platform");
751
+ const broadcaster = new NotificationBroadcaster([adapter]);
752
+
753
+ await broadcaster.broadcastDecision(
754
+ questionSignal({
755
+ requestKind: "pending_question",
756
+ requestId: "req-lenient-1",
757
+ requestCode: "abc999",
758
+ // A null requester id fails the strict schema (string | undefined).
759
+ requesterExternalUserId: null,
760
+ questionText: "What time works?",
761
+ }),
762
+ decisionForPlatform(),
763
+ );
764
+
765
+ expect(sends.length).toBe(1);
766
+ const approval = sends[0]?.payload.approvalContext;
767
+ expect(approval?.requestId).toBe("req-lenient-1");
768
+ expect(approval?.actions).toEqual([]);
769
+ expect(approval?.plainTextFallback).toBe(
770
+ 'Reference code: ABC999. Reply "ABC999 <your answer>".',
771
+ );
741
772
  });
742
773
 
743
774
  test("tool_approval payloads keep the approve/reject action pair", async () => {
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ collapseHorizontalWhitespace,
4
5
  describeMedia,
5
6
  mediaEmbeds,
7
+ sanitizeMultilineMessagePreview,
6
8
  stripMarkdownForPreview,
7
9
  } from "../notification-utils.js";
8
10
 
@@ -230,6 +232,35 @@ describe("mediaEmbeds", () => {
230
232
  });
231
233
  });
232
234
 
235
+ describe("collapseHorizontalWhitespace", () => {
236
+ test("keeps paragraph breaks and trims line padding", () => {
237
+ expect(collapseHorizontalWhitespace(" Here:\n\n item \n other ")).toBe(
238
+ "Here:\n\nitem\nother",
239
+ );
240
+ });
241
+
242
+ test("collapses three or more newlines to one blank line", () => {
243
+ expect(collapseHorizontalWhitespace("a\n\n\n\nb")).toBe("a\n\nb");
244
+ });
245
+ });
246
+
247
+ describe("sanitizeMultilineMessagePreview", () => {
248
+ test("keeps newlines and strips other control characters", () => {
249
+ expect(sanitizeMultilineMessagePreview("Hello\n\tWorld")).toBe(
250
+ "Hello\nWorld",
251
+ );
252
+ expect(sanitizeMultilineMessagePreview("Test\r\nMessage")).toBe(
253
+ "Test\nMessage",
254
+ );
255
+ });
256
+
257
+ test("clamps to the shared preview budget", () => {
258
+ const result = sanitizeMultilineMessagePreview("A".repeat(250));
259
+ expect(result).toHaveLength(200);
260
+ expect(result.endsWith("…")).toBe(true);
261
+ });
262
+ });
263
+
233
264
  describe("describeMedia", () => {
234
265
  test("names a single label", () => {
235
266
  expect(describeMedia(["cut.mp4"])).toBe("Sent cut.mp4");