@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
@@ -1,6 +1,10 @@
1
- import type { SlackStreamOp, SlackStreamTask } from "@vellumai/gateway-client";
1
+ import type { StreamPlan } from "@vellumai/gateway-client";
2
2
 
3
- import type { AssistantEvent } from "../api/index.js";
3
+ import type {
4
+ AssistantEvent,
5
+ ToolResultEvent,
6
+ ToolUseStartEvent,
7
+ } from "../api/index.js";
4
8
  import {
5
9
  extractThreadTsFromCallbackUrl,
6
10
  isSlackDeliveryCallbackUrl,
@@ -12,19 +16,16 @@ import {
12
16
  } from "../daemon/assistant-attachments.js";
13
17
  import { sendChannelStreamOp } from "../messaging/providers/index.js";
14
18
  import { SLACK_STREAM_MARKDOWN_LIMIT } from "../messaging/providers/slack/api.js";
15
- import { renderSlackBlocks } from "../messaging/providers/slack/render.js";
16
19
  import { getLogger } from "../util/logger.js";
17
20
  import { needsBoundarySpace } from "../util/text-spacing.js";
18
21
  import {
19
22
  hasDeliverableAssistantText,
20
23
  NO_RESPONSE_INLINE_RE,
21
24
  } from "./no-response.js";
22
- import type { TaskProgressData } from "./slack-task-progress.js";
23
25
  import {
24
- getTaskProgressDataFromSurfaceData,
26
+ getTaskProgressDataFromToolInput,
25
27
  mergeTaskProgressData,
26
- toSlackStreamTasks,
27
- } from "./slack-task-progress.js";
28
+ } from "./task-progress.js";
28
29
 
29
30
  const log = getLogger("slack-reply-session");
30
31
 
@@ -155,8 +156,7 @@ export function createSlackReplySession(params: {
155
156
  // `renderHistoryContent`'s `joinWithSpacing` on the durable delivery path).
156
157
  let pendingSegmentBoundary = false;
157
158
 
158
- const taskProgressBySurfaceId = new Map<string, TaskProgressData>();
159
- let activeProgress: TaskProgressData | undefined;
159
+ let activeProgress: StreamPlan | undefined;
160
160
  // Fingerprint of the plan state last delivered to Slack, so progress that
161
161
  // advances without new body text still flushes as a task-only append.
162
162
  let deliveredProgressKey: string | undefined;
@@ -187,25 +187,8 @@ export function createSlackReplySession(params: {
187
187
  return stripVellumLinks(stable).replace(NO_RESPONSE_INLINE_RE, "");
188
188
  };
189
189
 
190
- const planTasks = (): SlackStreamTask[] | undefined =>
191
- activeProgress ? toSlackStreamTasks(activeProgress) : undefined;
192
-
193
- const progressKey = (
194
- title: string | undefined,
195
- tasks: SlackStreamTask[] | undefined,
196
- ): string | undefined =>
197
- tasks ? JSON.stringify({ title, tasks }) : undefined;
198
-
199
- // Typed from the stream operation these are passed to, so the element type
200
- // follows that contract rather than drifting from it.
201
- const imageBlocks = (
202
- text: string,
203
- ): Extract<SlackStreamOp, { action: "stop" }>["blocks"] => {
204
- const blocks = renderSlackBlocks(text)?.filter(
205
- (block) => block.type === "image",
206
- );
207
- return blocks && blocks.length > 0 ? blocks : undefined;
208
- };
190
+ const progressKey = (plan: StreamPlan | undefined): string | undefined =>
191
+ plan ? JSON.stringify(plan) : undefined;
209
192
 
210
193
  const enqueue = (op: () => Promise<void>): void => {
211
194
  opChain = opChain.catch(() => undefined).then(op);
@@ -218,28 +201,28 @@ export function createSlackReplySession(params: {
218
201
  return;
219
202
  }
220
203
  const firstChunk = clean.slice(0, SLACK_STREAM_MARKDOWN_LIMIT);
221
- const title = activeProgress?.title;
222
- const tasks = planTasks();
204
+ const plan = activeProgress;
223
205
  try {
224
206
  const result = await sendChannelStreamOp(replyCallbackUrl, chatId, {
225
207
  action: "start",
226
- threadTs,
227
- markdownText: firstChunk,
228
- // The task display mode is fixed for the stream's lifetime at
229
- // start, while a `task_progress` surface usually appears only
230
- // after the first text flush has opened the stream. Plan mode
231
- // only affects how task chunks render, so a stream that never
232
- // carries tasks still reads as a plain message.
233
- taskDisplayMode: "plan" as const,
234
- ...(title ? { planTitle: title } : {}),
235
- ...(tasks ? { tasks } : {}),
236
- ...(recipientUserId ? { recipientUserId } : {}),
237
- ...(recipientTeamId ? { recipientTeamId } : {}),
208
+ anchorMessageId: threadTs,
209
+ text: clean,
210
+ appended: firstChunk,
211
+ ...(plan ? { plan } : {}),
212
+ ...(recipientUserId
213
+ ? {
214
+ audience: {
215
+ kind: "oneReader" as const,
216
+ userId: recipientUserId,
217
+ ...(recipientTeamId ? { userOrgId: recipientTeamId } : {}),
218
+ },
219
+ }
220
+ : {}),
238
221
  });
239
222
  if (result.ok && result.ts) {
240
223
  streamTs = result.ts;
241
224
  confirmedLength = firstChunk.length;
242
- deliveredProgressKey = progressKey(title, tasks);
225
+ deliveredProgressKey = progressKey(plan);
243
226
  state = "streaming";
244
227
  // The stream is already open on Slack's side, so an `onStreamOpen`
245
228
  // failure must not downgrade to fallback and repost the visible
@@ -270,9 +253,8 @@ export function createSlackReplySession(params: {
270
253
  return;
271
254
  }
272
255
  const clean = streamableText();
273
- const title = activeProgress?.title;
274
- const tasks = planTasks();
275
- const key = progressKey(title, tasks);
256
+ const plan = activeProgress;
257
+ const key = progressKey(plan);
276
258
  // `chat.appendStream` caps `markdown_text` per call, so a delta wider
277
259
  // than the limit drains across successive append calls. Each append
278
260
  // carries the current task state, advancing the plan alongside text.
@@ -284,10 +266,10 @@ export function createSlackReplySession(params: {
284
266
  try {
285
267
  await sendChannelStreamOp(replyCallbackUrl, chatId, {
286
268
  action: "append",
287
- streamTs,
288
- markdownText: chunk,
289
- ...(title ? { planTitle: title } : {}),
290
- ...(tasks ? { tasks } : {}),
269
+ streamId: streamTs,
270
+ text: clean,
271
+ appended: chunk,
272
+ ...(plan ? { plan } : {}),
291
273
  });
292
274
  confirmedLength += chunk.length;
293
275
  deliveredProgressKey = key ?? deliveredProgressKey;
@@ -305,13 +287,13 @@ export function createSlackReplySession(params: {
305
287
  // A failure disables further task-only appends for the session; the
306
288
  // unchanged fingerprint leaves the update pending, so it rides the
307
289
  // next text append and `stopStream` carries the final state.
308
- if (!taskOnlyAppendsDisabled && tasks && key !== deliveredProgressKey) {
290
+ if (!taskOnlyAppendsDisabled && plan && key !== deliveredProgressKey) {
309
291
  try {
310
292
  await sendChannelStreamOp(replyCallbackUrl, chatId, {
311
293
  action: "append",
312
- streamTs,
313
- ...(title ? { planTitle: title } : {}),
314
- tasks,
294
+ streamId: streamTs,
295
+ text: clean,
296
+ plan,
315
297
  });
316
298
  deliveredProgressKey = key;
317
299
  } catch (err) {
@@ -363,24 +345,68 @@ export function createSlackReplySession(params: {
363
345
  }
364
346
  };
365
347
 
366
- const observeTaskProgress = (msg: AssistantEvent): void => {
367
- if (msg.type === "ui_surface_show") {
368
- const progress = getTaskProgressDataFromSurfaceData(msg.data);
369
- if (!progress) {
370
- return;
371
- }
372
- taskProgressBySurfaceId.set(msg.surfaceId, progress);
373
- } else if (msg.type === "ui_surface_update") {
374
- const existing = taskProgressBySurfaceId.get(msg.surfaceId);
375
- const progress = mergeTaskProgressData(existing, msg.data);
376
- if (!progress) {
377
- return;
378
- }
379
- taskProgressBySurfaceId.set(msg.surfaceId, progress);
380
- } else {
348
+ /**
349
+ * The plan tool calls this turn has made, awaiting their results.
350
+ *
351
+ * `ui_show` and `ui_update` are tools the model reaches for, so a plan is
352
+ * turn output and arrives on this session's own stream. The daemon also
353
+ * publishes `ui_surface_show`, but that goes to the conversation sink, which
354
+ * no channel consumes.
355
+ *
356
+ * Held rather than applied on sight, because a `tool_use_start` is an
357
+ * intention: the surface tool rejects a stale `surface_id` or a malformed
358
+ * payload, and showing a plan the canonical surface refused is worse than
359
+ * showing none. An update is also merged at result time so it composes onto
360
+ * the plan actually in effect rather than the one in effect when the call
361
+ * began.
362
+ *
363
+ * `ui_show` carries no surface id in its input, since the id is minted when
364
+ * the tool runs, so a later `ui_update` cannot be matched to the card it
365
+ * targets. One plan per turn is tracked instead, which is what a map keyed by
366
+ * surface id collapsed to in practice: whichever entry was touched last was
367
+ * the one rendered.
368
+ */
369
+ const pendingPlanCalls = new Map<
370
+ string,
371
+ {
372
+ readonly kind: "show" | "update";
373
+ readonly input: Record<string, unknown>;
374
+ }
375
+ >();
376
+
377
+ const observePlanToolStart = (msg: ToolUseStartEvent): void => {
378
+ const kind =
379
+ msg.toolName === "ui_show"
380
+ ? "show"
381
+ : msg.toolName === "ui_update"
382
+ ? "update"
383
+ : undefined;
384
+ if (!kind || !msg.toolUseId) {
385
+ return;
386
+ }
387
+ pendingPlanCalls.set(msg.toolUseId, { kind, input: msg.input });
388
+ };
389
+
390
+ const observePlanToolResult = (msg: ToolResultEvent): void => {
391
+ if (!msg.toolUseId) {
392
+ return;
393
+ }
394
+ const pending = pendingPlanCalls.get(msg.toolUseId);
395
+ if (!pending) {
396
+ return;
397
+ }
398
+ pendingPlanCalls.delete(msg.toolUseId);
399
+ if (msg.isError === true) {
400
+ return;
401
+ }
402
+ const progress =
403
+ pending.kind === "show"
404
+ ? getTaskProgressDataFromToolInput(pending.input)
405
+ : mergeTaskProgressData(activeProgress, pending.input.data);
406
+ if (!progress) {
381
407
  return;
382
408
  }
383
- activeProgress = taskProgressBySurfaceId.get(msg.surfaceId);
409
+ activeProgress = progress;
384
410
  scheduleFlush();
385
411
  };
386
412
 
@@ -390,9 +416,15 @@ export function createSlackReplySession(params: {
390
416
  return;
391
417
  }
392
418
 
393
- if (msg.type === "ui_surface_show" || msg.type === "ui_surface_update") {
394
- observeTaskProgress(msg);
395
- return;
419
+ if (msg.type === "tool_use_start") {
420
+ observePlanToolStart(msg);
421
+ }
422
+ // guard:allow-tool-result-only. This reads `AssistantEvent`, not the
423
+ // provider content blocks the guard protects: `web_search_tool_result`
424
+ // is a block type in conversation history and never an event, so no
425
+ // result can be dropped by omitting it.
426
+ if (msg.type === "tool_result") {
427
+ observePlanToolResult(msg);
396
428
  }
397
429
  if (msg.type === "assistant_text_delta") {
398
430
  if (pendingSegmentBoundary && msg.text.length > 0) {
@@ -433,17 +465,17 @@ export function createSlackReplySession(params: {
433
465
  }
434
466
  const clean = cleanedText();
435
467
  const remaining = clean.slice(confirmedLength);
436
- const blocks = imageBlocks(clean);
437
- const title = activeProgress?.title;
438
- const tasks = planTasks();
468
+ const plan = activeProgress;
439
469
  try {
440
470
  await sendChannelStreamOp(replyCallbackUrl, chatId, {
441
471
  action: "stop",
442
- streamTs,
443
- ...(remaining.length > 0 ? { markdownText: remaining } : {}),
444
- ...(blocks ? { blocks } : {}),
445
- ...(title ? { planTitle: title } : {}),
446
- ...(tasks ? { tasks } : {}),
472
+ streamId: streamTs,
473
+ // The whole reply, not the remainder. A channel that finalizes its
474
+ // stream in place appends `appended`; one whose preview evaporates
475
+ // needs this to send the message that stays.
476
+ text: clean,
477
+ ...(remaining.length > 0 ? { appended: remaining } : {}),
478
+ ...(plan ? { plan } : {}),
447
479
  });
448
480
  confirmedLength = clean.length;
449
481
  } catch (err) {
@@ -3,8 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import {
4
4
  getTaskProgressDataFromSurfaceData,
5
5
  mergeTaskProgressData,
6
- toSlackStreamTasks,
7
- } from "./slack-task-progress.js";
6
+ } from "./task-progress.js";
8
7
 
9
8
  describe("getTaskProgressDataFromSurfaceData", () => {
10
9
  test("reads title, steps, and step details from a task_progress surface", () => {
@@ -99,29 +98,3 @@ describe("mergeTaskProgressData", () => {
99
98
  });
100
99
  });
101
100
  });
102
-
103
- describe("toSlackStreamTasks", () => {
104
- test("maps steps onto Slack task cards with stable ids and details", () => {
105
- expect(
106
- toSlackStreamTasks({
107
- title: "Quick Briefing",
108
- steps: [
109
- {
110
- label: "Check weather",
111
- status: "completed",
112
- detail: "Forecast fetched",
113
- },
114
- { label: "Summarize", status: "failed" },
115
- ],
116
- }),
117
- ).toEqual([
118
- {
119
- id: "task-0",
120
- title: "Check weather",
121
- status: "complete",
122
- details: "Forecast fetched",
123
- },
124
- { id: "task-1", title: "Summarize", status: "error" },
125
- ]);
126
- });
127
- });
@@ -1,20 +1,6 @@
1
- import type { SlackStreamTask } from "@vellumai/gateway-client";
1
+ import type { StreamPlan, StreamPlanStep } from "@vellumai/gateway-client";
2
2
 
3
- /**
4
- * A single step of a `task_progress` UI surface: an ordered, status-bearing
5
- * unit of work the assistant reports while a turn runs.
6
- */
7
- export type TaskProgressStep = {
8
- label: string;
9
- status: "pending" | "in_progress" | "completed" | "failed";
10
- detail?: string;
11
- };
12
-
13
- export type TaskProgressData = {
14
- /** Plan title shown above the steps (e.g. "Q2 Launch Plan"). */
15
- title?: string;
16
- steps: TaskProgressStep[];
17
- };
3
+ import { coerceSurfaceDataRecord } from "../api/surfaces.js";
18
4
 
19
5
  function isRecord(value: unknown): value is Record<string, unknown> {
20
6
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -23,7 +9,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
23
9
  /** Read a `task_progress` surface payload into typed steps, if it is one. */
24
10
  export function getTaskProgressDataFromSurfaceData(
25
11
  data: unknown,
26
- ): TaskProgressData | undefined {
12
+ ): StreamPlan | undefined {
27
13
  if (!isRecord(data)) {
28
14
  return undefined;
29
15
  }
@@ -33,12 +19,12 @@ export function getTaskProgressDataFromSurfaceData(
33
19
  return parseTaskProgressData(data.templateData);
34
20
  }
35
21
 
36
- function parseTaskProgressData(value: unknown): TaskProgressData | undefined {
22
+ function parseTaskProgressData(value: unknown): StreamPlan | undefined {
37
23
  if (!isRecord(value) || !Array.isArray(value.steps)) {
38
24
  return undefined;
39
25
  }
40
26
 
41
- const steps = value.steps.flatMap((step): TaskProgressStep[] => {
27
+ const steps = value.steps.flatMap((step): StreamPlanStep[] => {
42
28
  if (!isRecord(step)) {
43
29
  return [];
44
30
  }
@@ -72,15 +58,38 @@ function parseTaskProgressData(value: unknown): TaskProgressData | undefined {
72
58
  return { ...(title ? { title } : {}), steps };
73
59
  }
74
60
 
61
+ /**
62
+ * Task progress from a `ui_show` or `ui_update` tool call's input.
63
+ *
64
+ * The model reaches for `ui_show` to draw a plan, so the card's contents ride
65
+ * the turn's own tool-call stream, which every channel consumer already reads.
66
+ * The `ui_surface_show` event the daemon publishes alongside it goes to the
67
+ * conversation sink instead, and no channel consumes that, so reading the tool
68
+ * input is what lets a channel render a plan at all.
69
+ *
70
+ * The template may sit at the top level of the input or nested under `data`,
71
+ * and `data` itself may arrive double-encoded as a JSON string, which is why
72
+ * the coercion is shared with the surface tools rather than restated here.
73
+ */
74
+ export function getTaskProgressDataFromToolInput(
75
+ input: unknown,
76
+ ): StreamPlan | undefined {
77
+ const record = coerceSurfaceDataRecord(input);
78
+ return (
79
+ getTaskProgressDataFromSurfaceData(record) ??
80
+ getTaskProgressDataFromSurfaceData(coerceSurfaceDataRecord(record.data))
81
+ );
82
+ }
83
+
75
84
  /**
76
85
  * Apply a `ui_surface_update` payload onto the steps already known for a
77
86
  * surface. A full `task_progress` payload replaces the steps; a partial
78
87
  * `templateData` update is merged over the existing steps.
79
88
  */
80
89
  export function mergeTaskProgressData(
81
- existing: TaskProgressData | undefined,
90
+ existing: StreamPlan | undefined,
82
91
  data: unknown,
83
- ): TaskProgressData | undefined {
92
+ ): StreamPlan | undefined {
84
93
  if (!isRecord(data)) {
85
94
  return existing;
86
95
  }
@@ -98,33 +107,3 @@ export function mergeTaskProgressData(
98
107
  ...(isRecord(data.templateData) ? data.templateData : {}),
99
108
  });
100
109
  }
101
-
102
- const TASK_PROGRESS_STATUS_TO_SLACK: Record<
103
- TaskProgressStep["status"],
104
- SlackStreamTask["status"]
105
- > = {
106
- pending: "pending",
107
- in_progress: "in_progress",
108
- completed: "complete",
109
- failed: "error",
110
- };
111
-
112
- /**
113
- * Map ordered `task_progress` steps onto Slack streaming task cards. Step
114
- * position supplies the stable card `id` (a step keeps its index across
115
- * updates), the label becomes the card title, the step detail becomes the
116
- * card details, and the surface status maps onto Slack's task-card status
117
- * vocabulary.
118
- *
119
- * @see https://docs.slack.dev/ai/developing-agents
120
- */
121
- export function toSlackStreamTasks(
122
- progress: TaskProgressData,
123
- ): SlackStreamTask[] {
124
- return progress.steps.map((step, index) => ({
125
- id: `task-${index}`,
126
- title: step.label,
127
- status: TASK_PROGRESS_STATUS_TO_SLACK[step.status],
128
- ...(step.detail ? { details: step.detail } : {}),
129
- }));
130
- }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  escapeContentBoundaries,
5
+ escapeTagBoundaries,
5
6
  parseExternalContentEnvelope,
6
7
  unwrapExternalContentForDisplay,
7
8
  wrapUntrustedContent,
@@ -79,6 +80,47 @@ describe("wrapUntrustedContent", () => {
79
80
  });
80
81
  });
81
82
 
83
+ describe("escapeTagBoundaries", () => {
84
+ // A complete literal tag is the easiest boundary to forge and the least
85
+ // interesting. Matching stops at the tag name so the near-misses a model
86
+ // still reads as a fence go too.
87
+ const VARIANTS = [
88
+ "</watch-timeline>",
89
+ "</watch-timeline >",
90
+ "</watch-timeline\n>",
91
+ "</WATCH-TIMELINE>",
92
+ "</WaTcH-TiMeLiNe>",
93
+ "</watch-timeline",
94
+ '</watch-timeline id="x">',
95
+ "<watch-timeline>",
96
+ "<watch-timeline",
97
+ ];
98
+
99
+ for (const variant of VARIANTS) {
100
+ test(`neutralizes ${JSON.stringify(variant)}`, () => {
101
+ const escaped = escapeTagBoundaries(
102
+ `before ${variant} after`,
103
+ "watch-timeline",
104
+ );
105
+ expect(escaped).not.toMatch(/<\/?watch-timeline/i);
106
+ expect(escaped).toContain("&lt;");
107
+ // Neutralized in place: the surrounding text is untouched.
108
+ expect(escaped.startsWith("before ")).toBe(true);
109
+ expect(escaped.endsWith(" after")).toBe(true);
110
+ });
111
+ }
112
+
113
+ test("leaves a different tag alone", () => {
114
+ const axTree = "<ax-tree>Window: Editor</ax-tree>";
115
+ expect(escapeTagBoundaries(axTree, "watch-timeline")).toBe(axTree);
116
+ });
117
+
118
+ test("leaves ordinary markup alone", () => {
119
+ const safe = "Hello, this is a normal note about <html> tags.";
120
+ expect(escapeTagBoundaries(safe, "watch-timeline")).toBe(safe);
121
+ });
122
+ });
123
+
82
124
  describe("escapeContentBoundaries", () => {
83
125
  test("escapes closing tag", () => {
84
126
  expect(escapeContentBoundaries("</external_content>")).toBe(
@@ -58,6 +58,9 @@ const DEFAULT_BUDGETS: Record<UntrustedContentSource, number> = {
58
58
 
59
59
  const UNTRUSTED_CONTENT_SOURCE_SET = new Set<string>(UNTRUSTED_CONTENT_SOURCES);
60
60
 
61
+ /** Name of the wrapper element untrusted content is fenced in. */
62
+ const EXTERNAL_CONTENT_TAG_NAME = "external_content";
63
+
61
64
  const EXTERNAL_CONTENT_ENVELOPE_PATTERN =
62
65
  /^<external_content\s+([^\r\n<>]*)>\n([\s\S]*)\n<\/external_content>$/;
63
66
 
@@ -113,24 +116,40 @@ export function unwrapExternalContentForDisplay(value: string): string {
113
116
  }
114
117
 
115
118
  /**
116
- * Escape sequences that could forge or break out of the
117
- * `<external_content>` wrapper. Case-insensitive to cover mixed-case
119
+ * Escape sequences that could forge or break out of a `<tagName>` fence
120
+ * placed around untrusted text. Case-insensitive to cover mixed-case
118
121
  * evasion attempts.
119
122
  *
120
123
  * Both tag forms are escaped. A closing tag would end the fence early.
121
- * An opening tag is subtler: it lets external content fabricate what
122
- * reads as a second envelope — one it gets to label with any `source` it
123
- * likes — and it defeats any structural reasoning over the result, since
124
- * a forged tag is indistinguishable from a real one. Nothing legitimate
125
- * arrives from the outside world carrying either.
124
+ * An opening tag is subtler: it lets untrusted text fabricate what reads
125
+ * as a second fence, one it gets to label with any attribute it likes, and
126
+ * it defeats any structural reasoning over the result, since a forged tag
127
+ * is indistinguishable from a real one. Nothing legitimate arrives from
128
+ * the outside world carrying either.
129
+ *
130
+ * Matching stops at the tag name rather than at a closing `>`, so the
131
+ * variants a model still reads as a boundary are all neutralized:
132
+ * `</tag >`, a newline before the `>`, an unclosed `</tag`, and anything
133
+ * with attributes. Requiring the full literal tag would let every one of
134
+ * those through.
135
+ *
136
+ * Not to be confused with `escapeAxTreeContent` in
137
+ * `context/outbound-sanitize.ts`, which escapes the closing `</ax-tree>`
138
+ * only and must keep doing so: AX-tree compaction finds whole blocks by
139
+ * their opening tag and replaces them with a placeholder.
126
140
  */
127
- export function escapeContentBoundaries(content: string): string {
141
+ export function escapeTagBoundaries(content: string, tagName: string): string {
128
142
  return content.replace(
129
- /<\/?external_content/gi,
143
+ new RegExp(`</?${tagName}`, "gi"),
130
144
  (match) => `&lt;${match.slice(1)}`,
131
145
  );
132
146
  }
133
147
 
148
+ /** {@link escapeTagBoundaries} for the `<external_content>` wrapper. */
149
+ export function escapeContentBoundaries(content: string): string {
150
+ return escapeTagBoundaries(content, EXTERNAL_CONTENT_TAG_NAME);
151
+ }
152
+
134
153
  // ---------------------------------------------------------------------------
135
154
  // Helpers
136
155
  // ---------------------------------------------------------------------------
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ liveVoiceEndScreen,
5
+ liveVoiceSilenceReason,
6
+ } from "../live-voice-funnel.js";
7
+
8
+ describe("liveVoiceSilenceReason", () => {
9
+ it("blames the transport when the session never went active", () => {
10
+ // Nothing after `ready` can be held against a session that never got
11
+ // there: the user had no chance to speak.
12
+ expect(
13
+ liveVoiceSilenceReason({
14
+ reachedActive: false,
15
+ receivedAudio: false,
16
+ detectedSpeech: false,
17
+ }),
18
+ ).toBe("no_ready");
19
+ });
20
+
21
+ it("blames the transport even if stray audio arrived first", () => {
22
+ // Audio can land before the preflight resolves. The session still never
23
+ // became usable, and reporting that as a microphone problem would send
24
+ // someone looking in the wrong layer.
25
+ expect(
26
+ liveVoiceSilenceReason({
27
+ reachedActive: false,
28
+ receivedAudio: true,
29
+ detectedSpeech: true,
30
+ }),
31
+ ).toBe("no_ready");
32
+ });
33
+
34
+ it("reports a microphone that never opened", () => {
35
+ expect(
36
+ liveVoiceSilenceReason({
37
+ reachedActive: true,
38
+ receivedAudio: false,
39
+ detectedSpeech: false,
40
+ }),
41
+ ).toBe("no_audio");
42
+ });
43
+
44
+ it("separates a mic that opened but carried no speech", () => {
45
+ // The distinction that makes this taxonomy worth having: a denied
46
+ // permission and a muted mic both produce a silent session, and only this
47
+ // pair of flags tells them apart.
48
+ expect(
49
+ liveVoiceSilenceReason({
50
+ reachedActive: true,
51
+ receivedAudio: true,
52
+ detectedSpeech: false,
53
+ }),
54
+ ).toBe("no_speech");
55
+ });
56
+
57
+ it("reports speech that never became a turn", () => {
58
+ expect(
59
+ liveVoiceSilenceReason({
60
+ reachedActive: true,
61
+ receivedAudio: true,
62
+ detectedSpeech: true,
63
+ }),
64
+ ).toBe("no_turn");
65
+ });
66
+ });
67
+
68
+ describe("liveVoiceEndScreen", () => {
69
+ it("carries the close reason alone when nothing else applies", () => {
70
+ expect(liveVoiceEndScreen("client_end")).toBe("ended_client_end");
71
+ });
72
+
73
+ it("carries a failure code in the detail slot", () => {
74
+ expect(liveVoiceEndScreen("error", "invalid_field")).toBe(
75
+ "ended_error:invalid_field",
76
+ );
77
+ });
78
+
79
+ it("carries a silence reason when the session merely produced nothing", () => {
80
+ expect(liveVoiceEndScreen("client_end", null, "no_audio")).toBe(
81
+ "ended_client_end:silent_no_audio",
82
+ );
83
+ });
84
+
85
+ it("prefers the failure code when a session both failed and was silent", () => {
86
+ // A session that died on an error is explained by the error; its silence
87
+ // is a consequence, not a second finding.
88
+ expect(liveVoiceEndScreen("error", "invalid_field", "no_audio")).toBe(
89
+ "ended_error:invalid_field",
90
+ );
91
+ });
92
+
93
+ it("keeps every combination inside the wire field's 64-char bound", () => {
94
+ // `screen` is capped at 64 chars by the ingest serializer and an over-long
95
+ // value is dropped silently, taking the whole row with it.
96
+ const longest = liveVoiceEndScreen("transport_closed", null, "no_speech");
97
+ expect(longest.length).toBeLessThanOrEqual(64);
98
+ });
99
+
100
+ it("prefixes the silence value so it reads correctly in a mislabelled column", () => {
101
+ // It shares the detail slot with failure codes, and the admin dashboard
102
+ // renders that slot under a "failure code" header until it learns the
103
+ // difference. The prefix keeps the value honest meanwhile.
104
+ expect(liveVoiceEndScreen("client_end", null, "no_turn")).toContain(
105
+ "silent_",
106
+ );
107
+ });
108
+ });