@vellumai/assistant 0.11.4-dev.202608192008.2b8f565 → 0.11.4-dev.202608192109.b91153c

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.
@@ -131,7 +131,6 @@ function buildManagedOutboundRequestId(
131
131
  chatId: payload.chatId,
132
132
  text: normalizedText,
133
133
  assistantId: payload.assistantId ?? null,
134
- chatAction: payload.chatAction ?? null,
135
134
  hasAttachments:
136
135
  Array.isArray(payload.attachments) && payload.attachments.length > 0,
137
136
  approvalRequestId: payload.approval?.requestId ?? null,
@@ -212,7 +211,6 @@ async function deliverManagedOutboundReply(
212
211
  chatId: payload.chatId,
213
212
  text: payload.text ?? null,
214
213
  assistantId: payload.assistantId ?? null,
215
- chatAction: payload.chatAction ?? null,
216
214
  hasAttachments,
217
215
  sourceUpdateId: callback.sourceUpdateId ?? null,
218
216
  },
@@ -325,7 +323,12 @@ export async function deliverChannelReply(
325
323
  ): Promise<ChannelDeliveryResult> {
326
324
  const managedCallback = parseManagedOutboundCallback(callbackUrl);
327
325
  if (managedCallback) {
328
- await deliverManagedOutboundReply(managedCallback, payload, bearerToken, log);
326
+ await deliverManagedOutboundReply(
327
+ managedCallback,
328
+ payload,
329
+ bearerToken,
330
+ log,
331
+ );
329
332
  return { ok: true };
330
333
  }
331
334
 
@@ -385,17 +388,7 @@ export async function deliverChannelReply(
385
388
  // Response may not be JSON for non-Slack channels; that's fine.
386
389
  }
387
390
 
388
- if (payload.chatAction) {
389
- log.debug(
390
- { chatId: payload.chatId, callbackUrl, chatAction: payload.chatAction },
391
- "Channel action delivered",
392
- );
393
- } else {
394
- log.info(
395
- { chatId: payload.chatId, callbackUrl },
396
- "Channel reply delivered",
397
- );
398
- }
391
+ log.info({ chatId: payload.chatId, callbackUrl }, "Channel reply delivered");
399
392
 
400
393
  return result;
401
394
  }
@@ -168,7 +168,6 @@ export const ChannelReplyPayloadSchema = z.object({
168
168
  assistantId: z.string().optional(),
169
169
  attachments: z.array(AttachmentMetadataSchema).optional(),
170
170
  approval: ApprovalUIMetadataSchema.optional(),
171
- chatAction: z.literal("typing").optional(),
172
171
  /**
173
172
  * When true, deliver via `chat.postEphemeral` so only the target `user`
174
173
  * sees the message.
@@ -180,14 +179,6 @@ export const ChannelReplyPayloadSchema = z.object({
180
179
  messageTs: z.string().optional(),
181
180
  /** When true, the daemon generates Block Kit blocks from the text before delivery. */
182
181
  useBlocks: z.boolean().optional(),
183
- /** When provided, add or remove an emoji reaction on a message. */
184
- reaction: z
185
- .object({
186
- action: z.enum(["add", "remove"]),
187
- name: z.string(),
188
- messageTs: z.string(),
189
- })
190
- .optional(),
191
182
  /** When provided, set or clear the Slack Assistants API thread status. */
192
183
  assistantThreadStatus: z
193
184
  .object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608192008.2b8f565",
3
+ "version": "0.11.4-dev.202608192109.b91153c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,8 +57,14 @@ mock.module("../../../util/logger.js", () => ({
57
57
  getLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }),
58
58
  }));
59
59
 
60
- const { deliverDirect, isDirectDelivery, getTransportForCallback } =
61
- await import("../index.js");
60
+ const {
61
+ deliverDirect,
62
+ sendChannelReaction,
63
+ sendChannelTyping,
64
+ supportsChannelTyping,
65
+ isDirectDelivery,
66
+ getTransportForCallback,
67
+ } = await import("../index.js");
62
68
 
63
69
  const BASE = "https://gateway.internal";
64
70
 
@@ -134,17 +140,14 @@ describe("Slack sub-operation selection", () => {
134
140
  expect(opts.threadTs).toBe("1700.9");
135
141
  });
136
142
 
137
- test("reaction routes to sendSlackReaction, not the text path", async () => {
138
- await deliverDirect(
139
- `${BASE}/deliver/slack`,
140
- payload({
141
- reaction: {
142
- action: "add",
143
- name: "white_check_mark",
144
- messageTs: "1700.5",
145
- },
146
- }),
147
- );
143
+ test("sendChannelReaction reaches Slack without touching the text path", async () => {
144
+ await sendChannelReaction(`${BASE}/deliver/slack`, {
145
+ chatId: "C1",
146
+ messageId: "1700.5",
147
+ emoji: "white_check_mark",
148
+ action: "add",
149
+ });
150
+
148
151
  expect(slack.sendSlackReaction).toHaveBeenCalledTimes(1);
149
152
  expect(slack.sendSlackReply).not.toHaveBeenCalled();
150
153
  });
@@ -164,12 +167,11 @@ describe("Slack sub-operation selection", () => {
164
167
  expect(slack.sendSlackReply).not.toHaveBeenCalled();
165
168
  });
166
169
 
167
- test("a typing payload to Slack falls through to deliver (no typing capability)", async () => {
168
- await deliverDirect(
169
- `${BASE}/deliver/slack`,
170
- payload({ chatAction: "typing", text: "hi" }),
171
- );
172
- expect(slack.sendSlackReply).toHaveBeenCalledTimes(1);
170
+ test("sendChannelTyping resolves quietly for a channel with no typing capability", async () => {
171
+ const result = await sendChannelTyping(`${BASE}/deliver/slack`, "C1");
172
+
173
+ expect(result).toEqual({ ok: true });
174
+ expect(slack.sendSlackReply).not.toHaveBeenCalled();
173
175
  });
174
176
 
175
177
  test("slackStream routes to sendSlackStreamOp ahead of the text path", async () => {
@@ -191,22 +193,35 @@ describe("Slack sub-operation selection", () => {
191
193
  });
192
194
 
193
195
  describe("capability gating across channels", () => {
194
- test("a reaction payload to Telegram falls through to deliver (no sendReaction)", async () => {
195
- await deliverDirect(
196
- `${BASE}/deliver/telegram`,
197
- payload({
198
- text: "hi",
199
- reaction: { action: "add", name: "x", messageTs: "1" },
200
- }),
201
- );
202
- expect(telegram.sendTelegramReply).toHaveBeenCalledTimes(1);
196
+ test("a channel with no reaction capability resolves quietly", async () => {
197
+ // Slack is the only channel that implements it, because the only producer
198
+ // is Slack's own acknowledgement fallback. A channel without the method
199
+ // is not a failed delivery, so nothing is attempted and nothing throws.
200
+ const target = {
201
+ chatId: "C1",
202
+ messageId: "1",
203
+ emoji: "eyes",
204
+ action: "add",
205
+ } as const;
206
+ expect(
207
+ await sendChannelReaction(`${BASE}/deliver/telegram`, target),
208
+ ).toEqual({ ok: true });
209
+ expect(telegram.sendTelegramReply).not.toHaveBeenCalled();
203
210
  });
204
211
 
205
- test("typing to Telegram routes to its typing indicator", async () => {
206
- await deliverDirect(
207
- `${BASE}/deliver/telegram`,
208
- payload({ chatAction: "typing" }),
209
- );
212
+ test("the typing capability is read from the transport, not the channel name", () => {
213
+ // The heartbeat gate in background-dispatch asks this rather than testing
214
+ // `sourceChannel === "telegram"`, so a channel that implements the method
215
+ // starts showing an indicator without a caller being changed.
216
+ expect(supportsChannelTyping(`${BASE}/deliver/telegram`)).toBe(true);
217
+ expect(supportsChannelTyping(`${BASE}/deliver/discord`)).toBe(true);
218
+ expect(supportsChannelTyping(`${BASE}/deliver/slack`)).toBe(false);
219
+ expect(supportsChannelTyping(`${BASE}/deliver/whatsapp`)).toBe(false);
220
+ });
221
+
222
+ test("sendChannelTyping reaches Telegram's typing indicator", async () => {
223
+ await sendChannelTyping(`${BASE}/deliver/telegram`, "123");
224
+
210
225
  expect(telegram.sendTelegramTypingIndicator).toHaveBeenCalledTimes(1);
211
226
  });
212
227
 
@@ -244,15 +259,10 @@ describe("capability gating across channels", () => {
244
259
  });
245
260
  });
246
261
 
247
- test("a typing payload to Discord falls through to deliver (no sendTyping)", async () => {
248
- // Only the Telegram heartbeat produces `chatAction: "typing"`, and it is
249
- // gated on sourceChannel === "telegram", so Discord can never receive one.
250
- // The transport implements no sendTyping, so this falls through.
251
- await deliverDirect(
252
- `${BASE}/deliver/discord`,
253
- payload({ chatAction: "typing", text: "hi" }),
254
- );
255
- expect(discord.sendDiscordReply).toHaveBeenCalledTimes(1);
262
+ test("sendChannelTyping reaches Discord's typing indicator", async () => {
263
+ await sendChannelTyping(`${BASE}/deliver/discord`, "999");
264
+
265
+ expect(discord.sendDiscordTypingIndicator).toHaveBeenCalledTimes(1);
256
266
  });
257
267
 
258
268
  test("a Slack-only stream payload to Discord falls through to deliver", async () => {
@@ -15,6 +15,15 @@ export interface CallbackContext {
15
15
  readonly params: Readonly<Record<string, string>>;
16
16
  }
17
17
 
18
+ /** The message an emoji reaction lands on, and what to do there. */
19
+ export interface ReactionTarget {
20
+ readonly chatId: string;
21
+ /** The target message, in the channel's own id space. */
22
+ readonly messageId: string;
23
+ readonly emoji: string;
24
+ readonly action: "add" | "remove";
25
+ }
26
+
18
27
  /**
19
28
  * Direct outbound delivery for one channel, wrapping the channel's provider-API
20
29
  * send functions behind a uniform surface. Transports are registered statically
@@ -35,16 +44,26 @@ export interface ChannelTransport {
35
44
  payload: ChannelReplyPayload,
36
45
  ): Promise<ChannelDeliveryResult>;
37
46
 
38
- /** Send a typing indicator. Routed when `payload.chatAction === "typing"`. */
39
- sendTyping?(
40
- ctx: CallbackContext,
41
- payload: ChannelReplyPayload,
42
- ): Promise<ChannelDeliveryResult>;
47
+ /**
48
+ * Show that the assistant is working, in whatever form the channel has.
49
+ *
50
+ * Takes the place and nothing else, because that is all it needs. A channel
51
+ * without the affordance omits the method, so implementing it is the whole
52
+ * of declaring the capability.
53
+ */
54
+ typing?(ctx: CallbackContext, chatId: string): Promise<ChannelDeliveryResult>;
43
55
 
44
- /** Add an emoji reaction. Routed when `payload.reaction` is set. */
45
- sendReaction?(
56
+ /**
57
+ * Add or remove one of the assistant's own emoji reactions on a message.
58
+ *
59
+ * `messageId` is the target's id in the channel's own space, the same way
60
+ * `chatId` is: Slack spells it as a timestamp, Discord and Telegram as ids.
61
+ * Nothing outside the channel reads it, so nothing outside needs a shared
62
+ * spelling for it.
63
+ */
64
+ react?(
46
65
  ctx: CallbackContext,
47
- payload: ChannelReplyPayload,
66
+ target: ReactionTarget,
48
67
  ): Promise<ChannelDeliveryResult>;
49
68
 
50
69
  /** Update an assistant-thread status surface. Routed when `payload.assistantThreadStatus` is set. */
@@ -210,3 +210,19 @@ export async function sendDiscordAttachments(
210
210
  totalCount: attachments.length,
211
211
  };
212
212
  }
213
+
214
+ /**
215
+ * Show the Discord typing indicator in a channel.
216
+ *
217
+ * Discord expires it after about ten seconds, so a caller that wants it to
218
+ * persist re-sends on a timer rather than clearing it; there is no stop route.
219
+ */
220
+ export async function sendDiscordTypingIndicator(
221
+ target: DiscordSendTarget,
222
+ ): Promise<void> {
223
+ await callDiscordApi(
224
+ "POST",
225
+ `/channels/${encodeURIComponent(target.channelId)}/typing`,
226
+ {},
227
+ );
228
+ }
@@ -7,7 +7,11 @@ import type {
7
7
  } from "../channel-transport.js";
8
8
  import { openDiscordDmChannel } from "./api.js";
9
9
  import type { DiscordSendTarget } from "./send.js";
10
- import { sendDiscordAttachments, sendDiscordReply } from "./send.js";
10
+ import {
11
+ sendDiscordAttachments,
12
+ sendDiscordReply,
13
+ sendDiscordTypingIndicator,
14
+ } from "./send.js";
11
15
 
12
16
  const log = getLogger("discord-transport");
13
17
 
@@ -43,6 +47,12 @@ async function sendTarget(
43
47
  export const discordTransport: ChannelTransport = {
44
48
  channel: "discord",
45
49
 
50
+ async typing(ctx, chatId) {
51
+ await sendDiscordTypingIndicator(await sendTarget(ctx, chatId));
52
+ log.debug({ chatId }, "Discord typing indicator delivered (direct)");
53
+ return { ok: true };
54
+ },
55
+
46
56
  async deliver(ctx, payload) {
47
57
  const { chatId, text, attachments, approval } = payload;
48
58
  const target = await sendTarget(ctx, chatId);
@@ -16,7 +16,11 @@ import type {
16
16
  import { a2aTransport } from "./a2a/transport.js";
17
17
  import type { DirectDeliveryChannel } from "./callback-routing.js";
18
18
  import { channelForCallback } from "./callback-routing.js";
19
- import type { CallbackContext, ChannelTransport } from "./channel-transport.js";
19
+ import type {
20
+ CallbackContext,
21
+ ChannelTransport,
22
+ ReactionTarget,
23
+ } from "./channel-transport.js";
20
24
  import { discordTransport } from "./discord/transport.js";
21
25
  import { slackTransport } from "./slack/transport.js";
22
26
  import { telegramTransport } from "./telegram-bot/transport.js";
@@ -45,6 +49,52 @@ export function getTransportForCallback(
45
49
  return channel ? TRANSPORTS[channel] : undefined;
46
50
  }
47
51
 
52
+ /**
53
+ * Whether the channel this callback addresses can show a working indicator.
54
+ *
55
+ * Asks the transport rather than the channel id, so a channel that gains the
56
+ * method starts being asked without a caller being told about it.
57
+ */
58
+ export function supportsChannelTyping(callbackUrl: string): boolean {
59
+ return getTransportForCallback(callbackUrl)?.typing !== undefined;
60
+ }
61
+
62
+ /**
63
+ * Show that the assistant is working on the channel this callback addresses.
64
+ *
65
+ * Resolves to nothing when the channel has no such affordance, which is the
66
+ * ordinary case rather than a failure: the indicator is decoration, and a
67
+ * channel that cannot show one is not degraded by its absence.
68
+ */
69
+ export async function sendChannelTyping(
70
+ callbackUrl: string,
71
+ chatId: string,
72
+ ): Promise<ChannelDeliveryResult> {
73
+ const transport = getTransportForCallback(callbackUrl);
74
+ if (!transport?.typing) {
75
+ return { ok: true };
76
+ }
77
+ return transport.typing(callbackContext(callbackUrl), chatId);
78
+ }
79
+
80
+ /**
81
+ * Add or remove one of the assistant's own reactions on a message.
82
+ *
83
+ * Resolves to nothing when the channel has none, the same as typing: a
84
+ * reaction is an acknowledgement, and a channel that cannot show one is not a
85
+ * failed delivery.
86
+ */
87
+ export async function sendChannelReaction(
88
+ callbackUrl: string,
89
+ target: ReactionTarget,
90
+ ): Promise<ChannelDeliveryResult> {
91
+ const transport = getTransportForCallback(callbackUrl);
92
+ if (!transport?.react) {
93
+ return { ok: true };
94
+ }
95
+ return transport.react(callbackContext(callbackUrl), target);
96
+ }
97
+
48
98
  function callbackContext(callbackUrl: string): CallbackContext {
49
99
  const params: Record<string, string> = {};
50
100
  try {
@@ -74,9 +124,9 @@ export function isDirectDelivery(callbackUrl: string): boolean {
74
124
  * Deliver a channel reply directly to the provider API, bypassing the gateway
75
125
  * HTTP proxy. Callers MUST check `isDirectDelivery()` first.
76
126
  *
77
- * Sub-operations (reaction, thread status, typing) route to the transport's
78
- * optional method when both the payload field and the method are present;
79
- * otherwise the reply is delivered as text / approval / attachments.
127
+ * Sub-operations (reaction, thread status) route to the transport's optional
128
+ * method when both the payload field and the method are present; otherwise
129
+ * the reply is delivered as text / approval / attachments.
80
130
  */
81
131
  export async function deliverDirect(
82
132
  callbackUrl: string,
@@ -93,14 +143,8 @@ export async function deliverDirect(
93
143
  if (payload.slackStream && transport.streamReply) {
94
144
  return transport.streamReply(ctx, payload);
95
145
  }
96
- if (payload.reaction && transport.sendReaction) {
97
- return transport.sendReaction(ctx, payload);
98
- }
99
146
  if (payload.assistantThreadStatus && transport.setThreadStatus) {
100
147
  return transport.setThreadStatus(ctx, payload);
101
148
  }
102
- if (payload.chatAction === "typing" && transport.sendTyping) {
103
- return transport.sendTyping(ctx, payload);
104
- }
105
149
  return transport.deliver(ctx, payload);
106
150
  }
@@ -54,16 +54,12 @@ export const slackTransport: ChannelTransport = {
54
54
  return { ok: true, ts: sentTs };
55
55
  },
56
56
 
57
- async sendReaction(_ctx, payload) {
58
- const reaction = payload.reaction;
59
- if (!reaction) {
60
- return { ok: true };
61
- }
57
+ async react(_ctx, target) {
62
58
  await sendSlackReaction(
63
- payload.chatId,
64
- reaction.name,
65
- reaction.messageTs,
66
- reaction.action,
59
+ target.chatId,
60
+ target.emoji,
61
+ target.messageId,
62
+ target.action,
67
63
  );
68
64
  return { ok: true };
69
65
  },
@@ -272,10 +272,7 @@ describe("telegramTransport topic targeting", () => {
272
272
  });
273
273
 
274
274
  test("typing indicators target the topic", async () => {
275
- await telegramTransport.sendTyping!(
276
- topicCtx,
277
- payload({ chatAction: "typing" }),
278
- );
275
+ await telegramTransport.typing!(topicCtx, "123");
279
276
 
280
277
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendChatAction", {
281
278
  chat_id: "123",
@@ -291,10 +288,7 @@ describe("telegramTransport topic targeting", () => {
291
288
  };
292
289
 
293
290
  await telegramTransport.deliver(bareCtx, payload({ useBlocks: false }));
294
- await telegramTransport.sendTyping!(
295
- bareCtx,
296
- payload({ chatAction: "typing" }),
297
- );
291
+ await telegramTransport.typing!(bareCtx, "123");
298
292
 
299
293
  expect(callTelegramBotApiMock).toHaveBeenCalledWith("sendMessage", {
300
294
  chat_id: "123",
@@ -67,12 +67,9 @@ export const telegramTransport: ChannelTransport = {
67
67
  return { ok: true };
68
68
  },
69
69
 
70
- async sendTyping(ctx, payload) {
71
- await sendTelegramTypingIndicator(payload.chatId, threadOptions(ctx));
72
- log.debug(
73
- { chatId: payload.chatId },
74
- "Telegram typing indicator delivered (direct)",
75
- );
70
+ async typing(ctx, chatId) {
71
+ await sendTelegramTypingIndicator(chatId, threadOptions(ctx));
72
+ log.debug({ chatId }, "Telegram typing indicator delivered (direct)");
76
73
  return { ok: true };
77
74
  },
78
75
  };
@@ -74,6 +74,36 @@ describe("adapter factory", () => {
74
74
  ).toBe(true);
75
75
  });
76
76
 
77
+ test("openai-compatible round-trips thinking as reasoning_content", () => {
78
+ const adapter = buildProviderAdapter("openai-compatible", {
79
+ apiKey: "test-key",
80
+ model: "deepseek-reasoner",
81
+ streamTimeoutMs: 60_000,
82
+ baseURL: "https://example.com/v1",
83
+ useNativeWebSearch: false,
84
+ });
85
+ expect(adapter).toBeInstanceOf(OpenAIChatCompletionsProvider);
86
+ expect(
87
+ (adapter as unknown as { assistantReasoningField?: string })
88
+ .assistantReasoningField,
89
+ ).toBe("reasoning_content");
90
+ });
91
+
92
+ test("litellm round-trips thinking as reasoning_content", () => {
93
+ const adapter = buildProviderAdapter("litellm", {
94
+ apiKey: "test-key",
95
+ model: "deepseek-reasoner",
96
+ streamTimeoutMs: 60_000,
97
+ baseURL: "https://example.com/v1",
98
+ useNativeWebSearch: false,
99
+ });
100
+ expect(adapter).toBeInstanceOf(OpenAIChatCompletionsProvider);
101
+ expect(
102
+ (adapter as unknown as { assistantReasoningField?: string })
103
+ .assistantReasoningField,
104
+ ).toBe("reasoning_content");
105
+ });
106
+
77
107
  test("backfills placeholder content after an aborted empty assistant turn", async () => {
78
108
  const adapter = buildProviderAdapter("openai-compatible", {
79
109
  apiKey: "test-key",
@@ -131,6 +131,9 @@ const ADAPTER_FACTORIES: Record<string, AdapterFactory> = {
131
131
  providerName: "litellm",
132
132
  providerLabel: "LiteLLM",
133
133
  streamTimeoutMs,
134
+ // Replay thinking as `reasoning_content` so DeepSeek-compatible
135
+ // thinking-mode upstreams accept follow-up requests that include tools.
136
+ assistantReasoningField: "reasoning_content",
134
137
  // Generic OpenAI-compat proxies may front a strict backend (vLLM,
135
138
  // DeepSeek, Portkey). Backfill empty assistant turns so the request
136
139
  // satisfies `content or tool_calls must be set`.
@@ -144,6 +147,9 @@ const ADAPTER_FACTORIES: Record<string, AdapterFactory> = {
144
147
  providerName: "openai-compatible",
145
148
  providerLabel: "OpenAI-compatible",
146
149
  streamTimeoutMs,
150
+ // Replay thinking as `reasoning_content` so DeepSeek-compatible
151
+ // thinking-mode endpoints accept follow-up requests that include tools.
152
+ assistantReasoningField: "reasoning_content",
147
153
  // Custom endpoints (Portkey, vLLM, LM Studio, DeepSeek-compat) often
148
154
  // reject `{ role: "assistant", content: null }` after a Stop mid-stream
149
155
  // or a reasoning-only turn. Same guard as OpenRouter and Vercel AI