@vellumai/assistant 0.11.5 → 0.11.6-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 (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -86,95 +86,155 @@ export const SlackStreamTaskSchema = z.object({
86
86
 
87
87
  export type SlackStreamTask = z.infer<typeof SlackStreamTaskSchema>;
88
88
 
89
+ // ---------------------------------------------------------------------------
90
+ // Audience
91
+ // ---------------------------------------------------------------------------
92
+
89
93
  /**
90
- * A single Slack streaming operation, mapping directly onto the
91
- * `chat.startStream` / `chat.appendStream` / `chat.stopStream` Web API methods.
94
+ * Restricts a message to a single reader in a room that has more.
92
95
  *
93
- * `start` opens a streamed reply on a thread and returns the stream `ts`;
94
- * `append` adds markdown (and optional task cards) to that stream; `stop`
95
- * finalizes it, optionally rendering rich Block Kit blocks below the streamed
96
- * body. Blocks are only accepted on `stop` — during the stream, Slack renders
97
- * the `markdownText` natively.
96
+ * A single object rather than a flag beside an id, because the two are only
97
+ * meaningful together: a channel cannot address one reader without knowing
98
+ * which. Slack reads this as `chat.postEphemeral`; a channel whose rooms hold
99
+ * one reader already has nothing to do.
100
+ */
101
+ export const MessageAudienceSchema = z.object({
102
+ kind: z.literal("oneReader"),
103
+ /** The reader, in the channel's own id space. */
104
+ userId: z.string(),
105
+ /**
106
+ * The organization the reader belongs to, for a channel whose user ids are
107
+ * only unique within one.
108
+ *
109
+ * Not every channel federates. Telegram and Discord user ids are global and
110
+ * ignore this; Slack ids are scoped to a workspace, and a guest reaching a
111
+ * shared channel from another one is only identified by the pair. A channel
112
+ * that needs it and does not get it cannot address the reader, so it treats
113
+ * the audience as unexpressible rather than guessing.
114
+ */
115
+ userOrgId: z.string().optional(),
116
+ });
117
+
118
+ export type MessageAudience = z.infer<typeof MessageAudienceSchema>;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Streaming a reply that grows
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * One step of a plan carried alongside a growing reply: an ordered,
126
+ * status-bearing unit of work the assistant reports while a turn runs.
98
127
  *
99
- * @see https://docs.slack.dev/reference/methods/chat.startStream/
100
- * @see https://docs.slack.dev/reference/methods/chat.appendStream/
101
- * @see https://docs.slack.dev/reference/methods/chat.stopStream/
128
+ * The vocabulary is the assistant's own `task_progress` surface rather than any
129
+ * channel's, so a channel that renders plans natively converts on its way out
130
+ * and a channel that cannot writes them as text. Slack's `task_update` chunk
131
+ * spells two of these statuses differently and its transport translates.
132
+ */
133
+ export const StreamPlanStepSchema = z.object({
134
+ label: z.string(),
135
+ status: z.enum(["pending", "in_progress", "completed", "failed"]),
136
+ detail: z.string().optional(),
137
+ });
138
+
139
+ export type StreamPlanStep = z.infer<typeof StreamPlanStepSchema>;
140
+
141
+ /** A plan shown with a growing reply: an optional title over ordered steps. */
142
+ export const StreamPlanSchema = z.object({
143
+ title: z.string().optional(),
144
+ steps: z.array(StreamPlanStepSchema),
145
+ });
146
+
147
+ export type StreamPlan = z.infer<typeof StreamPlanSchema>;
148
+
149
+ /**
150
+ * One operation on a reply that grows while the turn runs.
151
+ *
152
+ * Channels that can do this disagree on what the growing thing *is*, and the
153
+ * split is preview versus persist. Slack streams the reply itself:
154
+ * `chat.startStream` opens the message that will remain, and `chat.stopStream`
155
+ * finalizes it in place. Telegram streams a live draft, which is ephemeral,
156
+ * expires on its own, and never becomes the reply; the reply is persisted
157
+ * afterwards by an ordinary send. Discord has no primitive for either and
158
+ * omits `streamReply`, so the caller sends the finished reply and nothing is
159
+ * simulated on its behalf.
160
+ *
161
+ * That is why every operation carries both `text`, the whole of what the reply
162
+ * now reads as, and `appended`, only what is new since the last operation.
163
+ * Slack's append takes the delta; Telegram's draft takes the partial text and
164
+ * lets its clients animate the difference. Neither channel has to know which
165
+ * kind the other is, and a third can read whichever it needs.
166
+ *
167
+ * `text` is required on every operation because a channel that rewrites needs
168
+ * it on every one, and it is what the reply reads as rather than what this
169
+ * operation changed: absent is never the right answer, where empty can be.
170
+ * `appended` is what moved, so it is absent when only the plan did.
171
+ *
172
+ * `start` opens the growing reply and returns a `streamId` in the channel's
173
+ * own id space; `append` advances it; `stop` ends it. What `stop` leaves
174
+ * behind differs by channel, which is why it carries the complete reply rather
175
+ * than a remainder: a channel that finalized in place has already shown it,
176
+ * and a channel whose preview evaporates needs it to send the real message.
102
177
  */
103
- export const SlackStreamOpSchema = z
178
+ export const StreamOpSchema = z
104
179
  .discriminatedUnion("action", [
105
180
  z.object({
106
181
  action: z.literal("start"),
107
- threadTs: z.string(),
108
- markdownText: z.string().optional(),
109
- taskDisplayMode: z.literal("plan").optional(),
110
- /** Title of the plan block, serialized as a `plan_update` chunk. */
111
- planTitle: z.string().optional(),
112
- tasks: z.array(SlackStreamTaskSchema).optional(),
113
182
  /**
114
- * Slack user ID of the reader the stream targets. Required by
115
- * `chat.startStream` when streaming into a channel; omitted for DMs.
183
+ * The message this reply grows under, in the channel's own id space.
184
+ *
185
+ * Slack requires one: `chat.startStream` streams into a thread, and
186
+ * omitting it is only accepted where a whole channel is one session. A
187
+ * channel that threads nothing ignores it.
116
188
  */
117
- recipientUserId: z.string().optional(),
189
+ anchorMessageId: z.string().optional(),
190
+ text: z.string(),
191
+ appended: z.string().optional(),
192
+ plan: StreamPlanSchema.optional(),
118
193
  /**
119
- * Slack team ID the recipient belongs to. Required alongside
120
- * `recipientUserId` when streaming into a channel; omitted for DMs.
194
+ * Who may see the growing reply. Absent means everyone in the room, the
195
+ * same reading the finished reply gives it.
121
196
  */
122
- recipientTeamId: z.string().optional(),
197
+ audience: MessageAudienceSchema.optional(),
123
198
  }),
124
199
  z.object({
125
200
  action: z.literal("append"),
126
- streamTs: z.string(),
127
- markdownText: z.string().optional(),
128
- /** Title of the plan block, serialized as a `plan_update` chunk. */
129
- planTitle: z.string().optional(),
130
- tasks: z.array(SlackStreamTaskSchema).optional(),
201
+ /** The open reply, in the channel's own id space. */
202
+ streamId: z.string(),
203
+ text: z.string(),
204
+ appended: z.string().optional(),
205
+ plan: StreamPlanSchema.optional(),
131
206
  }),
132
207
  z.object({
133
208
  action: z.literal("stop"),
134
- streamTs: z.string(),
135
- markdownText: z.string().optional(),
136
- blocks: z.array(z.custom<KnownBlock>()).optional(),
137
- /** Title of the plan block, serialized as a `plan_update` chunk. */
138
- planTitle: z.string().optional(),
139
- tasks: z.array(SlackStreamTaskSchema).optional(),
209
+ streamId: z.string(),
210
+ /**
211
+ * The complete reply, not the remainder. A channel whose preview
212
+ * evaporates sends this as the message that stays; one that finalizes
213
+ * its stream in place has already shown it and appends `appended`.
214
+ */
215
+ text: z.string(),
216
+ appended: z.string().optional(),
217
+ plan: StreamPlanSchema.optional(),
140
218
  }),
141
219
  ])
142
220
  .superRefine((op, ctx) => {
143
- // Slack requires either `markdown_text` or `chunks` on `start`/`append`; a
144
- // task-only operation advances the plan block without new body text.
221
+ // An operation has to move something: new words, or the plan beside them.
222
+ // `text` alone cannot, since it restates what the reply already reads as.
223
+ // Slack rejects a start or append carrying neither, and a channel that
224
+ // rewrites would spend an edit redrawing what is already on screen.
145
225
  if (
146
226
  (op.action === "start" || op.action === "append") &&
147
- op.markdownText === undefined &&
148
- op.tasks === undefined
227
+ op.appended === undefined &&
228
+ op.plan === undefined
149
229
  ) {
150
230
  ctx.addIssue({
151
231
  code: z.ZodIssueCode.custom,
152
- message: `${op.action} requires markdownText or tasks`,
232
+ message: `${op.action} requires appended or plan`,
153
233
  });
154
234
  }
155
235
  });
156
236
 
157
- export type SlackStreamOp = z.infer<typeof SlackStreamOpSchema>;
158
-
159
- // ---------------------------------------------------------------------------
160
- // Audience
161
- // ---------------------------------------------------------------------------
162
-
163
- /**
164
- * Restricts a message to a single reader in a room that has more.
165
- *
166
- * A single object rather than a flag beside an id, because the two are only
167
- * meaningful together: a channel cannot address one reader without knowing
168
- * which. Slack reads this as `chat.postEphemeral`; a channel whose rooms hold
169
- * one reader already has nothing to do.
170
- */
171
- export const MessageAudienceSchema = z.object({
172
- kind: z.literal("oneReader"),
173
- /** The reader, in the channel's own id space. */
174
- userId: z.string(),
175
- });
176
-
177
- export type MessageAudience = z.infer<typeof MessageAudienceSchema>;
237
+ export type StreamOp = z.infer<typeof StreamOpSchema>;
178
238
 
179
239
  // ---------------------------------------------------------------------------
180
240
  // Channel reply payload — the full outbound wire format
@@ -197,7 +257,7 @@ export const ChannelReplyPayloadSchema = z.object({
197
257
  * Each channel decides what that means, and one that cannot ignores it.
198
258
  * Slack is the only channel acting on it today, where it becomes Block Kit.
199
259
  */
200
- useBlocks: z.boolean().optional(),
260
+ renderRichly: z.boolean().optional(),
201
261
  });
202
262
 
203
263
  export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  normalizeHttpPublicBaseUrl,
5
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash,
5
6
  normalizePublicBaseUrl,
7
+ parseRecordedAssistantId,
8
+ parseTunnelRecord,
9
+ trimmedNonEmptyString,
10
+ TUNNEL_PROVIDERS,
6
11
  velayHostForPlatformHost,
7
12
  } from "../ingress.js";
8
13
  import {
@@ -85,6 +90,35 @@ describe("normalizeHttpPublicBaseUrl", () => {
85
90
  });
86
91
  });
87
92
 
93
+ describe("normalizeHttpPublicBaseUrlWithoutTrailingSlash", () => {
94
+ test("drops the root path its sibling always emits", () => {
95
+ expect(
96
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test"),
97
+ ).toBe("https://x.test");
98
+ expect(
99
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test/"),
100
+ ).toBe("https://x.test");
101
+ expect(
102
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(" https://x.test/api/// "),
103
+ ).toBe("https://x.test/api");
104
+ });
105
+
106
+ test("rejects everything its sibling rejects", () => {
107
+ for (const value of [
108
+ "",
109
+ " ",
110
+ "notaurl",
111
+ "ftp://x.test",
112
+ "https://x.test?a=b",
113
+ 42,
114
+ ]) {
115
+ expect(
116
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(value),
117
+ ).toBeUndefined();
118
+ }
119
+ });
120
+ });
121
+
88
122
  describe("Twilio ingress helpers", () => {
89
123
  test("resolves public base URL with fallback", () => {
90
124
  expect(
@@ -123,3 +157,87 @@ describe("Twilio ingress helpers", () => {
123
157
  });
124
158
  });
125
159
  });
160
+
161
+ describe("parseTunnelRecord", () => {
162
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
163
+
164
+ test("accepts every provider in the registry", () => {
165
+ for (const provider of TUNNEL_PROVIDERS) {
166
+ expect(
167
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
168
+ ).toEqual({ provider, publicBaseUrl: TUNNEL_URL });
169
+ }
170
+ });
171
+
172
+ test("returns the URL in the shape its own validator produces", () => {
173
+ // Padding and trailing slashes are normalized away rather than handed
174
+ // back, so readers never re-normalize what they were given.
175
+ for (const publicBaseUrl of [
176
+ ` ${TUNNEL_URL} `,
177
+ `${TUNNEL_URL}/`,
178
+ `${TUNNEL_URL}///`,
179
+ ]) {
180
+ expect(
181
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
182
+ ).toEqual({ provider: "ngrok", publicBaseUrl: TUNNEL_URL });
183
+ }
184
+ });
185
+
186
+ test("rejects values that are not a record", () => {
187
+ for (const value of [undefined, null, "nonsense", 42, []]) {
188
+ expect(parseTunnelRecord(value)).toBeNull();
189
+ }
190
+ });
191
+
192
+ test("rejects a record missing either field", () => {
193
+ expect(parseTunnelRecord({ provider: "ngrok" })).toBeNull();
194
+ expect(parseTunnelRecord({ publicBaseUrl: TUNNEL_URL })).toBeNull();
195
+ });
196
+
197
+ test("rejects a provider outside the registry", () => {
198
+ // Readers render the provider into a `vellum tunnel --provider <name>`
199
+ // command, so a hand-edited config must not reach a terminal.
200
+ for (const provider of ["wireguard", "; rm -rf /", "", 42]) {
201
+ expect(
202
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
203
+ ).toBeNull();
204
+ }
205
+ });
206
+
207
+ test("rejects a URL that is not absolute HTTP(S)", () => {
208
+ for (const publicBaseUrl of [
209
+ "",
210
+ " ",
211
+ "not-a-url",
212
+ "example.ts.net",
213
+ "ftp://x.test",
214
+ "https://one.ngrok.app?token=x",
215
+ 42,
216
+ ]) {
217
+ expect(
218
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
219
+ ).toBeNull();
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("trimmedNonEmptyString", () => {
225
+ test("trims and rejects blank or non-string values", () => {
226
+ expect(trimmedNonEmptyString(" assistant-1 ")).toBe("assistant-1");
227
+ for (const value of ["", " ", undefined, null, 42]) {
228
+ expect(trimmedNonEmptyString(value)).toBeUndefined();
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("parseRecordedAssistantId", () => {
234
+ test("trims a recorded id", () => {
235
+ expect(parseRecordedAssistantId(" assistant-1 ")).toBe("assistant-1");
236
+ });
237
+
238
+ test("rejects blank and non-string values", () => {
239
+ for (const value of ["", " ", undefined, null, 42]) {
240
+ expect(parseRecordedAssistantId(value)).toBeNull();
241
+ }
242
+ });
243
+ });
@@ -48,3 +48,106 @@ export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * The same validation as `normalizeHttpPublicBaseUrl`, minus the root path it
54
+ * always emits (`https://x` becomes `https://x/`). Callers that append a path
55
+ * or persist the result want this shape, so they neither join through a double
56
+ * slash nor re-strip what the normalizer just added.
57
+ */
58
+ export function normalizeHttpPublicBaseUrlWithoutTrailingSlash(
59
+ value: unknown,
60
+ ): string | undefined {
61
+ return normalizePublicBaseUrl(normalizeHttpPublicBaseUrl(value));
62
+ }
63
+
64
+ /**
65
+ * A trimmed string, or undefined when the value is not a non-blank string.
66
+ *
67
+ * Exported because the identity check in the daemon's tunnel probe compares an
68
+ * id recorded here against one an edge serves, and the two sides have to trim
69
+ * alike: a padded served id must read as a match, not as a different
70
+ * assistant.
71
+ */
72
+ export function trimmedNonEmptyString(value: unknown): string | undefined {
73
+ if (typeof value !== "string") {
74
+ return undefined;
75
+ }
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 0 ? trimmed : undefined;
78
+ }
79
+
80
+ // Records `vellum tunnel` leaves in the workspace config's `ingress` section.
81
+ // The CLI writes them and the daemon reads them, and the two are separate
82
+ // build units that cannot import each other, so the key names, the provider
83
+ // allowlist, and the validation all live here rather than in a copy on each
84
+ // side.
85
+
86
+ /** Key under `ingress` holding the tunnel that most recently ran. */
87
+ export const INGRESS_LAST_TUNNEL_KEY = "lastTunnel";
88
+
89
+ /**
90
+ * Key under `ingress` holding a tunnel published for device pairing alone.
91
+ *
92
+ * A tailnet-only tunnel started while webhook integrations are configured
93
+ * cannot carry their callbacks, so it leaves `publicBaseUrl` (the callback
94
+ * base) as it is and records itself here. Readers that answer the pairing card
95
+ * prefer this address over `publicBaseUrl`; teardown drops the key.
96
+ */
97
+ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
98
+
99
+ /** Key under `ingress` holding the assistant id that tunnel fronted. */
100
+ export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
+
102
+ /**
103
+ * The local tunnel providers the CLI can start, in the order they are offered.
104
+ * `vellum tunnel`'s accepted `--provider` values and the persisted-record
105
+ * validation below both derive from it.
106
+ */
107
+ export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+
109
+ export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
+
111
+ /** A recorded tunnel, kept after teardown so UIs can name the command to restart. */
112
+ export interface TunnelRecord {
113
+ provider: TunnelProviderName;
114
+ publicBaseUrl: string;
115
+ }
116
+
117
+ function isTunnelProviderName(value: unknown): value is TunnelProviderName {
118
+ return (
119
+ typeof value === "string" &&
120
+ (TUNNEL_PROVIDERS as readonly string[]).includes(value)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Parse an `ingress.lastTunnel` or `ingress.pairingTunnel` value, or null when
126
+ * it is absent or malformed.
127
+ *
128
+ * A hand-edited config must not hand callers an unusable address or a provider
129
+ * name no command accepts: the provider is checked against the allowlist
130
+ * because readers render it into a restart command, and the URL faces the same
131
+ * absolute HTTP(S) constraint as every other public base URL. The URL comes
132
+ * back in the shape the validator produced, so readers never re-normalize it.
133
+ */
134
+ export function parseTunnelRecord(value: unknown): TunnelRecord | null {
135
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
136
+ return null;
137
+ }
138
+ const { provider, publicBaseUrl } = value as Record<string, unknown>;
139
+ if (!isTunnelProviderName(provider)) {
140
+ return null;
141
+ }
142
+ const normalized =
143
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(publicBaseUrl);
144
+ if (normalized === undefined) {
145
+ return null;
146
+ }
147
+ return { provider, publicBaseUrl: normalized };
148
+ }
149
+
150
+ /** Parse an `ingress.assistantId` value, or null when it is absent or blank. */
151
+ export function parseRecordedAssistantId(value: unknown): string | null {
152
+ return trimmedNonEmptyString(value) ?? null;
153
+ }