@vellumai/assistant 0.4.45 → 0.4.48

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 (236) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/docs/architecture/memory.md +1 -1
  3. package/docs/architecture/scheduling.md +2 -3
  4. package/docs/architecture/security.md +5 -5
  5. package/docs/trusted-contact-access.md +5 -6
  6. package/package.json +4 -1
  7. package/src/__tests__/avatar-e2e.test.ts +18 -219
  8. package/src/__tests__/avatar-generator.test.ts +5 -57
  9. package/src/__tests__/browser-fill-credential.test.ts +5 -2
  10. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +2 -1
  11. package/src/__tests__/channel-readiness-routes.test.ts +20 -19
  12. package/src/__tests__/cli.test.ts +23 -0
  13. package/src/__tests__/credential-broker-browser-fill.test.ts +23 -22
  14. package/src/__tests__/credential-broker-server-use.test.ts +22 -21
  15. package/src/__tests__/credential-broker.test.ts +2 -1
  16. package/src/__tests__/credential-metadata-store.test.ts +240 -18
  17. package/src/__tests__/credential-resolve.test.ts +5 -4
  18. package/src/__tests__/credential-security-e2e.test.ts +8 -8
  19. package/src/__tests__/credential-security-invariants.test.ts +104 -7
  20. package/src/__tests__/credential-vault-unit.test.ts +22 -20
  21. package/src/__tests__/credential-vault.test.ts +284 -12
  22. package/src/__tests__/credentials-cli.test.ts +11 -6
  23. package/src/__tests__/gateway-only-enforcement.test.ts +4 -2
  24. package/src/__tests__/gemini-image-service.test.ts +75 -45
  25. package/src/__tests__/gemini-provider.test.ts +9 -6
  26. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -33
  27. package/src/__tests__/guardian-action-copy-generator.test.ts +0 -20
  28. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -28
  29. package/src/__tests__/guardian-action-followup-store.test.ts +1 -1
  30. package/src/__tests__/guardian-grant-minting.test.ts +35 -0
  31. package/src/__tests__/integration-status.test.ts +53 -21
  32. package/src/__tests__/managed-proxy-context.test.ts +5 -3
  33. package/src/__tests__/media-generate-image.test.ts +63 -2
  34. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -3
  35. package/src/__tests__/messaging-send-tool.test.ts +4 -6
  36. package/src/__tests__/provider-fail-open-selection.test.ts +3 -1
  37. package/src/__tests__/provider-managed-proxy-integration.test.ts +70 -6
  38. package/src/__tests__/schedule-store.test.ts +1 -1
  39. package/src/__tests__/schema-transforms.test.ts +226 -0
  40. package/src/__tests__/script-proxy-injection-runtime.test.ts +23 -13
  41. package/src/__tests__/script-proxy-policy-runtime.test.ts +1 -1
  42. package/src/__tests__/script-proxy-session-manager.test.ts +1 -1
  43. package/src/__tests__/secret-onetime-send.test.ts +5 -3
  44. package/src/__tests__/session-messaging-secret-redirect.test.ts +5 -4
  45. package/src/__tests__/skills-uninstall.test.ts +2 -2
  46. package/src/__tests__/skills.test.ts +0 -9
  47. package/src/__tests__/slack-channel-config.test.ts +9 -8
  48. package/src/__tests__/slack-share-routes.test.ts +11 -6
  49. package/src/__tests__/telegram-bot-username-resolution.test.ts +3 -0
  50. package/src/__tests__/twilio-config.test.ts +2 -1
  51. package/src/__tests__/twilio-provider.test.ts +4 -2
  52. package/src/__tests__/twilio-routes.test.ts +5 -4
  53. package/src/__tests__/verification-control-plane-policy.test.ts +1 -1
  54. package/src/approvals/AGENTS.md +1 -1
  55. package/src/calls/call-domain.ts +7 -4
  56. package/src/calls/twilio-config.ts +2 -1
  57. package/src/calls/twilio-provider.ts +2 -1
  58. package/src/calls/twilio-rest.ts +2 -2
  59. package/src/cli/commands/browser-relay.ts +40 -15
  60. package/src/cli/commands/credentials.ts +9 -8
  61. package/src/cli/commands/oauth.ts +1 -1
  62. package/src/cli.ts +3 -2
  63. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -4
  64. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +29 -32
  65. package/src/config/bundled-skills/gmail/SKILL.md +4 -4
  66. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +54 -61
  67. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +25 -28
  68. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +14 -17
  69. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +39 -44
  70. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +61 -58
  71. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +50 -49
  72. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +11 -13
  73. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +148 -146
  74. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +4 -7
  75. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +175 -173
  76. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +4 -7
  77. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +71 -76
  78. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +32 -38
  79. package/src/config/bundled-skills/google-calendar/SKILL.md +2 -2
  80. package/src/config/bundled-skills/google-calendar/calendar-client.ts +70 -29
  81. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +9 -10
  82. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +5 -6
  83. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +4 -5
  84. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +14 -15
  85. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +37 -37
  86. package/src/config/bundled-skills/google-calendar/tools/shared.ts +4 -9
  87. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +24 -3
  88. package/src/config/bundled-skills/messaging/SKILL.md +6 -6
  89. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +62 -63
  90. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +15 -16
  91. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +4 -5
  92. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +6 -7
  93. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +4 -5
  94. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +14 -15
  95. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +4 -5
  96. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +128 -128
  97. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +33 -34
  98. package/src/config/bundled-skills/messaging/tools/shared.ts +11 -11
  99. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +5 -5
  101. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  102. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  103. package/src/config/bundled-skills/slack/tools/shared.ts +4 -10
  104. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +4 -5
  105. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +15 -16
  106. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +4 -5
  107. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +4 -5
  108. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +4 -5
  109. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +95 -92
  110. package/src/config/loader.ts +6 -0
  111. package/src/daemon/computer-use-session.ts +7 -1
  112. package/src/daemon/guardian-action-generators.ts +4 -5
  113. package/src/daemon/handlers/config-slack-channel.ts +37 -20
  114. package/src/daemon/handlers/config-telegram.ts +33 -20
  115. package/src/daemon/lifecycle.ts +9 -1
  116. package/src/daemon/message-types/integrations.ts +1 -0
  117. package/src/daemon/ride-shotgun-handler.ts +3 -1
  118. package/src/daemon/session-messaging.ts +3 -1
  119. package/src/daemon/session-tool-setup.ts +18 -2
  120. package/src/daemon/session.ts +1 -1
  121. package/src/email/providers/index.ts +2 -1
  122. package/src/instrument.ts +15 -1
  123. package/src/media/app-icon-generator.ts +30 -4
  124. package/src/media/avatar-router.ts +28 -62
  125. package/src/media/gemini-image-service.ts +28 -2
  126. package/src/memory/canonical-guardian-store.ts +1 -1
  127. package/src/memory/guardian-action-store.ts +1 -1
  128. package/src/memory/schema/guardian.ts +1 -1
  129. package/src/messaging/provider.ts +16 -10
  130. package/src/messaging/providers/gmail/adapter.ts +40 -23
  131. package/src/messaging/providers/gmail/client.ts +203 -122
  132. package/src/messaging/providers/gmail/people-client.ts +26 -18
  133. package/src/messaging/providers/slack/adapter.ts +29 -19
  134. package/src/messaging/providers/slack/client.ts +265 -78
  135. package/src/messaging/providers/telegram-bot/adapter.ts +5 -4
  136. package/src/messaging/providers/whatsapp/adapter.ts +6 -3
  137. package/src/messaging/registry.ts +2 -1
  138. package/src/oauth/byo-connection.test.ts +436 -0
  139. package/src/oauth/byo-connection.ts +112 -0
  140. package/src/oauth/connect-orchestrator.ts +27 -0
  141. package/src/oauth/connection-resolver.ts +34 -0
  142. package/src/oauth/connection.ts +38 -0
  143. package/src/oauth/platform-connection.test.ts +163 -0
  144. package/src/oauth/platform-connection.ts +110 -0
  145. package/src/oauth/provider-base-urls.ts +21 -0
  146. package/src/oauth/provider-profiles.ts +1 -1
  147. package/src/oauth/token-persistence.ts +20 -20
  148. package/src/permissions/checker.ts +6 -1
  149. package/src/prompts/system-prompt.ts +52 -15
  150. package/src/prompts/templates/BOOTSTRAP.md +1 -1
  151. package/src/providers/gemini/client.ts +15 -6
  152. package/src/providers/managed-proxy/constants.ts +2 -2
  153. package/src/providers/managed-proxy/context.ts +5 -1
  154. package/src/providers/ratelimit.ts +17 -0
  155. package/src/providers/registry.ts +2 -2
  156. package/src/runtime/AGENTS.md +18 -1
  157. package/src/runtime/auth/route-policy.ts +1 -0
  158. package/src/runtime/channel-invite-transports/telegram.ts +2 -1
  159. package/src/runtime/channel-readiness-service.ts +168 -195
  160. package/src/runtime/channel-readiness-types.ts +4 -0
  161. package/src/runtime/guardian-action-conversation-turn.ts +1 -3
  162. package/src/runtime/guardian-action-followup-executor.ts +1 -2
  163. package/src/runtime/guardian-action-message-composer.ts +3 -23
  164. package/src/runtime/http-server.ts +9 -4
  165. package/src/runtime/http-types.ts +0 -1
  166. package/src/runtime/middleware/rate-limiter.ts +74 -20
  167. package/src/runtime/middleware/twilio-validation.ts +1 -3
  168. package/src/runtime/routes/channel-readiness-routes.ts +2 -0
  169. package/src/runtime/routes/diagnostics-routes.ts +11 -9
  170. package/src/runtime/routes/guardian-approval-interception.ts +20 -5
  171. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +71 -25
  172. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +12 -5
  173. package/src/runtime/routes/integrations/slack/share.ts +3 -2
  174. package/src/runtime/routes/integrations/twilio.ts +6 -5
  175. package/src/runtime/routes/secret-routes.ts +3 -2
  176. package/src/runtime/routes/settings-routes.ts +75 -17
  177. package/src/runtime/telegram-streaming-delivery.test.ts +132 -0
  178. package/src/runtime/telegram-streaming-delivery.ts +11 -1
  179. package/src/schedule/integration-status.ts +5 -4
  180. package/src/security/credential-key.ts +170 -0
  181. package/src/security/token-manager.ts +36 -7
  182. package/src/tools/apps/definitions.ts +0 -5
  183. package/src/tools/assets/materialize.ts +0 -5
  184. package/src/tools/assets/search.ts +0 -5
  185. package/src/tools/browser/headless-browser.ts +1 -67
  186. package/src/tools/claude-code/claude-code.ts +0 -5
  187. package/src/tools/computer-use/request-computer-control.ts +0 -5
  188. package/src/tools/credentials/broker.ts +6 -4
  189. package/src/tools/credentials/metadata-store.ts +72 -20
  190. package/src/tools/credentials/resolve.ts +2 -1
  191. package/src/tools/credentials/vault.ts +77 -16
  192. package/src/tools/filesystem/edit.ts +1 -6
  193. package/src/tools/filesystem/read.ts +0 -5
  194. package/src/tools/filesystem/write.ts +1 -6
  195. package/src/tools/host-filesystem/edit.ts +1 -6
  196. package/src/tools/host-filesystem/read.ts +1 -6
  197. package/src/tools/host-filesystem/write.ts +1 -6
  198. package/src/tools/mcp/mcp-tool-factory.ts +18 -1
  199. package/src/tools/memory/definitions.ts +0 -5
  200. package/src/tools/network/web-fetch.ts +0 -5
  201. package/src/tools/network/web-search.ts +0 -5
  202. package/src/tools/schema-transforms.ts +99 -0
  203. package/src/tools/skills/load.ts +0 -5
  204. package/src/tools/swarm/delegate.ts +0 -5
  205. package/src/tools/system/avatar-generator.ts +3 -44
  206. package/src/tools/ui-surface/definitions.ts +0 -15
  207. package/src/tools/watch/screen-watch.ts +0 -5
  208. package/src/version.ts +10 -0
  209. package/src/watcher/providers/github.ts +51 -52
  210. package/src/watcher/providers/gmail.ts +88 -80
  211. package/src/watcher/providers/google-calendar.ts +93 -86
  212. package/src/watcher/providers/linear.ts +87 -93
  213. package/src/__tests__/avatar-router.test.ts +0 -149
  214. package/src/__tests__/managed-avatar-client.test.ts +0 -337
  215. package/src/config/bundled-skills/doordash/SKILL.md +0 -170
  216. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +0 -205
  217. package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +0 -74
  218. package/src/config/bundled-skills/doordash/doordash-cli.ts +0 -1081
  219. package/src/config/bundled-skills/doordash/doordash-entry.ts +0 -22
  220. package/src/config/bundled-skills/doordash/lib/cart-queries.ts +0 -787
  221. package/src/config/bundled-skills/doordash/lib/client.ts +0 -1069
  222. package/src/config/bundled-skills/doordash/lib/order-queries.ts +0 -85
  223. package/src/config/bundled-skills/doordash/lib/queries.ts +0 -28
  224. package/src/config/bundled-skills/doordash/lib/query-extractor.ts +0 -94
  225. package/src/config/bundled-skills/doordash/lib/search-queries.ts +0 -203
  226. package/src/config/bundled-skills/doordash/lib/session.ts +0 -96
  227. package/src/config/bundled-skills/doordash/lib/shared/errors.ts +0 -61
  228. package/src/config/bundled-skills/doordash/lib/shared/network-recorder.ts +0 -380
  229. package/src/config/bundled-skills/doordash/lib/shared/platform.ts +0 -55
  230. package/src/config/bundled-skills/doordash/lib/shared/recording-store.ts +0 -43
  231. package/src/config/bundled-skills/doordash/lib/shared/recording-types.ts +0 -49
  232. package/src/config/bundled-skills/doordash/lib/shared/truncate.ts +0 -6
  233. package/src/config/bundled-skills/doordash/lib/store-queries.ts +0 -246
  234. package/src/config/bundled-skills/doordash/lib/types.ts +0 -367
  235. package/src/media/avatar-types.ts +0 -53
  236. package/src/media/managed-avatar-client.ts +0 -225
@@ -23,19 +23,30 @@ import {
23
23
  generateAllowlistOptions,
24
24
  generateScopeOptions,
25
25
  } from "../../permissions/checker.js";
26
+ import { credentialKey } from "../../security/credential-key.js";
26
27
  import { getSecureKey } from "../../security/secure-keys.js";
27
28
  import { parseToolManifestFile } from "../../skills/tool-manifest.js";
28
- import { assertMetadataWritable } from "../../tools/credentials/metadata-store.js";
29
+ import {
30
+ assertMetadataWritable,
31
+ getCredentialMetadata,
32
+ } from "../../tools/credentials/metadata-store.js";
29
33
  import {
30
34
  type ManifestOverride,
31
35
  resolveExecutionTarget,
32
36
  } from "../../tools/execution-target.js";
33
37
  import { getAllTools, getTool } from "../../tools/registry.js";
38
+ import {
39
+ injectReasonField,
40
+ REASON_SKIP_SET,
41
+ } from "../../tools/schema-transforms.js";
34
42
  import { isSideEffectTool } from "../../tools/side-effects.js";
35
43
  import { setAvatarTool } from "../../tools/system/avatar-generator.js";
36
44
  import { pathExists } from "../../util/fs.js";
37
45
  import { getLogger } from "../../util/logger.js";
38
46
  import { getWorkspaceDir } from "../../util/platform.js";
47
+ import { buildAssistantEvent } from "../assistant-event.js";
48
+ import { assistantEventHub } from "../assistant-event-hub.js";
49
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
39
50
  import { httpError } from "../http-errors.js";
40
51
  import type { RouteDefinition } from "../http-router.js";
41
52
  import { resolveWorkspacePath } from "./workspace-utils.js";
@@ -134,9 +145,9 @@ function getClientSecret(
134
145
  rawService: string,
135
146
  ): string | undefined {
136
147
  return (
137
- getSecureKey(`credential:${resolvedService}:client_secret`) ??
148
+ getSecureKey(credentialKey(resolvedService, "client_secret")) ??
138
149
  (resolvedService !== rawService
139
- ? getSecureKey(`credential:${rawService}:client_secret`)
150
+ ? getSecureKey(credentialKey(rawService, "client_secret"))
140
151
  : undefined) ??
141
152
  undefined
142
153
  );
@@ -162,9 +173,16 @@ async function handleOAuthConnectStart(body: {
162
173
 
163
174
  const resolvedService = resolveService(body.service);
164
175
 
165
- let clientId = getSecureKey(`credential:${resolvedService}:client_id`);
176
+ // client_id is stored in metadata (oauth2ClientId), not the secure store.
177
+ let clientId = getCredentialMetadata(
178
+ resolvedService,
179
+ "access_token",
180
+ )?.oauth2ClientId;
166
181
  if (!clientId && resolvedService !== body.service) {
167
- clientId = getSecureKey(`credential:${body.service}:client_id`);
182
+ clientId = getCredentialMetadata(
183
+ body.service,
184
+ "access_token",
185
+ )?.oauth2ClientId;
168
186
  }
169
187
 
170
188
  if (!clientId) {
@@ -203,6 +221,36 @@ async function handleOAuthConnectStart(body: {
203
221
  openUrl: (url: string) => {
204
222
  authUrl = url;
205
223
  },
224
+ onDeferredComplete: (deferredResult) => {
225
+ // Emit oauth_connect_result to all connected SSE clients so the
226
+ // UI can update immediately when the deferred browser flow completes.
227
+ assistantEventHub
228
+ .publish(
229
+ buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, {
230
+ type: "oauth_connect_result",
231
+ success: deferredResult.success,
232
+ service: deferredResult.service,
233
+ accountInfo: deferredResult.accountInfo,
234
+ error: deferredResult.error,
235
+ }),
236
+ )
237
+ .catch((err) => {
238
+ log.warn(
239
+ { err, service: deferredResult.service },
240
+ "Failed to publish oauth_connect_result event",
241
+ );
242
+ });
243
+
244
+ if (!deferredResult.success) {
245
+ log.warn(
246
+ {
247
+ service: deferredResult.service,
248
+ err: deferredResult.error,
249
+ },
250
+ "Deferred OAuth connect failed",
251
+ );
252
+ }
253
+ },
206
254
  });
207
255
 
208
256
  if (!result.success) {
@@ -309,23 +357,34 @@ function resolveManifestOverride(
309
357
  function handleToolNamesList(): Response {
310
358
  const tools = getAllTools();
311
359
  const nameSet = new Set(tools.map((t) => t.name));
312
- const schemas: Record<
313
- string,
314
- {
315
- type: string;
316
- properties?: Record<string, unknown>;
317
- required?: string[];
318
- }
319
- > = {};
360
+ type SchemaShape = {
361
+ type: string;
362
+ properties?: Record<string, unknown>;
363
+ required?: string[];
364
+ };
365
+ const schemas: Record<string, SchemaShape> = {};
366
+
367
+ // Collect raw definitions from the registry so we can transform them.
368
+ const rawDefs: import("../../providers/types.js").ToolDefinition[] = [];
320
369
  for (const tool of tools) {
321
370
  try {
322
- const def = tool.getDefinition();
323
- schemas[tool.name] = def.input_schema as (typeof schemas)[string];
371
+ rawDefs.push(tool.getDefinition());
324
372
  } catch {
325
373
  // Skip tools whose definitions can't be resolved
326
374
  }
327
375
  }
328
376
 
377
+ // Apply reason injection so settings/debug schemas match runtime behavior.
378
+ const transformedDefs = injectReasonField(rawDefs, REASON_SKIP_SET);
379
+ for (const def of transformedDefs) {
380
+ schemas[def.name] = def.input_schema as SchemaShape;
381
+ }
382
+
383
+ // Skill manifest schemas are served raw (untransformed). Unlike runtime tool
384
+ // schemas which have `reason` injected via injectReasonField(), skill manifests
385
+ // reflect the original TOOLS.json content. This is intentional: skill tools are
386
+ // invoked through skill_execute (which has its own reason field), so their
387
+ // individual schemas are never sent to the LLM directly.
329
388
  try {
330
389
  const catalog = loadSkillCatalog();
331
390
  for (const skill of catalog) {
@@ -337,8 +396,7 @@ function handleToolNamesList(): Response {
337
396
  for (const entry of manifest.tools) {
338
397
  if (nameSet.has(entry.name)) continue;
339
398
  nameSet.add(entry.name);
340
- schemas[entry.name] =
341
- entry.input_schema as unknown as (typeof schemas)[string];
399
+ schemas[entry.name] = entry.input_schema as unknown as SchemaShape;
342
400
  }
343
401
  } catch {
344
402
  // Skip skills whose manifests can't be parsed
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { ApprovalUIMetadata } from "./channel-approval-types.js";
3
4
  import type { ChannelDeliveryResult } from "./gateway-client.js";
4
5
 
5
6
  // ---------------------------------------------------------------------------
@@ -494,6 +495,137 @@ describe("TelegramStreamingDelivery", () => {
494
495
  expect(delivery.finishSucceeded).toBe(true);
495
496
  });
496
497
 
498
+ // ── Test 5j: no-messageId + tool_use_start + finish delivers post-tool text ─
499
+ test("no-messageId + tool_use_start + finish delivers post-tool text", async () => {
500
+ // Scenario from Devin review: initial send succeeds without messageId,
501
+ // more deltas arrive, tool_use_start fires, finish() must deliver post-tool text.
502
+ mockDeliverChannelReply.mockReset();
503
+ let localCallCount = 0;
504
+ mockDeliverChannelReply.mockImplementation(
505
+ async (): Promise<ChannelDeliveryResult> => {
506
+ localCallCount++;
507
+ if (localCallCount === 1) return { ok: true }; // no messageId
508
+ return { ok: true, messageId: 400 };
509
+ },
510
+ );
511
+
512
+ const delivery = createDelivery();
513
+
514
+ // Step 1: initial send (>= 20 chars), succeeds without messageId
515
+ delivery.onEvent({
516
+ type: "assistant_text_delta",
517
+ text: "a".repeat(25),
518
+ });
519
+ await flushPromises();
520
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(1);
521
+
522
+ // Step 2: more deltas arrive — stuck in buffer (onTextDelta skips both branches)
523
+ delivery.onEvent({
524
+ type: "assistant_text_delta",
525
+ text: "post-tool text",
526
+ });
527
+ await flushPromises();
528
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(1); // no new call
529
+
530
+ // Step 3: tool_use_start — buffer should NOT be moved to currentMessageText
531
+ delivery.onEvent({
532
+ type: "tool_use_start",
533
+ toolName: "some_tool",
534
+ input: {},
535
+ });
536
+
537
+ // Step 4: finish() — should deliver the post-tool text as a new message
538
+ await delivery.finish();
539
+
540
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(2);
541
+ const secondPayload = callPayload(1);
542
+ expect(secondPayload.text).toBe("post-tool text");
543
+ expect(secondPayload.messageId).toBeUndefined(); // new message, not edit
544
+ expect(delivery.finishSucceeded).toBe(true);
545
+ });
546
+
547
+ // ── Test 5k: no-messageId + finish with approval sends approval as new message ─
548
+ test("no-messageId + finish with approval sends approval as new message", async () => {
549
+ // Scenario from Codex review: initial send succeeds without messageId,
550
+ // no additional buffer, but finish(approval) must still deliver approval buttons.
551
+ mockDeliverChannelReply.mockReset();
552
+ mockDeliverChannelReply.mockImplementation(
553
+ async (): Promise<ChannelDeliveryResult> => {
554
+ return { ok: true }; // no messageId
555
+ },
556
+ );
557
+
558
+ const delivery = createDelivery();
559
+
560
+ // Initial send succeeds without messageId
561
+ delivery.onEvent({
562
+ type: "assistant_text_delta",
563
+ text: "a".repeat(25),
564
+ });
565
+ await flushPromises();
566
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(1);
567
+
568
+ // finish() with approval — approval must not be silently dropped
569
+ const approval: ApprovalUIMetadata = {
570
+ requestId: "test-req",
571
+ actions: [{ id: "approve_once", label: "Approve" }],
572
+ plainTextFallback: "Reply APPROVE or REJECT",
573
+ };
574
+ await delivery.finish(approval);
575
+
576
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(2);
577
+ const secondPayload = callPayload(1);
578
+ // Approval buttons sent as a new message
579
+ expect(secondPayload.approval).toEqual(approval);
580
+ expect(secondPayload.messageId).toBeUndefined();
581
+ expect(delivery.finishSucceeded).toBe(true);
582
+ });
583
+
584
+ // ── Test 5l: no-messageId + buffer + finish with approval delivers both ─
585
+ test("no-messageId + buffer + finish with approval delivers both text and approval", async () => {
586
+ // Combined scenario: no-messageId initial send, buffered text, and approval buttons.
587
+ mockDeliverChannelReply.mockReset();
588
+ let localCallCount = 0;
589
+ mockDeliverChannelReply.mockImplementation(
590
+ async (): Promise<ChannelDeliveryResult> => {
591
+ localCallCount++;
592
+ if (localCallCount === 1) return { ok: true }; // no messageId
593
+ return { ok: true, messageId: 500 };
594
+ },
595
+ );
596
+
597
+ const delivery = createDelivery();
598
+
599
+ // Initial send succeeds without messageId
600
+ delivery.onEvent({
601
+ type: "assistant_text_delta",
602
+ text: "a".repeat(25),
603
+ });
604
+ await flushPromises();
605
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(1);
606
+
607
+ // More deltas arrive
608
+ delivery.onEvent({
609
+ type: "assistant_text_delta",
610
+ text: "remainder",
611
+ });
612
+
613
+ // finish() with approval — should deliver buffer text + approval together
614
+ const approval: ApprovalUIMetadata = {
615
+ requestId: "test-req",
616
+ actions: [{ id: "approve_once", label: "Approve" }],
617
+ plainTextFallback: "Reply APPROVE or REJECT",
618
+ };
619
+ await delivery.finish(approval);
620
+
621
+ expect(mockDeliverChannelReply).toHaveBeenCalledTimes(2);
622
+ const secondPayload = callPayload(1);
623
+ expect(secondPayload.text).toBe("remainder");
624
+ expect(secondPayload.approval).toEqual(approval);
625
+ expect(secondPayload.messageId).toBeUndefined();
626
+ expect(delivery.finishSucceeded).toBe(true);
627
+ });
628
+
497
629
  // ── Test 6: skips final edit when text hasn't changed ───────────────
498
630
  test("skips final edit when text hasn't changed", async () => {
499
631
  const delivery = createDelivery();
@@ -51,11 +51,13 @@ export class TelegramStreamingDelivery {
51
51
  // Flush buffer and send an edit so the message is up-to-date before the tool runs
52
52
  if (this.buffer.length > 0 && this.currentMessageId) {
53
53
  this.flushEdit();
54
- } else if (this.buffer.length > 0) {
54
+ } else if (this.buffer.length > 0 && !this.textDelivered) {
55
55
  // No message sent yet — just move buffer to currentMessageText
56
56
  this.currentMessageText += this.buffer;
57
57
  this.buffer = "";
58
58
  }
59
+ // When textDelivered is true but currentMessageId is null (no-messageId
60
+ // response), leave buffer as-is so finish() sends it as a new message.
59
61
  break;
60
62
  case "message_complete":
61
63
  // Don't finalize here — let finish() handle it
@@ -121,6 +123,14 @@ export class TelegramStreamingDelivery {
121
123
  return;
122
124
  }
123
125
 
126
+ // Text was delivered but no messageId was returned, and there are approval
127
+ // buttons to attach. Send them as a new message so they aren't silently dropped.
128
+ if (!this.currentMessageId && this.textDelivered && approval) {
129
+ await this.sendNewMessage("", approval);
130
+ this.finishOk = true;
131
+ return;
132
+ }
133
+
124
134
  // Final edit with approval buttons if present.
125
135
  // Skip the edit when text hasn't changed since the last successful
126
136
  // delivery and there are no approval buttons to attach — sending the
@@ -1,4 +1,5 @@
1
1
  import { hasTwilioCredentials } from "../calls/twilio-rest.js";
2
+ import { credentialKey } from "../security/credential-key.js";
2
3
  import { getSecureKey } from "../security/secure-keys.js";
3
4
 
4
5
  interface IntegrationProbe {
@@ -13,13 +14,13 @@ const INTEGRATION_PROBES: IntegrationProbe[] = [
13
14
  name: "Gmail",
14
15
  category: "email",
15
16
  isConnected: () =>
16
- !!getSecureKey("credential:integration:gmail:access_token"),
17
+ !!getSecureKey(credentialKey("integration:gmail", "access_token")),
17
18
  },
18
19
  {
19
20
  name: "Slack",
20
21
  category: "messaging",
21
22
  isConnected: () =>
22
- !!getSecureKey("credential:integration:slack:access_token"),
23
+ !!getSecureKey(credentialKey("integration:slack", "access_token")),
23
24
  },
24
25
  {
25
26
  name: "Twilio",
@@ -30,8 +31,8 @@ const INTEGRATION_PROBES: IntegrationProbe[] = [
30
31
  name: "Telegram",
31
32
  category: "messaging",
32
33
  isConnected: () =>
33
- !!getSecureKey("credential:telegram:bot_token") &&
34
- !!getSecureKey("credential:telegram:webhook_secret"),
34
+ !!getSecureKey(credentialKey("telegram", "bot_token")) &&
35
+ !!getSecureKey(credentialKey("telegram", "webhook_secret")),
35
36
  },
36
37
  ];
37
38
 
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Single source of truth for credential key format in the secure store.
3
+ *
4
+ * Keys follow the pattern: credential/{service}/{field}
5
+ *
6
+ * Previously, keys used colons as delimiters (credential:service:field),
7
+ * which was ambiguous when service names contained colons (e.g.
8
+ * "integration:gmail"). The slash-delimited format avoids this.
9
+ */
10
+
11
+ import { listCredentialMetadata } from "../tools/credentials/metadata-store.js";
12
+ import { getLogger } from "../util/logger.js";
13
+ import {
14
+ deleteSecureKey,
15
+ getSecureKey,
16
+ listSecureKeys,
17
+ setSecureKey,
18
+ } from "./secure-keys.js";
19
+
20
+ const log = getLogger("credential-key");
21
+
22
+ /**
23
+ * Build a credential key for the secure store.
24
+ *
25
+ * @returns A key of the form `credential/{service}/{field}`
26
+ */
27
+ export function credentialKey(service: string, field: string): string {
28
+ return `credential/${service}/${field}`;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Migration from colon-delimited keys
33
+ // ---------------------------------------------------------------------------
34
+
35
+ let migrated = false;
36
+
37
+ /**
38
+ * Migrate any legacy colon-delimited credential keys to the new
39
+ * slash-delimited format. Idempotent: skips keys that already exist
40
+ * under the new format, and only runs once per process (guarded by a
41
+ * module-level flag).
42
+ *
43
+ * Legacy key format: `credential:<service>:<field>`
44
+ * New key format: `credential/<service>/<field>`
45
+ *
46
+ * The old colon-delimited format is ambiguous when either the service
47
+ * or field name contains colons — for `credential:A:B:C:D`, you can't
48
+ * tell where the service ends and the field begins without external
49
+ * context.
50
+ *
51
+ * To resolve this, the function first consults the credential metadata
52
+ * store to find which (service, field) pair matches a valid split.
53
+ * If no metadata match is found, it falls back to splitting on the
54
+ * **first** colon after the prefix — this handles the common case
55
+ * where service names are simple (e.g. "doordash.com") and field
56
+ * names may contain colons (e.g. "session:cookies").
57
+ */
58
+ export function migrateKeys(): void {
59
+ if (migrated) return;
60
+ migrated = true;
61
+
62
+ let allKeys: string[];
63
+ try {
64
+ allKeys = listSecureKeys();
65
+ } catch (err) {
66
+ log.warn({ err }, "Failed to list secure keys during migration");
67
+ return;
68
+ }
69
+
70
+ const colonKeys = allKeys.filter(
71
+ (k) => k.startsWith("credential:") && !k.startsWith("credential/"),
72
+ );
73
+ if (colonKeys.length === 0) return;
74
+
75
+ log.info(
76
+ { count: colonKeys.length },
77
+ "Migrating colon-delimited credential keys to slash-delimited format",
78
+ );
79
+
80
+ // Build a set of known (service, field) pairs from credential metadata
81
+ // to disambiguate colon-delimited keys.
82
+ const knownPairs = new Set<string>();
83
+ try {
84
+ for (const meta of listCredentialMetadata()) {
85
+ knownPairs.add(`${meta.service}\0${meta.field}`);
86
+ }
87
+ } catch {
88
+ // If metadata is unavailable, we'll rely on the first-colon fallback.
89
+ }
90
+
91
+ for (const oldKey of colonKeys) {
92
+ // Strip the "credential:" prefix — `rest` is "service:field" with
93
+ // potential colons in either part.
94
+ const rest = oldKey.slice("credential:".length);
95
+
96
+ const parsed = parseServiceField(rest, knownPairs);
97
+ if (parsed === undefined) {
98
+ log.warn({ key: oldKey }, "Skipping malformed credential key");
99
+ continue;
100
+ }
101
+
102
+ const { service, field } = parsed;
103
+ const newKey = credentialKey(service, field);
104
+
105
+ // Skip if the new key already exists (idempotent)
106
+ if (getSecureKey(newKey) !== undefined) {
107
+ // Clean up old key
108
+ deleteSecureKey(oldKey);
109
+ continue;
110
+ }
111
+
112
+ const value = getSecureKey(oldKey);
113
+ if (value === undefined) {
114
+ continue;
115
+ }
116
+
117
+ const ok = setSecureKey(newKey, value);
118
+ if (ok) {
119
+ deleteSecureKey(oldKey);
120
+ } else {
121
+ log.warn(
122
+ { oldKey, newKey },
123
+ "Failed to write migrated key; keeping old key",
124
+ );
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Parse a "service:field" string, using known metadata pairs to
131
+ * disambiguate when colons appear in either part.
132
+ *
133
+ * Strategy:
134
+ * 1. Try every possible split position and check against metadata.
135
+ * 2. If no metadata match, fall back to splitting on the first colon
136
+ * (field names with colons are more common than service names with colons).
137
+ *
138
+ * Returns undefined for malformed keys that have no colon.
139
+ */
140
+ function parseServiceField(
141
+ rest: string,
142
+ knownPairs: Set<string>,
143
+ ): { service: string; field: string } | undefined {
144
+ const firstColon = rest.indexOf(":");
145
+ if (firstColon <= 0) return undefined;
146
+
147
+ // Try each possible split position against metadata
148
+ if (knownPairs.size > 0) {
149
+ for (let i = firstColon; i < rest.length; i++) {
150
+ if (rest[i] !== ":") continue;
151
+ const service = rest.slice(0, i);
152
+ const field = rest.slice(i + 1);
153
+ if (field.length > 0 && knownPairs.has(`${service}\0${field}`)) {
154
+ return { service, field };
155
+ }
156
+ }
157
+ }
158
+
159
+ // Fallback: split on first colon — handles simple services with
160
+ // compound field names (e.g. "doordash.com:session:cookies").
161
+ return {
162
+ service: rest.slice(0, firstColon),
163
+ field: rest.slice(firstColon + 1),
164
+ };
165
+ }
166
+
167
+ /** @internal Test-only: reset the migration guard so migrateKeys() runs again. */
168
+ export function _resetMigrationFlag(): void {
169
+ migrated = false;
170
+ }
@@ -11,6 +11,7 @@ import {
11
11
  upsertCredentialMetadata,
12
12
  } from "../tools/credentials/metadata-store.js";
13
13
  import { getLogger } from "../util/logger.js";
14
+ import { credentialKey, migrateKeys } from "./credential-key.js";
14
15
  import { refreshOAuth2Token, type TokenEndpointAuthMethod } from "./oauth2.js";
15
16
  import { getSecureKey, setSecureKeyAsync } from "./secure-keys.js";
16
17
 
@@ -106,11 +107,34 @@ function recordRefreshFailure(service: string): void {
106
107
  }
107
108
  }
108
109
 
110
+ // ── Per-service refresh deduplication ─────────────────────────────────
111
+ // When multiple concurrent `withValidToken` calls detect an expired or
112
+ // 401-rejected token for the same service, only one actual refresh
113
+ // attempt is made. Other callers join the in-flight promise.
114
+
115
+ const inflightRefreshes = new Map<string, Promise<string>>();
116
+
117
+ function deduplicatedRefresh(service: string): Promise<string> {
118
+ const existing = inflightRefreshes.get(service);
119
+ if (existing) return existing;
120
+
121
+ const promise = doRefresh(service).finally(() => {
122
+ inflightRefreshes.delete(service);
123
+ });
124
+ inflightRefreshes.set(service, promise);
125
+ return promise;
126
+ }
127
+
109
128
  /** @internal Test-only: reset all circuit breaker state */
110
129
  export function _resetRefreshBreakers(): void {
111
130
  refreshBreakers.clear();
112
131
  }
113
132
 
133
+ /** @internal Test-only: reset in-flight refresh deduplication state */
134
+ export function _resetInflightRefreshes(): void {
135
+ inflightRefreshes.clear();
136
+ }
137
+
114
138
  /** @internal Test-only: get breaker state for a service */
115
139
  export function _getRefreshBreakerState(
116
140
  service: string,
@@ -148,7 +172,7 @@ function isTokenExpired(service: string): boolean {
148
172
  * Throws `TokenExpiredError` if refresh is not possible.
149
173
  */
150
174
  async function doRefresh(service: string): Promise<string> {
151
- const refreshToken = getSecureKey(`credential:${service}:refresh_token`);
175
+ const refreshToken = getSecureKey(credentialKey(service, "refresh_token"));
152
176
  if (!refreshToken) {
153
177
  throw new TokenExpiredError(
154
178
  service,
@@ -175,7 +199,7 @@ async function doRefresh(service: string): Promise<string> {
175
199
  );
176
200
  }
177
201
 
178
- const clientSecret = meta?.oauth2ClientSecret as string | undefined;
202
+ const clientSecret = getSecureKey(credentialKey(service, "client_secret"));
179
203
  const authMethod = meta?.oauth2TokenEndpointAuthMethod as
180
204
  | TokenEndpointAuthMethod
181
205
  | undefined;
@@ -219,7 +243,7 @@ async function doRefresh(service: string): Promise<string> {
219
243
 
220
244
  if (
221
245
  !(await setSecureKeyAsync(
222
- `credential:${service}:access_token`,
246
+ credentialKey(service, "access_token"),
223
247
  result.accessToken,
224
248
  ))
225
249
  ) {
@@ -232,7 +256,7 @@ async function doRefresh(service: string): Promise<string> {
232
256
  if (result.refreshToken) {
233
257
  if (
234
258
  !(await setSecureKeyAsync(
235
- `credential:${service}:refresh_token`,
259
+ credentialKey(service, "refresh_token"),
236
260
  result.refreshToken,
237
261
  ))
238
262
  ) {
@@ -265,12 +289,17 @@ async function doRefresh(service: string): Promise<string> {
265
289
  * 1. Retrieves the stored access token (throws if none exists).
266
290
  * 2. If the token is expired or near-expiry, refreshes it before calling the callback.
267
291
  * 3. If the callback throws with a 401 status, attempts one refresh-and-retry cycle.
292
+ *
293
+ * @deprecated Use `resolveOAuthConnection(service).request()` instead.
294
+ * Retained only for BYO connection internals.
268
295
  */
269
296
  export async function withValidToken<T>(
270
297
  service: string,
271
298
  callback: (token: string) => Promise<T>,
272
299
  ): Promise<T> {
273
- let token = getSecureKey(`credential:${service}:access_token`);
300
+ migrateKeys();
301
+
302
+ let token = getSecureKey(credentialKey(service, "access_token"));
274
303
  if (!token) {
275
304
  throw new TokenExpiredError(
276
305
  service,
@@ -280,14 +309,14 @@ export async function withValidToken<T>(
280
309
 
281
310
  // Proactively refresh if expired or about to expire.
282
311
  if (isTokenExpired(service)) {
283
- token = await doRefresh(service);
312
+ token = await deduplicatedRefresh(service);
284
313
  }
285
314
 
286
315
  try {
287
316
  return await callback(token);
288
317
  } catch (err: unknown) {
289
318
  if (is401Error(err)) {
290
- token = await doRefresh(service);
319
+ token = await deduplicatedRefresh(service);
291
320
  return callback(token);
292
321
  }
293
322
  throw err;
@@ -51,11 +51,6 @@ const appOpenTool: Tool = {
51
51
  description:
52
52
  "Display mode. 'preview' shows an inline preview card in chat. 'workspace' opens the full app in a workspace panel. Defaults to 'workspace'.",
53
53
  },
54
- reason: {
55
- type: "string",
56
- description:
57
- "Brief non-technical explanation of what you are opening and why, shown to the user as a status update. Use simple language a non-technical person would understand.",
58
- },
59
54
  },
60
55
  required: ["app_id"],
61
56
  },
@@ -95,11 +95,6 @@ const definition: ToolDefinition = {
95
95
  description:
96
96
  "Path where the file should be written, relative to (or inside) the sandbox working directory.",
97
97
  },
98
- reason: {
99
- type: "string",
100
- description:
101
- "Brief non-technical explanation of what you are saving and why, shown to the user as a status update. Use simple language a non-technical person would understand.",
102
- },
103
98
  },
104
99
  required: ["attachment_id", "destination_path"],
105
100
  },
@@ -298,11 +298,6 @@ const definition: ToolDefinition = {
298
298
  type: "number",
299
299
  description: `Maximum results to return (default ${DEFAULT_LIMIT}, max ${MAX_RESULTS}).`,
300
300
  },
301
- reason: {
302
- type: "string",
303
- description:
304
- "Brief non-technical explanation of what you are searching for and why, shown to the user as a status update. Use simple language a non-technical person would understand.",
305
- },
306
301
  },
307
302
  required: [],
308
303
  },