@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
@@ -20,7 +20,10 @@ import {
20
20
  nonEmpty,
21
21
  sanitizeIdentityField,
22
22
  sanitizeMessagePreview,
23
+ stripReplyMechanicsFromCopy,
24
+ stripRequestCodeDirectives,
23
25
  } from "./notification-utils.js";
26
+ import type { RenderedChannelCopy } from "./types.js";
24
27
 
25
28
  // ── Zod schema for access-request payloads ──────────────────────────────────
26
29
 
@@ -150,7 +153,7 @@ export function isHandshakeOfferedForPayload(
150
153
  }
151
154
 
152
155
  /** Internal typed implementation — avoids re-parsing when called from
153
- * buildAccessRequestContractText which has already parsed the payload. */
156
+ * buildAccessRequestContextText which has already parsed the payload. */
154
157
  function buildIdentityLineFromParsed(p: ParsedAccessRequestPayload): string {
155
158
  const requester = sanitizeIdentityField(p.senderIdentifier || "Someone");
156
159
  const callerName = nonEmpty(p.actorDisplayName);
@@ -235,112 +238,25 @@ export function buildAccessRequestInviteDirective(): string {
235
238
  }
236
239
 
237
240
  /**
238
- * Normalize text before running directive-matching regexes.
241
+ * Guardian-facing context for an access request: who is asking, what they
242
+ * said, any warnings, where it came from, and the invite-flow directive.
243
+ * The invite directive is context rather than mechanics because no surface
244
+ * offers a button for it: typing "open invite flow" is the only way to start
245
+ * it anywhere, so every surface has to say so. The request-code directive
246
+ * is {@link buildAccessRequestReplyMechanics}, and only the broadcaster's
247
+ * `plainTextFallback` holds it, so a transport appends it exactly when it
248
+ * sends text without buttons.
239
249
  *
240
- * - Replaces smart/curly apostrophes (\u2018, \u2019, \u201B) with ASCII `'`
241
- * so contractions like "Don\u2019t" are matched by the `n't` lookbehind.
242
- * - Collapses runs of whitespace into a single space so "Do not reply"
243
- * is matched by the single-space negative lookbehind.
244
- * - Trims leading/trailing whitespace.
250
+ * Channel-agnostic by design: this reads the generic `contextPayload` and
251
+ * renders the same on every channel. When `guardianResolutionSource` is
252
+ * present and not `"source-channel-contact"`, the guardian was resolved via
253
+ * fallback (e.g. vellum anchor) rather than a verified same-channel contact,
254
+ * and the copy says so.
245
255
  */
246
- export function normalizeForDirectiveMatching(text: string): string {
247
- return text
248
- .replace(/[\u2018\u2019\u201B]/g, "'")
249
- .replace(/\s+/g, " ")
250
- .trim();
251
- }
252
-
253
- /** Build a negated-lookbehind directive regex for `reply ... "CODE <verb>"`. */
254
- function buildCodeDirectiveRegex(escapedCode: string, verb: string): RegExp {
255
- return new RegExp(
256
- `(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+${verb}"`,
257
- "i",
258
- );
259
- }
260
-
261
- /**
262
- * Check whether a text contains the required access-request instruction
263
- * elements for the introduction card:
264
- * 1. Trust directive: Reply "CODE trust"
265
- * 2. Verify directive: Reply "CODE verify" — only when the handshake is
266
- * offered for this requester (never for bots / workspace-vouched members)
267
- * 3. Leave-unverified directive: Reply "CODE reject"
268
- * 4. Block directive: Reply "CODE block"
269
- * 5. Invite directive: Reply "open invite flow"
270
- *
271
- * Each directive is matched independently using negative lookbehind to reject
272
- * matches preceded by negation words ("not", "n't", "never"). This prevents
273
- * contradictory copy like `Do not reply "CODE reject"` from satisfying the
274
- * check even when a positive directive exists nearby.
275
- *
276
- * The text is normalized before matching to handle smart apostrophes and
277
- * multiple whitespace characters that would otherwise bypass negation detection.
278
- */
279
- export function hasAccessRequestInstructions(
280
- text: string | undefined,
281
- requestCode: string,
282
- options?: { handshakeOffered?: boolean },
283
- ): boolean {
284
- if (typeof text !== "string") {
285
- return false;
286
- }
287
- const handshakeOffered = options?.handshakeOffered ?? true;
288
- const normalized = normalizeForDirectiveMatching(text);
289
- const escapedCode = requestCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
290
- // Each directive must follow "reply" without a preceding negation word.
291
- // Negative lookbehinds reject "do not reply", "don't reply", "never reply".
292
- const trustRe = buildCodeDirectiveRegex(escapedCode, "trust");
293
- const verifyRe = buildCodeDirectiveRegex(escapedCode, "verify");
294
- const rejectRe = buildCodeDirectiveRegex(escapedCode, "reject");
295
- const blockRe = buildCodeDirectiveRegex(escapedCode, "block");
296
- const inviteRe =
297
- /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
298
-
299
- return (
300
- trustRe.test(normalized) &&
301
- (!handshakeOffered || verifyRe.test(normalized)) &&
302
- rejectRe.test(normalized) &&
303
- blockRe.test(normalized) &&
304
- inviteRe.test(normalized)
305
- );
306
- }
307
-
308
- /**
309
- * Check whether text contains the invite-flow directive ("open invite flow")
310
- * using the same normalized negative-lookbehind pattern as the full check.
311
- * This is used for enforcement when requestCode is absent but the invite
312
- * directive should still be present.
313
- */
314
- export function hasInviteFlowDirective(text: string | undefined): boolean {
315
- if (typeof text !== "string") {
316
- return false;
317
- }
318
- const normalized = normalizeForDirectiveMatching(text);
319
- const inviteRe =
320
- /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
321
- return inviteRe.test(normalized);
322
- }
323
-
324
- // ── Contract text builder ───────────────────────────────────────────────────
325
-
326
- /**
327
- * Build the deterministic access-request contract text from payload fields.
328
- * This is the canonical baseline that enforcement can append when generated
329
- * copy is missing required elements.
330
- *
331
- * Channel-agnostic by design: this function reads from the generic
332
- * `contextPayload` and works identically regardless of which channel
333
- * (Slack, Telegram, desktop, etc.) the notification is delivered to.
334
- * When `guardianResolutionSource` is present and not `"source-channel-contact"`,
335
- * the guardian was resolved via fallback (e.g. vellum anchor) rather than
336
- * a verified same-channel contact — downstream copy or routing can use
337
- * this to append verification CTAs like "Was this you?".
338
- */
339
- export function buildAccessRequestContractText(
256
+ export function buildAccessRequestContextText(
340
257
  payload: Record<string, unknown>,
341
258
  ): string {
342
259
  const p = parseAccessRequestPayload(payload);
343
- const requestCode = nonEmpty(p.requestCode);
344
260
 
345
261
  const lines: string[] = [];
346
262
  lines.push(buildIdentityLineFromParsed(p));
@@ -371,18 +287,6 @@ export function buildAccessRequestContractText(
371
287
  lines.push(source);
372
288
  }
373
289
 
374
- if (requestCode) {
375
- const code = requestCode.toUpperCase();
376
- if (isHandshakeOfferedForPayload(p)) {
377
- lines.push(
378
- `Reply "${code} verify" to send them a verification code, "${code} trust" to trust them without one, "${code} reject" to leave them unverified, or "${code} block" to block them.`,
379
- );
380
- } else {
381
- lines.push(
382
- `Reply "${code} trust" to trust them, "${code} reject" to leave them unverified, or "${code} block" to block them.`,
383
- );
384
- }
385
- }
386
290
  lines.push(buildAccessRequestInviteDirective());
387
291
 
388
292
  if (
@@ -397,6 +301,114 @@ export function buildAccessRequestContractText(
397
301
  return lines.join("\n");
398
302
  }
399
303
 
304
+ /**
305
+ * The typed-reply mechanics for an access request: the request-code
306
+ * directive (verify, trust, reject, block; or trust, reject, block when no
307
+ * handshake is offered). This is the broadcaster's `plainTextFallback` for
308
+ * the card, appended to a message only by a transport that sends text
309
+ * without buttons. Empty when the request carries no code, since there is
310
+ * then nothing to type.
311
+ */
312
+ export function buildAccessRequestReplyMechanics(
313
+ payload: Record<string, unknown>,
314
+ ): string {
315
+ const p = parseAccessRequestPayload(payload);
316
+ const requestCode = nonEmpty(p.requestCode);
317
+ if (!requestCode) {
318
+ return "";
319
+ }
320
+ const code = requestCode.toUpperCase();
321
+ return isHandshakeOfferedForPayload(p)
322
+ ? `Reply "${code} verify" to send them a verification code, "${code} trust" to trust them without one, "${code} reject" to leave them unverified, or "${code} block" to block them.`
323
+ : `Reply "${code} trust" to trust them, "${code} reject" to leave them unverified, or "${code} block" to block them.`;
324
+ }
325
+
326
+ /**
327
+ * Remove request-code reply mechanics the model wrote into composed copy
328
+ * ({@link stripRequestCodeDirectives}). The invite-flow directive is context
329
+ * and stays.
330
+ */
331
+ export function stripAccessRequestReplyMechanics(
332
+ text: string,
333
+ payload: Record<string, unknown>,
334
+ ): string {
335
+ const requestCode = nonEmpty(parseAccessRequestPayload(payload).requestCode);
336
+ return requestCode ? stripRequestCodeDirectives(text, requestCode) : text;
337
+ }
338
+
339
+ /**
340
+ * The text-only rendering of an access request: the context, then the
341
+ * typed directive when the request carries a code. This is the card's text
342
+ * sibling, what a client without buttons (the CLI, search, the model) sees
343
+ * in place of the card.
344
+ */
345
+ export function buildAccessRequestTextFallback(
346
+ payload: Record<string, unknown>,
347
+ ): string {
348
+ const mechanics = buildAccessRequestReplyMechanics(payload);
349
+ const context = buildAccessRequestContextText(payload);
350
+ return mechanics ? `${context}\n${mechanics}` : context;
351
+ }
352
+
353
+ /**
354
+ * Whether text carries a positive invite-flow directive: a "reply ..."
355
+ * sentence naming the phrase, not preceded by a negation. A bare mention
356
+ * ("the open invite flow is disabled") or a negated form ("do not reply
357
+ * ...") does not count, so the canonical directive still gets appended.
358
+ */
359
+ export function hasInviteFlowDirective(text: string): boolean {
360
+ const normalized = text
361
+ .replace(/[\u2018\u2019\u201B]/g, "'")
362
+ .replace(/\s+/g, " ");
363
+ return /(?<!not )(?<!n't )(?<!never )reply\b[^.!?\n]*?"open invite flow"/i.test(
364
+ normalized,
365
+ );
366
+ }
367
+
368
+ /**
369
+ * Ensure the invite-flow directive is in every text field of a channel's
370
+ * access-request copy. It is context rather than mechanics (no surface has
371
+ * an invite button, so the sentence is the only way to start the flow), and
372
+ * model-composed copy can omit, negate, or merely mention it, so the
373
+ * canonical sentence is appended whenever no positive directive is present.
374
+ * A title stays a title.
375
+ */
376
+ export function ensureAccessRequestInviteDirectiveInCopy(
377
+ copy: RenderedChannelCopy,
378
+ ): RenderedChannelCopy {
379
+ const ensure = (text: string): string =>
380
+ hasInviteFlowDirective(text)
381
+ ? text
382
+ : `${text.trim()}\n${buildAccessRequestInviteDirective()}`;
383
+ return {
384
+ ...copy,
385
+ body: ensure(copy.body),
386
+ deliveryText: copy.deliveryText
387
+ ? ensure(copy.deliveryText)
388
+ : copy.deliveryText,
389
+ conversationSeedMessage: copy.conversationSeedMessage
390
+ ? ensure(copy.conversationSeedMessage)
391
+ : copy.conversationSeedMessage,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * {@link stripAccessRequestReplyMechanics} over every text field of a
397
+ * channel's copy; a field left empty becomes the requester context.
398
+ */
399
+ export function stripAccessRequestReplyMechanicsFromCopy(
400
+ copy: RenderedChannelCopy,
401
+ payload: Record<string, unknown>,
402
+ ): RenderedChannelCopy {
403
+ const requestCode = nonEmpty(parseAccessRequestPayload(payload).requestCode);
404
+ return stripReplyMechanicsFromCopy(
405
+ copy,
406
+ (text) =>
407
+ requestCode ? stripRequestCodeDirectives(text, requestCode) : text,
408
+ buildAccessRequestContextText(payload),
409
+ );
410
+ }
411
+
400
412
  // ── Card view model ─────────────────────────────────────────────────────────
401
413
 
402
414
  /**
@@ -31,7 +31,11 @@ import type {
31
31
  DeliveryResult,
32
32
  NotificationChannel,
33
33
  } from "../types.js";
34
- import { appendPlainTextFallback, resolveMessageText } from "./shared.js";
34
+ import {
35
+ appendPlainTextFallback,
36
+ rendersActions,
37
+ resolveMessageText,
38
+ } from "./shared.js";
35
39
 
36
40
  const log = getLogger("notif-adapter-discord");
37
41
 
@@ -60,7 +64,7 @@ export class DiscordAdapter implements ChannelAdapter {
60
64
  try {
61
65
  const channelId = await openDiscordDmChannel(guardianUserId);
62
66
 
63
- if (approval) {
67
+ if (rendersActions(approval)) {
64
68
  // Attempt rich delivery with component buttons; on failure, fall
65
69
  // back to the plain-text card below.
66
70
  try {
@@ -74,7 +78,7 @@ export class DiscordAdapter implements ChannelAdapter {
74
78
  "Discord approval notification delivered with buttons",
75
79
  );
76
80
  // The message id lets the delivery row address the card later
77
- // (reactions, button presses, in-place withdrawal).
81
+ // (button presses, in-place withdrawal).
78
82
  return { success: true, messageId: sent.lastMessageId };
79
83
  } catch (richErr) {
80
84
  if (richErr instanceof DiscordPartialSendError) {
@@ -108,8 +112,8 @@ export class DiscordAdapter implements ChannelAdapter {
108
112
  }
109
113
  }
110
114
 
111
- // When falling back from rich delivery, append the plain-text
112
- // instructions so the guardian still knows how to approve/reject.
115
+ // Text without buttons carries the typed-reply instructions, whether
116
+ // the rich delivery failed or the request never had buttons to draw.
113
117
  const sent = await sendDiscordReply(
114
118
  { channelId },
115
119
  appendPlainTextFallback(messageText, approval),
@@ -35,10 +35,23 @@ export function resolveMessageText(payload: ChannelDeliveryPayload): string {
35
35
  }
36
36
 
37
37
  /**
38
- * Append an approval's typed-command instructions to the message text, when
39
- * the copy does not already carry them. Used by adapters delivering an
40
- * approval without live buttons (Discord always; Telegram when its rich
41
- * delivery fails), so the guardian still knows how to decide.
38
+ * Whether an approval context has buttons to draw. A context with no actions
39
+ * (an option-less question) is answered by typed reply, so it takes the
40
+ * plain-text path with {@link appendPlainTextFallback} rather than a card
41
+ * with nothing on it.
42
+ */
43
+ export function rendersActions(
44
+ approval: ChannelDeliveryPayload["approvalContext"],
45
+ ): approval is NonNullable<ChannelDeliveryPayload["approvalContext"]> {
46
+ return approval != null && approval.actions.length > 0;
47
+ }
48
+
49
+ /**
50
+ * Append an approval's typed-reply instructions to the message text, when
51
+ * the text does not already carry them. This is the only place reply
52
+ * mechanics join a message: composed copy never carries them, so an adapter
53
+ * sending text without live buttons (no actions to draw, or a rich delivery
54
+ * that failed) appends them here and the guardian still knows how to answer.
42
55
  */
43
56
  export function appendPlainTextFallback(
44
57
  text: string,
@@ -39,7 +39,11 @@ import type {
39
39
  DeliveryResult,
40
40
  NotificationChannel,
41
41
  } from "../types.js";
42
- import { resolveMessageText } from "./shared.js";
42
+ import {
43
+ appendPlainTextFallback,
44
+ rendersActions,
45
+ resolveMessageText,
46
+ } from "./shared.js";
43
47
 
44
48
  const log = getLogger("notif-adapter-slack");
45
49
 
@@ -444,13 +448,19 @@ export class SlackAdapter implements ChannelAdapter {
444
448
  // `approval` rides along with the prebuilt card blocks so the send
445
449
  // layer treats a rejected Block Kit payload as an approval prompt:
446
450
  // its block-free retry re-attaches `plainTextFallback` reply
447
- // instructions instead of posting text with no way to respond.
448
- const result = payload.approvalContext
451
+ // instructions instead of posting text with no way to respond. A
452
+ // context with no buttons to draw is answered by typed reply, so its
453
+ // instructions join the text up front.
454
+ const result = rendersActions(payload.approvalContext)
449
455
  ? await sendSlackReply(chatId, messageText, {
450
456
  blocks: buildApprovalNotificationBlocks(payload, messageText),
451
457
  approval: payload.approvalContext,
452
458
  })
453
- : await sendSlackReply(chatId, messageText, { useBlocks: true });
459
+ : await sendSlackReply(
460
+ chatId,
461
+ appendPlainTextFallback(messageText, payload.approvalContext),
462
+ { useBlocks: true },
463
+ );
454
464
 
455
465
  log.info(
456
466
  { sourceEventName: payload.sourceEventName, chatId, ts: result.ts },
@@ -23,7 +23,11 @@ import type {
23
23
  DeliveryResult,
24
24
  NotificationChannel,
25
25
  } from "../types.js";
26
- import { appendPlainTextFallback, resolveMessageText } from "./shared.js";
26
+ import {
27
+ appendPlainTextFallback,
28
+ rendersActions,
29
+ resolveMessageText,
30
+ } from "./shared.js";
27
31
 
28
32
  const log = getLogger("notif-adapter-telegram");
29
33
 
@@ -50,7 +54,7 @@ export class TelegramAdapter implements ChannelAdapter {
50
54
  const approval = payload.approvalContext;
51
55
 
52
56
  try {
53
- if (approval) {
57
+ if (rendersActions(approval)) {
54
58
  // Attempt rich delivery with inline keyboard buttons.
55
59
  // On failure, fall back to plain text below.
56
60
  try {
@@ -72,8 +76,8 @@ export class TelegramAdapter implements ChannelAdapter {
72
76
  }
73
77
  }
74
78
 
75
- // When falling back from rich delivery, append the plain-text
76
- // instructions so the guardian still knows how to approve/reject.
79
+ // Text without buttons carries the typed-reply instructions, whether
80
+ // the rich delivery failed or the request never had buttons to draw.
77
81
  const sent = await sendTelegramReply(
78
82
  chatId,
79
83
  appendPlainTextFallback(messageText, approval),
@@ -17,7 +17,7 @@ import {
17
17
  accessRequestCardSubtitle,
18
18
  accessRequestCardTitle,
19
19
  buildAccessRequestCardView,
20
- buildAccessRequestContractText,
20
+ buildAccessRequestTextFallback,
21
21
  buildIntroductionActionsForPayload,
22
22
  parseAccessRequestPayload,
23
23
  } from "./access-request-copy.js";
@@ -238,7 +238,10 @@ function resolveAccessRequestCard(
238
238
  metadata,
239
239
  requestId: view.requestId,
240
240
  actions,
241
- fallbackText: buildAccessRequestContractText(payload),
241
+ // The card's text sibling is what a client without buttons sees (the
242
+ // CLI, search, the model), so it is the one place the card itself
243
+ // carries the typed directive beside the context.
244
+ fallbackText: buildAccessRequestTextFallback(payload),
242
245
  };
243
246
  }
244
247
 
@@ -36,6 +36,7 @@ import {
36
36
  describeMedia,
37
37
  mediaEmbeds,
38
38
  sanitizeMessagePreview,
39
+ sanitizeMultilineMessagePreview,
39
40
  sanitizeNotificationTitle,
40
41
  stripMarkdownForPreview,
41
42
  } from "./notification-utils.js";
@@ -50,10 +51,10 @@ const DESKTOP_PRESENCE_FLAG = "desktop-presence-suppression" as const;
50
51
  const WEB_PRESENCE_FLAG = "web-presence-suppression" as const;
51
52
 
52
53
  /**
53
- * Collapse whitespace runs ahead of the sanitizers' truncation: blank lines and
54
- * list indentation would otherwise eat into the copy's length budget.
54
+ * Flatten a title onto one line. Notification titles cannot wrap, and
55
+ * `normalizeTitle` discards any string that still contains a newline.
55
56
  */
56
- function collapseWhitespace(value: string): string {
57
+ function flattenTitleWhitespace(value: string): string {
57
58
  return value.replace(/\s+/g, " ").trim();
58
59
  }
59
60
 
@@ -229,9 +230,8 @@ export async function emitAssistantReplyNotification(params: {
229
230
  // text nor media stays silent.
230
231
  const text = stringifyMessageContent(assistantRow.content);
231
232
  const preview =
232
- sanitizeMessagePreview(
233
- collapseWhitespace(stripMarkdownForPreview(text)),
234
- ) || sanitizeMessagePreview(describeReplyMedia(text, assistantMessageId));
233
+ sanitizeMultilineMessagePreview(stripMarkdownForPreview(text)) ||
234
+ sanitizeMessagePreview(describeReplyMedia(text, assistantMessageId));
235
235
  if (!preview) {
236
236
  return;
237
237
  }
@@ -242,7 +242,7 @@ export async function emitAssistantReplyNotification(params: {
242
242
  // title from the body, which reads better than an empty or placeholder
243
243
  // conversation title.
244
244
  const requestedTitle = sanitizeNotificationTitle(
245
- collapseWhitespace(conversation.title ?? ""),
245
+ flattenTitleWhitespace(conversation.title ?? ""),
246
246
  );
247
247
 
248
248
  // Read as close to the emit as possible: nothing short-circuits on it.
@@ -16,7 +16,7 @@ import { getConversation } from "../persistence/conversation-crud.js";
16
16
  import type { ApprovalUIMetadata } from "../runtime/channel-approval-types.js";
17
17
  import { getLogger } from "../util/logger.js";
18
18
  import {
19
- buildAccessRequestContractText,
19
+ buildAccessRequestReplyMechanics,
20
20
  buildIntroductionActionsForPayload,
21
21
  parseAccessRequestPayload,
22
22
  } from "./access-request-copy.js";
@@ -33,14 +33,16 @@ import {
33
33
  } from "./deliveries-store.js";
34
34
  import { resolveDestinations } from "./destination-resolver.js";
35
35
  import {
36
+ buildGuardianRequestCodeInstruction,
36
37
  buildQuestionAnswerActions,
37
38
  buildToolApprovalSourceView,
38
39
  parseGuardianQuestionPayload,
39
40
  parseInteractiveApprovalPayload,
40
41
  resolveGuardianInstructionModeFromPayload,
42
+ resolveGuardianQuestionInstructionMode,
41
43
  type ToolApprovalSourceView,
42
44
  } from "./guardian-question-mode.js";
43
- import { nonEmpty } from "./notification-utils.js";
45
+ import { nonEmpty, readPayloadString } from "./notification-utils.js";
44
46
  import type { NotificationSignal } from "./signal.js";
45
47
  import type {
46
48
  ChannelAdapter,
@@ -97,25 +99,25 @@ function resolveApprovalContext(
97
99
  actions: buildIntroductionActionsForPayload(
98
100
  parseAccessRequestPayload(payload),
99
101
  ),
100
- plainTextFallback: buildAccessRequestContractText(payload),
102
+ plainTextFallback: buildAccessRequestReplyMechanics(payload),
101
103
  },
102
104
  };
103
105
  }
104
106
 
105
107
  if (signal.sourceEventName === "guardian.question") {
106
- // Answer-mode question with structured options (an ask_question prompt):
107
- // render the options as card actions so every channel adapter shows
108
- // tappable choices. Built here — once per broadcast — so adapters stay
109
- // rendering-only; the reply router recognizes the action ids as answer
110
- // selections (see parseQuestionAnswerActionId).
111
- const questionContext = resolveQuestionOptionsContext(payload);
108
+ // Answer-mode question: options render as card actions so every channel
109
+ // adapter shows tappable choices, and an option-less question carries
110
+ // its typed-reply instruction. Built here, once per broadcast, so
111
+ // adapters stay rendering-only; the reply router recognizes the action
112
+ // ids as answer selections (see parseQuestionAnswerActionId).
113
+ const questionContext = resolveQuestionContext(payload);
112
114
  if (questionContext) {
113
115
  return questionContext;
114
116
  }
115
117
 
116
118
  const parsed = parseInteractiveApprovalPayload(payload);
117
119
  if (!parsed) {
118
- return undefined;
120
+ return resolveCodedTextContext(payload);
119
121
  }
120
122
  const requestId = parsed.requestId;
121
123
 
@@ -139,7 +141,10 @@ function resolveApprovalContext(
139
141
  approval: {
140
142
  requestId,
141
143
  actions: APPROVAL_ACTIONS,
142
- plainTextFallback: `Reply "${parsed.requestCode?.toUpperCase() ?? requestId} approve" or "${parsed.requestCode?.toUpperCase() ?? requestId} reject"`,
144
+ plainTextFallback: buildGuardianRequestCodeInstruction(
145
+ parsed.requestCode?.toUpperCase() ?? requestId,
146
+ "approval",
147
+ ),
143
148
  permissionDetails: toolName
144
149
  ? {
145
150
  toolName,
@@ -157,14 +162,43 @@ function resolveApprovalContext(
157
162
  }
158
163
 
159
164
  /**
160
- * Card actions for an answer-mode `pending_question` payload carrying
161
- * structured options: one action per option plus Skip, ids in the
162
- * answer-selection scheme. Returns `undefined` for approval-mode payloads,
163
- * option-less questions (voice questions answer by free text), and anything
164
- * that fails strict parsing — those fall through to the approval/plain-text
165
- * paths.
165
+ * A `guardian.question` payload that fails strict parsing draws no buttons
166
+ * anywhere, so its only way to be answered is the typed reply. When it
167
+ * still names a request id and code, it gets a context with no actions and
168
+ * the instruction for its mode, which the transports render as text plus
169
+ * the instruction. Without those it is undefined, as before.
166
170
  */
167
- function resolveQuestionOptionsContext(
171
+ function resolveCodedTextContext(
172
+ payload: Record<string, unknown>,
173
+ ): ResolvedApprovalContext | undefined {
174
+ const requestId = nonEmpty(readPayloadString(payload, "requestId"));
175
+ const requestCode = nonEmpty(readPayloadString(payload, "requestCode"));
176
+ if (!requestId || !requestCode) {
177
+ return undefined;
178
+ }
179
+ return {
180
+ approval: {
181
+ requestId,
182
+ actions: [],
183
+ plainTextFallback: buildGuardianRequestCodeInstruction(
184
+ requestCode.toUpperCase(),
185
+ resolveGuardianQuestionInstructionMode(payload).mode,
186
+ ),
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * The card context for an answer-mode `pending_question`: one action per
193
+ * option plus Skip, ids in the answer-selection scheme, and the typed-reply
194
+ * instruction as the text fallback. An option-less question (a voice
195
+ * question, answered by free text) carries no actions, only the fallback:
196
+ * the transports read an empty action set as "send text, append the
197
+ * instruction", which is the one way that question stays answerable.
198
+ * Returns `undefined` for approval-mode payloads and anything that fails
199
+ * strict parsing; those fall through to the approval path.
200
+ */
201
+ function resolveQuestionContext(
168
202
  payload: Record<string, unknown>,
169
203
  ): ResolvedApprovalContext | undefined {
170
204
  const parsed = parseGuardianQuestionPayload(payload);
@@ -174,21 +208,19 @@ function resolveQuestionOptionsContext(
174
208
  if (resolveGuardianInstructionModeFromPayload(parsed).mode !== "answer") {
175
209
  return undefined;
176
210
  }
177
- const options = parsed.options;
178
- if (!options || options.length === 0) {
179
- return undefined;
180
- }
181
211
  const requestId = nonEmpty(parsed.requestId);
182
212
  if (!requestId) {
183
213
  return undefined;
184
214
  }
185
215
 
186
- const code = parsed.requestCode?.toUpperCase() ?? requestId;
187
216
  return {
188
217
  approval: {
189
218
  requestId,
190
- actions: buildQuestionAnswerActions(options),
191
- plainTextFallback: `Reply "${code} <your answer>".`,
219
+ actions: buildQuestionAnswerActions(parsed.options ?? []),
220
+ plainTextFallback: buildGuardianRequestCodeInstruction(
221
+ parsed.requestCode?.toUpperCase() ?? requestId,
222
+ "answer",
223
+ ),
192
224
  intent: "question",
193
225
  },
194
226
  };