@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,7 +1,10 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import type { ChannelDeliveryResult } from "@vellumai/gateway-client";
4
- import { ChannelReplyPayloadSchema } from "@vellumai/gateway-client";
3
+ import type { ChannelDeliveryResult, StreamOp } from "@vellumai/gateway-client";
4
+ import {
5
+ ChannelReplyPayloadSchema,
6
+ StreamOpSchema,
7
+ } from "@vellumai/gateway-client";
5
8
 
6
9
  type DeliverCall = { callbackUrl: string; payload: Record<string, unknown> };
7
10
 
@@ -14,8 +17,8 @@ let deliverImpl: (
14
17
  ts: "stream-ts-1",
15
18
  });
16
19
 
17
- const sentStreamOps: Array<Record<string, unknown>> = [];
18
- let streamOpImpl: (op: Record<string, unknown>) => Promise<{
20
+ const sentStreamOps: StreamOp[] = [];
21
+ let streamOpImpl: (op: StreamOp) => Promise<{
19
22
  ok: boolean;
20
23
  ts?: string;
21
24
  }> = async () => ({ ok: true, ts: "stream-ts-1" });
@@ -23,8 +26,13 @@ mock.module("../messaging/providers/index.js", () => ({
23
26
  sendChannelStreamOp: async (
24
27
  _callbackUrl: string,
25
28
  _chatId: string,
26
- op: Record<string, unknown>,
29
+ op: StreamOp,
27
30
  ) => {
31
+ // Every op the session emits must be valid on the wire, the same
32
+ // guarantee the reply-payload mock below enforces. A task-only append
33
+ // carrying neither words nor a plan would move nothing, and the
34
+ // refinement is what says so.
35
+ StreamOpSchema.parse(op);
28
36
  sentStreamOps.push(op);
29
37
  return streamOpImpl(op);
30
38
  },
@@ -37,7 +45,7 @@ mock.module("./gateway-client.js", () => ({
37
45
  ) => {
38
46
  // Every payload the session emits must be valid on the wire — in
39
47
  // particular a task-only append must satisfy the contract's
40
- // markdownText-or-tasks refinement.
48
+ // text-or-appended-or-plan refinement.
41
49
  ChannelReplyPayloadSchema.parse(payload);
42
50
  deliverCalls.push({ callbackUrl, payload });
43
51
  return deliverImpl(callbackUrl, payload);
@@ -78,45 +86,72 @@ const messageComplete = (messageId: string): AssistantEvent =>
78
86
  messageId,
79
87
  }) as AssistantEvent;
80
88
 
89
+ /**
90
+ * The model drawing a plan: a `ui_show` tool call on the turn's own stream,
91
+ * which is how a plan reaches a channel.
92
+ *
93
+ * Built as the tool call rather than as a `ui_surface_show` event, because the
94
+ * event goes to the conversation sink and no channel consumes that. A helper
95
+ * that emitted the event would exercise a path production never takes.
96
+ */
81
97
  const taskProgressShow = (
82
- surfaceId: string,
83
98
  steps: Array<{ label: string; status: string; detail?: string }>,
84
99
  templateTitle?: string,
85
100
  ): AssistantEvent =>
86
101
  ({
87
- type: "ui_surface_show",
102
+ type: "tool_use_start",
103
+ toolName: "ui_show",
88
104
  conversationId: "conv-stream",
89
- surfaceId,
90
- surfaceType: "card",
91
- data: {
105
+ toolUseId: "tool-ui-show",
106
+ input: {
107
+ surface_type: "card",
92
108
  title: "Task progress",
93
- template: "task_progress",
94
- templateData: {
95
- ...(templateTitle ? { title: templateTitle } : {}),
96
- steps,
109
+ data: {
110
+ template: "task_progress",
111
+ templateData: {
112
+ ...(templateTitle ? { title: templateTitle } : {}),
113
+ steps,
114
+ },
97
115
  },
98
116
  },
99
117
  }) as AssistantEvent;
100
118
 
119
+ /**
120
+ * The successful result of the tool call above. A plan is only applied once its
121
+ * call reports success, so a test that shows a plan without one asserts that
122
+ * nothing renders.
123
+ */
124
+ const toolOk = (toolUseId: string, toolName: string): AssistantEvent =>
125
+ ({
126
+ type: "tool_result",
127
+ toolName,
128
+ result: "ok",
129
+ conversationId: "conv-stream",
130
+ toolUseId,
131
+ }) as AssistantEvent;
132
+
101
133
  const taskProgressUpdate = (
102
- surfaceId: string,
103
134
  steps: Array<{ label: string; status: string }>,
104
135
  ): AssistantEvent =>
105
136
  ({
106
- type: "ui_surface_update",
137
+ type: "tool_use_start",
138
+ toolName: "ui_update",
107
139
  conversationId: "conv-stream",
108
- surfaceId,
109
- data: { templateData: { steps } },
140
+ toolUseId: "tool-ui-update",
141
+ input: {
142
+ surface_id: "surface-1",
143
+ data: { templateData: { steps } },
144
+ },
110
145
  }) as AssistantEvent;
111
146
 
112
147
  const tick = (ms: number): Promise<void> =>
113
148
  new Promise((resolve) => setTimeout(resolve, ms));
114
149
 
115
- const slackStreamOps = (): Array<Record<string, unknown>> => sentStreamOps;
150
+ const slackStreamOps = (): StreamOp[] => sentStreamOps;
116
151
 
117
152
  const streamedMarkdown = (): string =>
118
153
  slackStreamOps()
119
- .map((op) => (op.markdownText as string | undefined) ?? "")
154
+ .map((op) => op.appended ?? "")
120
155
  .join("");
121
156
 
122
157
  beforeEach(() => {
@@ -231,11 +266,15 @@ describe("createSlackReplySession", () => {
231
266
  expect(slackStreamOps()).toEqual([
232
267
  {
233
268
  action: "start",
234
- threadTs: THREAD_TS,
235
- markdownText: "The complete answer.",
236
- taskDisplayMode: "plan",
269
+ anchorMessageId: THREAD_TS,
270
+ text: "The complete answer.",
271
+ appended: "The complete answer.",
272
+ },
273
+ {
274
+ action: "stop",
275
+ streamId: "stream-ts-1",
276
+ text: "The complete answer.",
237
277
  },
238
- { action: "stop", streamTs: "stream-ts-1" },
239
278
  ]);
240
279
  expect(reconciliation).toEqual({
241
280
  mode: "streamed",
@@ -262,13 +301,16 @@ describe("createSlackReplySession", () => {
262
301
  expect(slackStreamOps()).toEqual([
263
302
  {
264
303
  action: "start",
265
- threadTs: THREAD_TS,
266
- markdownText: "The complete answer.",
267
- taskDisplayMode: "plan",
268
- recipientUserId: "U123",
269
- recipientTeamId: "T123",
304
+ anchorMessageId: THREAD_TS,
305
+ text: "The complete answer.",
306
+ appended: "The complete answer.",
307
+ audience: { kind: "oneReader", userId: "U123", userOrgId: "T123" },
308
+ },
309
+ {
310
+ action: "stop",
311
+ streamId: "stream-ts-1",
312
+ text: "The complete answer.",
270
313
  },
271
- { action: "stop", streamTs: "stream-ts-1" },
272
314
  ]);
273
315
  });
274
316
 
@@ -290,16 +332,21 @@ describe("createSlackReplySession", () => {
290
332
  expect(slackStreamOps()).toEqual([
291
333
  {
292
334
  action: "start",
293
- threadTs: THREAD_TS,
294
- markdownText: "First half. ",
295
- taskDisplayMode: "plan",
335
+ anchorMessageId: THREAD_TS,
336
+ text: "First half. ",
337
+ appended: "First half. ",
296
338
  },
297
339
  {
298
340
  action: "append",
299
- streamTs: "stream-ts-1",
300
- markdownText: "Second half.",
341
+ streamId: "stream-ts-1",
342
+ text: "First half. Second half.",
343
+ appended: "Second half.",
344
+ },
345
+ {
346
+ action: "stop",
347
+ streamId: "stream-ts-1",
348
+ text: "First half. Second half.",
301
349
  },
302
- { action: "stop", streamTs: "stream-ts-1" },
303
350
  ]);
304
351
  expect(reconciliation.mode).toBe("streamed");
305
352
  });
@@ -319,12 +366,9 @@ describe("createSlackReplySession", () => {
319
366
 
320
367
  const ops = slackStreamOps();
321
368
  expect(ops.map((op) => op.action)).toEqual(["start", "append", "stop"]);
322
- expect((ops[0].markdownText as string).length).toBe(
323
- SLACK_STREAM_MARKDOWN_LIMIT,
324
- );
325
- expect((ops[1].markdownText as string).length).toBe(4_000);
326
- const streamed =
327
- (ops[0].markdownText as string) + (ops[1].markdownText as string);
369
+ expect((ops[0]!.appended ?? "").length).toBe(SLACK_STREAM_MARKDOWN_LIMIT);
370
+ expect((ops[1]!.appended ?? "").length).toBe(4_000);
371
+ const streamed = (ops[0]!.appended ?? "") + (ops[1]!.appended ?? "");
328
372
  expect(streamed).toBe(body);
329
373
  });
330
374
 
@@ -431,39 +475,49 @@ describe("createSlackReplySession", () => {
431
475
  })!;
432
476
 
433
477
  session.observeEvent(
434
- taskProgressShow("surface-1", [
478
+ taskProgressShow([
435
479
  { label: "Search docs", status: "in_progress" },
436
480
  { label: "Summarize", status: "pending" },
437
481
  ]),
438
482
  );
483
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
439
484
  session.observeEvent(textDelta("Working on it."));
440
485
  await tick(15);
441
486
  session.observeEvent(
442
- taskProgressUpdate("surface-1", [
487
+ taskProgressUpdate([
443
488
  { label: "Search docs", status: "completed" },
444
489
  { label: "Summarize", status: "in_progress" },
445
490
  ]),
446
491
  );
492
+ session.observeEvent(toolOk("tool-ui-update", "ui_update"));
447
493
  await tick(15);
448
494
 
449
495
  expect(slackStreamOps().at(-1)).toEqual({
450
496
  action: "append",
451
- streamTs: "stream-ts-1",
452
- tasks: [
453
- { id: "task-0", title: "Search docs", status: "complete" },
454
- { id: "task-1", title: "Summarize", status: "in_progress" },
455
- ],
497
+ streamId: "stream-ts-1",
498
+ // No `appended`: the plan moved and the words did not. `text` is still
499
+ // the whole reply, which is what a channel that rewrites needs.
500
+ text: "Working on it.",
501
+ plan: {
502
+ steps: [
503
+ { label: "Search docs", status: "completed" },
504
+ { label: "Summarize", status: "in_progress" },
505
+ ],
506
+ },
456
507
  });
457
508
 
458
509
  await session.finish();
459
510
 
460
511
  expect(slackStreamOps().at(-1)).toEqual({
461
512
  action: "stop",
462
- streamTs: "stream-ts-1",
463
- tasks: [
464
- { id: "task-0", title: "Search docs", status: "complete" },
465
- { id: "task-1", title: "Summarize", status: "in_progress" },
466
- ],
513
+ streamId: "stream-ts-1",
514
+ text: "Working on it.",
515
+ plan: {
516
+ steps: [
517
+ { label: "Search docs", status: "completed" },
518
+ { label: "Summarize", status: "in_progress" },
519
+ ],
520
+ },
467
521
  });
468
522
  });
469
523
 
@@ -477,17 +531,15 @@ describe("createSlackReplySession", () => {
477
531
  })!;
478
532
 
479
533
  session.observeEvent(
480
- taskProgressShow("surface-1", [
481
- { label: "Search docs", status: "in_progress" },
482
- ]),
534
+ taskProgressShow([{ label: "Search docs", status: "in_progress" }]),
483
535
  );
536
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
484
537
  session.observeEvent(textDelta("Working on it."));
485
538
  await tick(15);
486
539
  session.observeEvent(
487
- taskProgressUpdate("surface-1", [
488
- { label: "Search docs", status: "in_progress" },
489
- ]),
540
+ taskProgressUpdate([{ label: "Search docs", status: "in_progress" }]),
490
541
  );
542
+ session.observeEvent(toolOk("tool-ui-update", "ui_update"));
491
543
  await tick(15);
492
544
 
493
545
  // The start already delivered this exact plan state; a matching update
@@ -495,9 +547,53 @@ describe("createSlackReplySession", () => {
495
547
  expect(slackStreamOps().map((op) => op.action)).toEqual(["start"]);
496
548
  });
497
549
 
550
+ test("does not advance a plan the surface tool rejected", async () => {
551
+ const session = createSlackReplySession({
552
+ sourceChannel: "slack",
553
+ chatType: "im",
554
+ replyCallbackUrl: CALLBACK_URL,
555
+ chatId: CHANNEL,
556
+ coalesceMs: 5,
557
+ })!;
558
+
559
+ session.observeEvent(
560
+ taskProgressShow([{ label: "Search docs", status: "in_progress" }]),
561
+ );
562
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
563
+ session.observeEvent(textDelta("Working on it."));
564
+ await tick(15);
565
+
566
+ // A stale surface id or a malformed payload is rejected by the surface
567
+ // tool. The call is an intention, so a plan applied on sight would show a
568
+ // step completed that the canonical surface never accepted.
569
+ session.observeEvent(
570
+ taskProgressUpdate([{ label: "Search docs", status: "completed" }]),
571
+ );
572
+ session.observeEvent({
573
+ type: "tool_result",
574
+ toolName: "ui_update",
575
+ result: "Error: unknown surface_id",
576
+ isError: true,
577
+ conversationId: "conv-stream",
578
+ toolUseId: "tool-ui-update",
579
+ } as AssistantEvent);
580
+ await tick(15);
581
+ await session.finish();
582
+
583
+ // Reads the plan in the assistant's own vocabulary. While the op carried
584
+ // Slack task cards this assertion could not fail: their word for the
585
+ // status is "complete", so "completed" was never going to appear whatever
586
+ // the session did with the rejected update.
587
+ const statuses = slackStreamOps()
588
+ .flatMap((op) => op.plan?.steps ?? [])
589
+ .map((step) => step.status);
590
+ expect(statuses).not.toContain("completed");
591
+ expect(statuses).toContain("in_progress");
592
+ });
593
+
498
594
  test("leaves progress to stop when the task-only append fails", async () => {
499
595
  streamOpImpl = async (op) => {
500
- if (op.action === "append" && op.markdownText === undefined) {
596
+ if (op.action === "append" && op.appended === undefined) {
501
597
  throw new Error("chunks-only append rejected");
502
598
  }
503
599
  return { ok: true, ts: "stream-ts-1" };
@@ -513,23 +609,21 @@ describe("createSlackReplySession", () => {
513
609
  session.observeEvent(textDelta("Working on it."));
514
610
  await tick(15);
515
611
  session.observeEvent(
516
- taskProgressShow("surface-1", [
517
- { label: "Search docs", status: "in_progress" },
518
- ]),
612
+ taskProgressShow([{ label: "Search docs", status: "in_progress" }]),
519
613
  );
614
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
520
615
  await tick(15);
521
616
  session.observeEvent(
522
- taskProgressUpdate("surface-1", [
523
- { label: "Search docs", status: "completed" },
524
- ]),
617
+ taskProgressUpdate([{ label: "Search docs", status: "completed" }]),
525
618
  );
619
+ session.observeEvent(toolOk("tool-ui-update", "ui_update"));
526
620
  await tick(15);
527
621
  const reconciliation = await session.finish();
528
622
 
529
623
  // The first rejection disables task-only appends for the session, so
530
624
  // later progress updates do not retry a doomed call.
531
625
  const taskOnlyAttempts = slackStreamOps().filter(
532
- (op) => op.action === "append" && op.markdownText === undefined,
626
+ (op) => op.action === "append" && op.appended === undefined,
533
627
  );
534
628
  expect(taskOnlyAttempts.length).toBe(1);
535
629
 
@@ -538,8 +632,9 @@ describe("createSlackReplySession", () => {
538
632
  expect(reconciliation.mode).toBe("streamed");
539
633
  expect(slackStreamOps().at(-1)).toEqual({
540
634
  action: "stop",
541
- streamTs: "stream-ts-1",
542
- tasks: [{ id: "task-0", title: "Search docs", status: "complete" }],
635
+ streamId: "stream-ts-1",
636
+ text: "Working on it.",
637
+ plan: { steps: [{ label: "Search docs", status: "completed" }] },
543
638
  });
544
639
  });
545
640
 
@@ -613,9 +708,7 @@ describe("createSlackReplySession", () => {
613
708
  await session.finish();
614
709
 
615
710
  const ops = slackStreamOps();
616
- const streamed = ops
617
- .map((op) => (op.markdownText as string | undefined) ?? "")
618
- .join("");
711
+ const streamed = ops.map((op) => op.appended ?? "").join("");
619
712
  expect(streamed).toBe("Here is your file: report.pdf");
620
713
  expect(streamed).not.toContain("vellum://");
621
714
  });
@@ -727,40 +820,47 @@ describe("createSlackReplySession", () => {
727
820
  })!;
728
821
 
729
822
  session.observeEvent(
730
- taskProgressShow("surface-1", [
823
+ taskProgressShow([
731
824
  { label: "Search docs", status: "in_progress" },
732
825
  { label: "Summarize", status: "pending" },
733
826
  ]),
734
827
  );
828
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
735
829
  session.observeEvent(textDelta("Working on it."));
736
830
  await tick(15);
737
831
  session.observeEvent(
738
- taskProgressUpdate("surface-1", [
832
+ taskProgressUpdate([
739
833
  { label: "Search docs", status: "completed" },
740
834
  { label: "Summarize", status: "in_progress" },
741
835
  ]),
742
836
  );
837
+ session.observeEvent(toolOk("tool-ui-update", "ui_update"));
743
838
  session.observeEvent(messageComplete("assistant-msg-1"));
744
839
  const reconciliation = await session.finish();
745
840
 
746
841
  const ops = slackStreamOps();
747
842
  expect(ops[0]).toEqual({
748
843
  action: "start",
749
- threadTs: THREAD_TS,
750
- markdownText: "Working on it.",
751
- taskDisplayMode: "plan",
752
- tasks: [
753
- { id: "task-0", title: "Search docs", status: "in_progress" },
754
- { id: "task-1", title: "Summarize", status: "pending" },
755
- ],
844
+ anchorMessageId: THREAD_TS,
845
+ text: "Working on it.",
846
+ appended: "Working on it.",
847
+ plan: {
848
+ steps: [
849
+ { label: "Search docs", status: "in_progress" },
850
+ { label: "Summarize", status: "pending" },
851
+ ],
852
+ },
756
853
  });
757
854
  expect(ops.at(-1)).toEqual({
758
855
  action: "stop",
759
- streamTs: "stream-ts-1",
760
- tasks: [
761
- { id: "task-0", title: "Search docs", status: "complete" },
762
- { id: "task-1", title: "Summarize", status: "in_progress" },
763
- ],
856
+ streamId: "stream-ts-1",
857
+ text: "Working on it.",
858
+ plan: {
859
+ steps: [
860
+ { label: "Search docs", status: "completed" },
861
+ { label: "Summarize", status: "in_progress" },
862
+ ],
863
+ },
764
864
  });
765
865
  expect(reconciliation.mode).toBe("streamed");
766
866
  });
@@ -779,14 +879,15 @@ describe("createSlackReplySession", () => {
779
879
  coalesceMs: 5,
780
880
  })!;
781
881
 
782
- session.observeEvent(textDelta("On it — starting now."));
882
+ session.observeEvent(textDelta("On it, starting now."));
783
883
  await tick(15);
784
884
  session.observeEvent(
785
- taskProgressShow("surface-1", [
885
+ taskProgressShow([
786
886
  { label: "Search docs", status: "in_progress" },
787
887
  { label: "Summarize", status: "pending" },
788
888
  ]),
789
889
  );
890
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
790
891
  await tick(15);
791
892
  session.observeEvent(messageComplete("assistant-msg-1"));
792
893
  await session.finish();
@@ -794,25 +895,31 @@ describe("createSlackReplySession", () => {
794
895
  expect(slackStreamOps()).toEqual([
795
896
  {
796
897
  action: "start",
797
- threadTs: THREAD_TS,
798
- markdownText: "On it — starting now.",
799
- taskDisplayMode: "plan",
898
+ anchorMessageId: THREAD_TS,
899
+ text: "On it, starting now.",
900
+ appended: "On it, starting now.",
800
901
  },
801
902
  {
802
903
  action: "append",
803
- streamTs: "stream-ts-1",
804
- tasks: [
805
- { id: "task-0", title: "Search docs", status: "in_progress" },
806
- { id: "task-1", title: "Summarize", status: "pending" },
807
- ],
904
+ streamId: "stream-ts-1",
905
+ text: "On it, starting now.",
906
+ plan: {
907
+ steps: [
908
+ { label: "Search docs", status: "in_progress" },
909
+ { label: "Summarize", status: "pending" },
910
+ ],
911
+ },
808
912
  },
809
913
  {
810
914
  action: "stop",
811
- streamTs: "stream-ts-1",
812
- tasks: [
813
- { id: "task-0", title: "Search docs", status: "in_progress" },
814
- { id: "task-1", title: "Summarize", status: "pending" },
815
- ],
915
+ streamId: "stream-ts-1",
916
+ text: "On it, starting now.",
917
+ plan: {
918
+ steps: [
919
+ { label: "Search docs", status: "in_progress" },
920
+ { label: "Summarize", status: "pending" },
921
+ ],
922
+ },
816
923
  },
817
924
  ]);
818
925
  });
@@ -828,7 +935,6 @@ describe("createSlackReplySession", () => {
828
935
 
829
936
  session.observeEvent(
830
937
  taskProgressShow(
831
- "surface-1",
832
938
  [
833
939
  {
834
940
  label: "Check weather",
@@ -840,6 +946,7 @@ describe("createSlackReplySession", () => {
840
946
  "Quick Briefing",
841
947
  ),
842
948
  );
949
+ session.observeEvent(toolOk("tool-ui-show", "ui_show"));
843
950
  session.observeEvent(textDelta("Working on it."));
844
951
  await tick(15);
845
952
  session.observeEvent(messageComplete("assistant-msg-1"));
@@ -848,23 +955,25 @@ describe("createSlackReplySession", () => {
848
955
  const ops = slackStreamOps();
849
956
  expect(ops[0]).toEqual({
850
957
  action: "start",
851
- threadTs: THREAD_TS,
852
- markdownText: "Working on it.",
853
- taskDisplayMode: "plan",
854
- planTitle: "Quick Briefing",
855
- tasks: [
856
- {
857
- id: "task-0",
858
- title: "Check weather",
859
- status: "in_progress",
860
- details: "Fetching the forecast",
861
- },
862
- { id: "task-1", title: "Summarize", status: "pending" },
863
- ],
864
- });
865
- expect(ops.at(-1)).toMatchObject({
866
- action: "stop",
867
- planTitle: "Quick Briefing",
958
+ anchorMessageId: THREAD_TS,
959
+ text: "Working on it.",
960
+ appended: "Working on it.",
961
+ plan: {
962
+ title: "Quick Briefing",
963
+ steps: [
964
+ {
965
+ label: "Check weather",
966
+ status: "in_progress",
967
+ detail: "Fetching the forecast",
968
+ },
969
+ { label: "Summarize", status: "pending" },
970
+ ],
971
+ },
868
972
  });
973
+ // The plan title survives to the final stop, so a reader who only sees
974
+ // the finished message still sees what the plan was called.
975
+ const final = ops.at(-1);
976
+ expect(final?.action).toBe("stop");
977
+ expect(final?.plan?.title).toBe("Quick Briefing");
869
978
  });
870
979
  });