@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231715.8da971c

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 (229) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/call-controller.test.ts +18 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +6 -1
  48. package/src/calls/__tests__/voice-leg-coordinator.test.ts +42 -0
  49. package/src/calls/__tests__/voice-session-bridge.test.ts +97 -4
  50. package/src/calls/__tests__/voice-triage-escalate.test.ts +69 -11
  51. package/src/calls/call-controller.ts +3 -1
  52. package/src/calls/voice-control-protocol.ts +9 -4
  53. package/src/calls/voice-escalation-judge.ts +7 -6
  54. package/src/calls/voice-leg-coordinator.ts +15 -4
  55. package/src/calls/voice-screen-annotation.ts +44 -0
  56. package/src/calls/voice-session-bridge.ts +50 -20
  57. package/src/calls/voice-triage-escalate.ts +61 -26
  58. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  59. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  60. package/src/cli/commands/oauth/index.help.ts +8 -2
  61. package/src/cli/commands/oauth/request.test.ts +32 -0
  62. package/src/cli/commands/platform/index.help.ts +2 -1
  63. package/src/cli/commands/platform/index.ts +8 -2
  64. package/src/cli/commands/plugins.ts +16 -0
  65. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  66. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  67. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  68. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  69. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  70. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  71. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  72. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  73. package/src/cli/lib/bundled-marketplace.json +4 -4
  74. package/src/cli/lib/inspect-plugin.ts +5 -0
  75. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  76. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  77. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  78. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  79. package/src/cli/lib/plugin-details.ts +10 -7
  80. package/src/cli/lib/search-plugins.ts +2 -0
  81. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  82. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  83. package/src/config/__tests__/platform-identity.test.ts +59 -0
  84. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  85. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  86. package/src/config/call-site-defaults.ts +17 -11
  87. package/src/config/default-profile-catalog.ts +118 -19
  88. package/src/config/default-profile-names.ts +69 -0
  89. package/src/config/feature-flag-registry.json +25 -9
  90. package/src/config/inference-profile-validation.ts +7 -6
  91. package/src/config/llm-resolver.ts +16 -0
  92. package/src/config/platform-identity.ts +22 -5
  93. package/src/config/profile-text-generation.test.ts +9 -0
  94. package/src/config/schemas/call-site-catalog.ts +8 -1
  95. package/src/config/schemas/llm.ts +70 -9
  96. package/src/config/sidebar-done-gate.ts +27 -0
  97. package/src/config/sync-gated-profiles.ts +96 -44
  98. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  99. package/src/daemon/auto-profile-router.ts +220 -0
  100. package/src/daemon/conversation-agent-loop.ts +77 -4
  101. package/src/daemon/conversation-slash.ts +2 -2
  102. package/src/daemon/conversation-turn-finalize.ts +12 -0
  103. package/src/daemon/dictation-text-processing.ts +4 -4
  104. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  105. package/src/daemon/handlers/config-channels.ts +3 -1
  106. package/src/daemon/handlers/conversation-history.ts +11 -1
  107. package/src/daemon/handlers/conversations.ts +118 -10
  108. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  109. package/src/daemon/pkb-reminder-builder.ts +1 -1
  110. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  111. package/src/home/feed-types.ts +2 -0
  112. package/src/inbound/platform-callback-registration.ts +19 -6
  113. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  114. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  115. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  116. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +37 -1
  117. package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -2
  118. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  119. package/src/live-voice/continuation-judge.ts +1 -1
  120. package/src/live-voice/live-voice-session.ts +17 -14
  121. package/src/live-voice/protocol.ts +16 -5
  122. package/src/messaging/providers/gmail/client.ts +4 -0
  123. package/src/notifications/README.md +11 -3
  124. package/src/notifications/__tests__/assistant-reply-producer.test.ts +13 -0
  125. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  126. package/src/notifications/conversation-pairing.ts +23 -8
  127. package/src/notifications/home-feed-mirror.ts +39 -0
  128. package/src/notifications/home-feed-side-effect.ts +46 -19
  129. package/src/oauth/AGENTS.md +9 -1
  130. package/src/oauth/byo-connection.test.ts +28 -0
  131. package/src/permissions/question-prompter.ts +14 -4
  132. package/src/persistence/conversation-crud.ts +116 -3
  133. package/src/persistence/conversation-queries.ts +48 -3
  134. package/src/persistence/conversation-types.ts +6 -2
  135. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  136. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  137. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  138. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  139. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  140. package/src/persistence/embeddings/embedding-local.ts +72 -17
  141. package/src/persistence/embeddings/embedding-types.ts +0 -19
  142. package/src/persistence/jobs-store.ts +229 -0
  143. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  144. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  145. package/src/persistence/steps.ts +12 -0
  146. package/src/platform/sync-avatar.test.ts +29 -0
  147. package/src/plugin-api/model-profiles.ts +5 -3
  148. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  149. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  150. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  151. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  152. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  153. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  154. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  155. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  156. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  157. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  158. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  159. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  160. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  161. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  162. package/src/providers/anthropic/client.ts +2 -2
  163. package/src/providers/gemini/client.ts +17 -1
  164. package/src/providers/inference/auth.ts +25 -0
  165. package/src/providers/jev/ask.test.ts +66 -0
  166. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  167. package/src/providers/malformed-tool-call.ts +55 -0
  168. package/src/providers/model-catalog.ts +257 -4
  169. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  170. package/src/providers/openai/chat-completions-provider.ts +30 -0
  171. package/src/providers/openai/codex-models.ts +2 -0
  172. package/src/providers/platform-proxy/constants.ts +5 -0
  173. package/src/providers/retry.ts +68 -36
  174. package/src/providers/search-provider-catalog.ts +10 -0
  175. package/src/providers/vellum-model-routing.test.ts +1 -0
  176. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  177. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  178. package/src/runtime/agent-wake.ts +9 -2
  179. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  180. package/src/runtime/auth/middleware.ts +15 -11
  181. package/src/runtime/middleware/auth.ts +7 -1
  182. package/src/runtime/pending-interactions.ts +8 -8
  183. package/src/runtime/question-resolution.ts +54 -8
  184. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  185. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  186. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  187. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  188. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  189. package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +61 -3
  190. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  191. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  192. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  193. package/src/runtime/routes/conversation-routes.ts +4 -2
  194. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  195. package/src/runtime/routes/global-search-routes.ts +22 -7
  196. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  197. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  198. package/src/runtime/routes/oauth-proxy-passthrough.test.ts +32 -0
  199. package/src/runtime/routes/platform-routes.ts +6 -1
  200. package/src/runtime/routes/plugins-routes.ts +5 -2
  201. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  202. package/src/runtime/sync/resource-sync-events.ts +11 -0
  203. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  204. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  205. package/src/runtime/verification-outbound-actions.ts +2 -2
  206. package/src/skills/validate-input.ts +75 -11
  207. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  208. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  209. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  210. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  211. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  212. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  213. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  214. package/src/tools/network/web-fetch.ts +30 -9
  215. package/src/tools/network/web-search.ts +235 -4
  216. package/src/tools/skills/execute.ts +1 -1
  217. package/src/tools/skills/format-input-schema.ts +15 -0
  218. package/src/tools/skills/load.ts +15 -43
  219. package/src/tools/skills/scaffold-managed.ts +86 -71
  220. package/src/tools/skills/skill-tool-factory.ts +2 -1
  221. package/src/tools/workflows/manage-workflows.ts +4 -2
  222. package/src/util/oauth-request-body.test.ts +5 -1
  223. package/src/util/worker-pipe.test.ts +146 -0
  224. package/src/util/worker-pipe.ts +49 -0
  225. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  226. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  227. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  228. package/src/workspace/migrations/registry.ts +4 -0
  229. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -9,15 +9,16 @@
9
9
  * correct reply need something the front door cannot do from here? A
10
10
  * confident yes overrules a front-door answer and hands the turn off.
11
11
  *
12
- * The judge runs only when the `voiceEscalationJudge` call site resolves to
13
- * the TypeSafe provider. Every other outcome (no such profile, provider
14
- * error, timeout, unparseable answer) is a "clear" verdict, so the front
15
- * door's own decision stands exactly as it would without the judge.
12
+ * The judge uses managed Jev by default when Vellum-managed inference is
13
+ * available. An explicit TypeSafe call-site override can use a user-owned
14
+ * connection. Every other outcome (unavailable route, provider error,
15
+ * timeout, unparseable answer) is a "clear" verdict, so the front door's own
16
+ * decision stands exactly as it would without the judge.
16
17
  */
17
18
 
19
+ import { askTypesafeNoul } from "../providers/jev/ask.js";
18
20
  import type { Message, Provider } from "../providers/types.js";
19
21
  import { safeStringSlice } from "../util/unicode.js";
20
- import { askTypesafeNoul } from "./typesafe-noul.js";
21
22
 
22
23
  export const VOICE_ESCALATION_JUDGE_CALL_SITE = "voiceEscalationJudge";
23
24
 
@@ -57,7 +58,7 @@ export interface EscalationJudgement {
57
58
  }
58
59
 
59
60
  const ESCALATION_QUESTION =
60
- "The voice assistant answering right now has NO tools and no access to the user's accounts, files, messages, calendar, screen, apps, the web, or saved memories. Would a correct reply to what the caller just said require doing something it cannot do from here: taking an action (sending, scheduling, creating, editing, changing, opening, calling), looking up live or current information, or retrieving a personal fact that is not already stated in the recent conversation? Answer yes if the caller is asking for, or agreeing to, any such action, even vaguely or via a short confirmation of an earlier offer. Answer no for conversation, opinions, general knowledge, and questions answerable from the recent conversation.";
61
+ "The voice assistant answering right now has NO tools and no access to the user's accounts, files, messages, calendar, apps, the web, or saved memories. It can start or refresh screen sharing and inspect shared screen images through built-in call controls. Answer no when the caller only asks to share their screen, look at their screen, or describe what is visible; obtaining a screen view does not require escalation. Requests to click, type, edit, or draw/highlight on the screen still require escalation. Would a correct reply to what the caller just said require doing something it cannot do from here: taking an action (sending, scheduling, creating, editing, changing, opening, calling), looking up live or current information, or retrieving a personal fact that is not already stated in the recent conversation? Answer yes if the caller is asking for, or agreeing to, any such action, even vaguely or via a short confirmation of an earlier offer. Answer no for conversation, opinions, general knowledge, and questions answerable from the recent conversation.";
61
62
 
62
63
  function textOf(message: Message): string {
63
64
  return message.content
@@ -33,7 +33,10 @@ import {
33
33
  /** The phrase spoken across the hand-off, as {@link resolveSpokenEscalationBridge} settles it. */
34
34
  export type SpokenEscalationBridge = ReturnType<
35
35
  typeof resolveSpokenEscalationBridge
36
- >;
36
+ > & {
37
+ /** The answer path already emitted this text; only flush pending speech. */
38
+ alreadyReleased?: boolean;
39
+ };
37
40
 
38
41
  /** The leg that answers for real after the front-door leg handed off. */
39
42
  export interface EscalatedLeg {
@@ -169,8 +172,8 @@ export function createFrontDoorLegCoordinator(options: {
169
172
  };
170
173
 
171
174
  const handOff = (
172
- cappedBridge: string,
173
- opts: { overruled?: boolean } = {},
175
+ bridgeText: string,
176
+ opts: { overruled?: boolean; alreadyReleased?: boolean } = {},
174
177
  ): void => {
175
178
  if (handedOff || !host.isLive()) {
176
179
  return;
@@ -182,7 +185,13 @@ export function createFrontDoorLegCoordinator(options: {
182
185
  } else {
183
186
  host.abortLeg();
184
187
  }
185
- const bridge = resolveSpokenEscalationBridge(cappedBridge, host.language());
188
+ const bridge: SpokenEscalationBridge = opts.alreadyReleased
189
+ ? {
190
+ spokenBridge: bridgeText,
191
+ usesFallback: false,
192
+ alreadyReleased: true,
193
+ }
194
+ : resolveSpokenEscalationBridge(bridgeText, host.language());
186
195
  host.speakBridge(bridge);
187
196
  // The bridge is the turn's spoken acknowledgement: narration keeps its
188
197
  // minimum gap from it rather than following it back to back.
@@ -299,6 +308,8 @@ export function createFrontDoorLegCoordinator(options: {
299
308
  const step = verdict.finish();
300
309
  if (step.kind === "bridge") {
301
310
  handOff(step.bridge);
311
+ } else if (step.kind === "terminal-escalate") {
312
+ handOff(step.bridge, { alreadyReleased: true });
302
313
  }
303
314
  }
304
315
  return handedOff;
@@ -0,0 +1,44 @@
1
+ import { shouldAttachHostProxyForCapability } from "../daemon/host-proxy-preactivation.js";
2
+ import { skillLoadTool } from "../tools/skills/load.js";
3
+ import type { ToolContext } from "../tools/types.js";
4
+ import { getLogger } from "../util/logger.js";
5
+
6
+ const log = getLogger("voice-screen-annotation");
7
+
8
+ export async function loadVoiceScreenAnnotation(
9
+ context: ToolContext,
10
+ ): Promise<string | null> {
11
+ if (
12
+ !shouldAttachHostProxyForCapability(
13
+ "host_cu_annotate",
14
+ context.transportInterface,
15
+ context.sourceActorPrincipalId,
16
+ )
17
+ ) {
18
+ return null;
19
+ }
20
+ try {
21
+ const result = await skillLoadTool.execute(
22
+ { skill: "screen-annotation" },
23
+ context,
24
+ { readOnly: true },
25
+ );
26
+ if (result.isError) {
27
+ log.warn(
28
+ { conversationId: context.conversationId, error: result.content },
29
+ "Could not preload screen annotation",
30
+ );
31
+ return null;
32
+ }
33
+ return [
34
+ "The user is sharing their screen. skill_load has automatically loaded screen-annotation for this turn. Use its tools through skill_execute when pointing at the shared screen.",
35
+ result.content,
36
+ ].join("\n\n");
37
+ } catch (err) {
38
+ log.warn(
39
+ { err, conversationId: context.conversationId },
40
+ "Could not preload screen annotation",
41
+ );
42
+ return null;
43
+ }
44
+ }
@@ -32,7 +32,9 @@ import { CONVERSATION_BUSY_MESSAGE } from "../daemon/conversation-messaging.js";
32
32
  import type { ModeSessionSourceHandle } from "../daemon/conversation-mode-session.js";
33
33
  import { resolveChannelCapabilities } from "../daemon/conversation-runtime-assembly.js";
34
34
  import { getOrCreateConversation } from "../daemon/conversation-store.js";
35
+ import { getEffectiveEnabledPluginSet } from "../daemon/conversation-tool-setup.js";
35
36
  import { preactivateHostProxySkills } from "../daemon/host-proxy-preactivation.js";
37
+ import { resolveTrustClass } from "../daemon/trust-context.js";
36
38
  import type { TrustContext } from "../daemon/trust-context-types.js";
37
39
  import {
38
40
  newestPersistedSightFrame,
@@ -71,19 +73,21 @@ import {
71
73
  import {
72
74
  CALL_OPENING_MARKER,
73
75
  CALL_VERIFICATION_COMPLETE_MARKER,
74
- ESCALATE_VERDICT_TOKEN,
76
+ ESCALATE_VERDICT_TOKENS,
75
77
  HOLD_VERDICT_TOKEN,
76
78
  stripInternalSpeechMarkers,
77
79
  terminalControlMarkerLength,
78
80
  } from "./voice-control-protocol.js";
79
81
  import { judgeEscalation } from "./voice-escalation-judge.js";
80
82
  import type { VoiceEscalationTarget } from "./voice-escalation-target.js";
83
+ import { loadVoiceScreenAnnotation } from "./voice-screen-annotation.js";
81
84
  import {
82
85
  createFrontDoorStreamGate,
83
86
  escalatedContinuationRule,
84
87
  ESCALATION_CONTINUATION_CONTENT,
85
88
  frontDoorCapabilityDigest,
86
89
  frontDoorDecisionRule,
90
+ leadingEscalationToken,
87
91
  spokenBridgeText,
88
92
  type VoiceRoutingLeg,
89
93
  } from "./voice-triage-escalate.js";
@@ -452,6 +456,8 @@ export interface VoiceTurnOptions {
452
456
  * session reports `macos` for its channel capabilities, iOS included.
453
457
  */
454
458
  macosDesktopSession?: boolean;
459
+ /** The desktop client currently shares a surface with this voice session. */
460
+ screenSharing?: boolean;
455
461
  /** Whether this is an inbound call (no outbound task). */
456
462
  isInbound: boolean;
457
463
  /** The outbound call task, if any. */
@@ -835,30 +841,32 @@ function trimOuterTextEdges(blocks: ContentBlock[]): ContentBlock[] {
835
841
  * `ESCALATE_VERDICT_TOKEN` reduces to a single text block holding the
836
842
  * capped bridge; empty spoken text means the caller heard only the canned
837
843
  * fallback bridge, which is audio-only and never a transcript row, so the
838
- * caller should delete the row. Stray verdict tokens elsewhere in an
839
- * answer were never spoken (the live gate strips them) and are stripped
840
- * from the persisted text to match.
844
+ * caller should delete the row. A terminal escalation keeps all speech
845
+ * already released before the verdict. Other stray verdict tokens were
846
+ * never spoken and are stripped from the persisted text to match.
841
847
  */
842
848
  export function cutFrontDoorContentAtVerdict(
843
849
  blocks: ContentBlock[],
844
850
  ): { blocks: ContentBlock[]; spokenText: string } | null {
845
851
  const joinedText = joinedTextOfBlocks(blocks);
846
- if (joinedText.trimStart().startsWith(ESCALATE_VERDICT_TOKEN)) {
847
- const spokenText = spokenBridgeText(joinedText);
852
+ const spokenText = spokenBridgeText(joinedText);
853
+ if (
854
+ spokenText.length > 0 ||
855
+ leadingEscalationToken(joinedText) !== undefined
856
+ ) {
848
857
  return {
849
858
  blocks: spokenText.length > 0 ? [{ type: "text", text: spokenText }] : [],
850
859
  spokenText,
851
860
  };
852
861
  }
853
862
  if (
854
- !joinedText.includes(ESCALATE_VERDICT_TOKEN) &&
863
+ !ESCALATE_VERDICT_TOKENS.some((token) => joinedText.includes(token)) &&
855
864
  !joinedText.includes(HOLD_VERDICT_TOKEN)
856
865
  ) {
857
866
  return null;
858
867
  }
859
868
  const kept = stripMarkersFromBlocks(blocks);
860
- const spokenText = joinedTextOfBlocks(kept).trim();
861
- return { blocks: kept, spokenText };
869
+ return { blocks: kept, spokenText: joinedTextOfBlocks(kept).trim() };
862
870
  }
863
871
 
864
872
  // ---------------------------------------------------------------------------
@@ -1806,6 +1814,20 @@ export async function startVoiceTurn(
1806
1814
  ? createFrontDoorStreamGate(opts.unifiedVerdict === true)
1807
1815
  : null;
1808
1816
 
1817
+ const broadcastFrontDoorText = (
1818
+ msg: Extract<AssistantEvent, { type: "assistant_text_delta" }>,
1819
+ ): void => {
1820
+ // Answer text waits for the judge, including text flushed at completion.
1821
+ if (
1822
+ frontDoorStreamGate?.answering &&
1823
+ !escalationJudgeSettled &&
1824
+ hubHold === null
1825
+ ) {
1826
+ hubHold = [];
1827
+ }
1828
+ emitHubEvent(msg);
1829
+ };
1830
+
1809
1831
  /**
1810
1832
  * Broadcast one agent-loop event to hub subscribers, holding a front-door
1811
1833
  * leg's control-plane text back at the boundary rather than emitting it and
@@ -1820,16 +1842,7 @@ export async function startVoiceTurn(
1820
1842
  }
1821
1843
  const released = frontDoorStreamGate.push(msg.text);
1822
1844
  if (released.length > 0) {
1823
- // Answer text while the escalation judge is out: hold it, and every
1824
- // leg event after it, until the verdict says the caller hears it.
1825
- if (
1826
- frontDoorStreamGate.answering &&
1827
- !escalationJudgeSettled &&
1828
- hubHold === null
1829
- ) {
1830
- hubHold = [];
1831
- }
1832
- emitHubEvent({ ...msg, text: released });
1845
+ broadcastFrontDoorText({ ...msg, text: released });
1833
1846
  }
1834
1847
  };
1835
1848
 
@@ -2108,6 +2121,23 @@ export async function startVoiceTurn(
2108
2121
  sourceInterface,
2109
2122
  sourceActorPrincipalId,
2110
2123
  );
2124
+ if (opts.screenSharing === true) {
2125
+ const annotation = await loadVoiceScreenAnnotation({
2126
+ conversationId: opts.conversationId,
2127
+ workingDir: conversation.workingDir,
2128
+ trustClass: resolveTrustClass(opts.trustContext),
2129
+ transportInterface: sourceInterface,
2130
+ clientOs: "macos",
2131
+ enabledPluginSet: getEffectiveEnabledPluginSet(conversation),
2132
+ sourceActorPrincipalId,
2133
+ signal: opts.signal,
2134
+ });
2135
+ if (annotation !== null) {
2136
+ conversation.setVoiceCallControlPrompt(
2137
+ [voiceCallControlPrompt, annotation].filter(Boolean).join("\n\n"),
2138
+ );
2139
+ }
2140
+ }
2111
2141
  }
2112
2142
  await conversation.runAgentLoop(persistedContent, messageId, {
2113
2143
  ...(opts.subagentNotification?.cronRunId
@@ -2128,7 +2158,7 @@ export async function startVoiceTurn(
2128
2158
  // never hands off, and correspondingly never flushes.
2129
2159
  const trailing = frontDoorStreamGate.finish();
2130
2160
  if (trailing.length > 0) {
2131
- broadcastMessage({
2161
+ broadcastFrontDoorText({
2132
2162
  type: "assistant_text_delta",
2133
2163
  text: trailing,
2134
2164
  ...(reservedAssistantRowId !== null
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * - `[0]` ({@link HOLD_VERDICT_TOKEN}, unified front-door only): the
8
8
  * caller is mid-thought — the leg is discarded and listening continues.
9
- * - `[1]` ({@link ESCALATE_VERDICT_TOKEN}) followed by ONE short natural
10
- * holding phrase: the turn is too tricky — the phrase is spoken (capped
9
+ * - `[ESCALATE]` ({@link ESCALATE_VERDICT_TOKEN}) followed by ONE short natural
10
+ * holding phrase: the turn is too tricky. The phrase is spoken (capped
11
11
  * at a single sentence) while the turn re-runs on the conversation's
12
12
  * own profile, the model the caller's typed turns already run on.
13
13
  * Because the holding phrase is spoken, the caller never hears the
@@ -20,6 +20,9 @@
20
20
  * the escalation hand-off: the bridge is capped session-side instead of
21
21
  * trusting the model to stop. Every infra failure fails open to a normal
22
22
  * committed answer turn.
23
+ * A standalone terminal `[ESCALATE]` is recovered at normal completion without
24
+ * delaying answer streaming or repeating the already released speech.
25
+ * The numeric `[1]` verdict is accepted only at the start of a reply.
23
26
  *
24
27
  * This module owns the routing policy in one place: the profile key, the
25
28
  * leg-specific prompt rules, the leading-token classifier, and the bridge
@@ -32,13 +35,20 @@ import {
32
35
  localizedOrDefault,
33
36
  } from "../util/language-subtag.js";
34
37
  import {
38
+ createControlMarkerHoldback,
35
39
  ESCALATE_VERDICT_TOKEN,
40
+ ESCALATE_VERDICT_TOKENS,
36
41
  HOLD_VERDICT_TOKEN,
37
42
  stripInternalSpeechMarkers,
38
43
  } from "./voice-control-protocol.js";
39
44
 
40
45
  export { ESCALATE_VERDICT_TOKEN, HOLD_VERDICT_TOKEN };
41
46
 
47
+ export function leadingEscalationToken(text: string): string | undefined {
48
+ const leading = text.trimStart();
49
+ return ESCALATE_VERDICT_TOKENS.find((token) => leading.startsWith(token));
50
+ }
51
+
42
52
  // The fast model fronting every turn is pinned by the `voiceFrontDoor` call
43
53
  // site (see config/call-site-defaults.ts) — no per-turn profile override.
44
54
  // The escalated leg is pinned by the bridge to the conversation's own
@@ -156,7 +166,7 @@ export const BRIDGE_SENTENCE_END_REGEX = new RegExp(
156
166
  );
157
167
 
158
168
  /**
159
- * Normalize a raw post-`[1]` stream into the bridge that is actually
169
+ * Normalize the post-verdict stream into the bridge that is actually
160
170
  * spoken: internal markers stripped, cut just after the first sentence
161
171
  * terminator, hard-capped at {@link MAX_ESCALATION_BRIDGE_CHARS}, trimmed.
162
172
  * The session speaks exactly this, the persisted front-door row keeps
@@ -174,7 +184,7 @@ export function capEscalationBridge(rawBridge: string): string {
174
184
  }
175
185
 
176
186
  /**
177
- * Whether enough of the post-`[1]` stream has arrived to finalize the
187
+ * Whether enough of the post-verdict stream has arrived to finalize the
178
188
  * bridge and hand off: a sentence terminator landed, or the hard cap is
179
189
  * reached. Until then the session keeps buffering (the bridge is spoken in
180
190
  * one piece at hand-off, so what is spoken is exactly the capped bridge).
@@ -188,24 +198,21 @@ export function isEscalationBridgeComplete(rawBridge: string): boolean {
188
198
  }
189
199
 
190
200
  /**
191
- * The spoken bridge of a front-door leg's FULL raw output: empty unless the
192
- * output leads with {@link ESCALATE_VERDICT_TOKEN} (a stray token later in
193
- * an answer is not an escalation under the verdict-first protocol), else
194
- * the capped bridge that followed the token. Used by transcript hygiene to
195
- * reconstruct what the caller heard from a persisted row, by running the
196
- * same verdict machine the live stream ran through.
201
+ * The spoken bridge of a front-door leg's full raw output: the capped
202
+ * phrase following a leading verdict, or the already released speech
203
+ * before a terminal verdict. Other output has no bridge. Transcript hygiene
204
+ * replays the same verdict machine to reconstruct what the caller heard.
197
205
  */
198
206
  export function spokenBridgeText(frontDoorText: string): string {
199
207
  const machine = createFrontDoorVerdictMachine(false);
200
208
  const step = machine.push(frontDoorText);
201
- if (step.kind !== "escalate") {
202
- return "";
203
- }
204
- if (step.bridge !== null) {
209
+ if (step.kind === "escalate" && step.bridge !== null) {
205
210
  return step.bridge;
206
211
  }
207
212
  const finished = machine.finish();
208
- return finished.kind === "bridge" ? finished.bridge : "";
213
+ return finished.kind === "bridge" || finished.kind === "terminal-escalate"
214
+ ? finished.bridge
215
+ : "";
209
216
  }
210
217
 
211
218
  /**
@@ -221,7 +228,8 @@ export function needsFallbackBridge(frontDoorText: string): boolean {
221
228
  /**
222
229
  * Classification of a front-door leg's accumulated leading output (already
223
230
  * `trimStart()`ed). `pending` means the stream could still become a verdict
224
- * token — keep buffering; everything else is final for the leg.
231
+ * token: keep buffering. Other results select the leg's streaming path;
232
+ * a terminal escalation verdict is checked separately at completion.
225
233
  */
226
234
  export type FrontDoorLeadingVerdict =
227
235
  | "pending"
@@ -250,12 +258,12 @@ export function classifyFrontDoorLeading(
250
258
  if (holdEnabled && leading.startsWith(HOLD_VERDICT_TOKEN)) {
251
259
  return "hold";
252
260
  }
253
- if (leading.startsWith(ESCALATE_VERDICT_TOKEN)) {
261
+ if (leadingEscalationToken(leading) !== undefined) {
254
262
  return "escalate";
255
263
  }
256
264
  const candidates = holdEnabled
257
- ? [HOLD_VERDICT_TOKEN, ESCALATE_VERDICT_TOKEN]
258
- : [ESCALATE_VERDICT_TOKEN];
265
+ ? [HOLD_VERDICT_TOKEN, ...ESCALATE_VERDICT_TOKENS]
266
+ : ESCALATE_VERDICT_TOKENS;
259
267
  if (candidates.some((token) => token.startsWith(leading))) {
260
268
  return "pending";
261
269
  }
@@ -287,6 +295,8 @@ export type FrontDoorStep =
287
295
  | { readonly kind: "bridging" }
288
296
  /** The capped bridge is complete: hand off to the escalated leg. */
289
297
  | { readonly kind: "bridge"; readonly bridge: string }
298
+ /** A terminal verdict hands off using the answer text already released. */
299
+ | { readonly kind: "terminal-escalate"; readonly bridge: string }
290
300
  /** The leg already held, handed off, or finished; nothing more happens. */
291
301
  | { readonly kind: "done" };
292
302
 
@@ -299,8 +309,9 @@ export type FrontDoorStep =
299
309
  *
300
310
  * `push` feeds one delta and reports what it decided. `finish` is called
301
311
  * when the leg completes normally: a bridge that stopped short of a
302
- * sentence terminator hands off with what arrived. A leg that is cancelled
303
- * instead never calls `finish`.
312
+ * sentence terminator hands off with what arrived. A standalone terminal
313
+ * escalation token also hands off, using the already released answer as
314
+ * its bridge. A cancelled leg never calls `finish`.
304
315
  */
305
316
  export interface FrontDoorVerdictMachine {
306
317
  push(deltaText: string): FrontDoorStep;
@@ -359,7 +370,9 @@ export function createFrontDoorVerdictMachine(
359
370
  }
360
371
  if (verdict === "escalate") {
361
372
  stage = "bridging";
362
- bridgeRaw = raw.trimStart().slice(ESCALATE_VERDICT_TOKEN.length);
373
+ bridgeRaw = raw
374
+ .trimStart()
375
+ .slice(leadingEscalationToken(raw)!.length);
363
376
  return {
364
377
  kind: "escalate",
365
378
  bridge: isEscalationBridgeComplete(bridgeRaw)
@@ -379,6 +392,17 @@ export function createFrontDoorVerdictMachine(
379
392
  if (stage === "bridging") {
380
393
  return completeBridge();
381
394
  }
395
+ if (stage === "answer") {
396
+ stage = "done";
397
+ const text = raw.trimEnd();
398
+ const beforeToken = text.slice(0, -ESCALATE_VERDICT_TOKEN.length);
399
+ if (text.endsWith(ESCALATE_VERDICT_TOKEN) && /\s$/.test(beforeToken)) {
400
+ return {
401
+ kind: "terminal-escalate",
402
+ bridge: stripInternalSpeechMarkers(beforeToken).trim(),
403
+ };
404
+ }
405
+ }
382
406
  stage = "done";
383
407
  return DONE_STEP;
384
408
  },
@@ -419,14 +443,25 @@ export interface FrontDoorStreamGate {
419
443
  * `usesFallbackBridge` in `live-voice-session.ts`) and deletes the row
420
444
  * rather than persisting a phrase the model never really produced, so
421
445
  * there is no displayed text for the gate to agree with.
422
- * - `answer`: the leg's output IS the reply, so every delta passes through,
423
- * including the leading text held back while the verdict was pending.
446
+ * - `answer`: speech streams immediately, with control markers held back
447
+ * and stripped using the same filter as the audio drivers.
424
448
  */
425
449
  export function createFrontDoorStreamGate(
426
450
  holdEnabled: boolean,
427
451
  ): FrontDoorStreamGate {
428
452
  const machine = createFrontDoorVerdictMachine(holdEnabled);
429
453
  let answering = false;
454
+ let rawSpeech = "";
455
+ let releasedSpeech = "";
456
+ const flushSpeech = createControlMarkerHoldback((text) => {
457
+ releasedSpeech += text;
458
+ });
459
+ const filterSpeech = (text: string, force = false): string => {
460
+ rawSpeech += text;
461
+ releasedSpeech = "";
462
+ flushSpeech(rawSpeech, { force });
463
+ return releasedSpeech;
464
+ };
430
465
  const releasable = (bridge: string): string =>
431
466
  bridge.length < MIN_SPOKEN_BRIDGE_CHARS ? "" : bridge;
432
467
  const release = (step: FrontDoorStep): string => {
@@ -443,8 +478,8 @@ export function createFrontDoorStreamGate(
443
478
  }
444
479
  };
445
480
  return {
446
- push: (deltaText) => release(machine.push(deltaText)),
447
- finish: () => release(machine.finish()),
481
+ push: (deltaText) => filterSpeech(release(machine.push(deltaText))),
482
+ finish: () => filterSpeech(release(machine.finish()), true),
448
483
  get answering() {
449
484
  return answering;
450
485
  },
@@ -66,6 +66,20 @@ let platformInstallCalls: Array<{ name: string; force?: boolean }> = [];
66
66
  let upgradePluginCalls: UpgradePluginOptions[] = [];
67
67
  let catalogMatches: PluginSearchMatch[] = [];
68
68
  let catalogError: Error | null = null;
69
+ const CATALOG_COMMIT = "a".repeat(40);
70
+
71
+ function githubCatalogMatch(name: string): PluginSearchMatch {
72
+ return {
73
+ name,
74
+ path: `github:example-org/${name}@${CATALOG_COMMIT}`,
75
+ category: null,
76
+ source: {
77
+ kind: "github",
78
+ repo: `example-org/${name}`,
79
+ ref: CATALOG_COMMIT,
80
+ },
81
+ };
82
+ }
69
83
 
70
84
  /**
71
85
  * Queued daemon IPC responses. The default (empty queue) is a transport
@@ -269,7 +283,10 @@ beforeEach(() => {
269
283
  installPluginCalls = [];
270
284
  platformInstallCalls = [];
271
285
  upgradePluginCalls = [];
272
- catalogMatches = [];
286
+ catalogMatches = [
287
+ githubCatalogMatch("example"),
288
+ githubCatalogMatch("imessage"),
289
+ ];
273
290
  catalogError = null;
274
291
  ipcResults = [];
275
292
  inspectResult = null;
@@ -485,6 +502,17 @@ describe("plugins install - declared-schedules consent", () => {
485
502
  ]);
486
503
  expect(r.exitCode).toBe(0);
487
504
  });
505
+
506
+ test("does not use the outage fallback for a gated integration", async () => {
507
+ catalogError = new Error("catalog unavailable");
508
+
509
+ const r = await runCommand(["plugins", "install", "gamma"]);
510
+
511
+ expect(installPluginCalls).toHaveLength(0);
512
+ expect(platformInstallCalls).toHaveLength(0);
513
+ expect(r.stderr).toContain("not in the marketplace catalog");
514
+ expect(r.exitCode).toBe(1);
515
+ });
488
516
  });
489
517
 
490
518
  describe("plugins upgrade - declared-schedules consent (local fallback)", () => {
@@ -44,7 +44,11 @@ Examples:
44
44
  description:
45
45
  "Destination address for outbound verification (handle, phone number, user ID, or email address)",
46
46
  },
47
- { flags: "--rebind", description: "Replace existing guardian binding" },
47
+ {
48
+ flags: "--rebind",
49
+ description:
50
+ "Create a code although the channel already has a guardian (see below)",
51
+ },
48
52
  {
49
53
  flags: "--conversation-id <conversationId>",
50
54
  description: "Conversation ID for inbound challenges",
@@ -74,7 +78,13 @@ Routes between three creation modes based on the provided options:
74
78
  2. Outbound: --channel <ch> --destination <dest>
75
79
  Sends a verification code to the given destination. Supports telegram
76
80
  (handle or chat ID), phone (E.164 number), slack and discord (user ID),
77
- and email. Use --rebind to replace an existing guardian binding.
81
+ and email.
82
+
83
+ --rebind creates a code although the channel already has a guardian. On
84
+ telegram, slack, discord and email, only the account already bound can redeem
85
+ that code, which is how the same guardian verifies again. A code never moves
86
+ the guardian to a different account there: run "revoke" first, then create
87
+ without --rebind. On phone, an outbound code replaces the bound number.
78
88
 
79
89
  3. Inbound: --channel <ch> (no --destination)
80
90
  Generates a challenge secret for the guardian to send back on the
@@ -804,7 +804,8 @@ Examples:
804
804
  $ assistant oauth request --provider google https://www.googleapis.com/calendar/v3/calendars/primary/events
805
805
  $ assistant oauth request --provider slack -H "Content-Type: application/json" -d '{"channel":"C123"}' /api/chat.postMessage --json
806
806
  $ assistant oauth request --provider google -X POST -H "Content-Type: multipart/related; boundary=b" -d @upload.txt "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"
807
- $ assistant oauth request --provider google -X POST -H "Content-Type: application/pdf" -d @report.pdf "https://www.googleapis.com/upload/drive/v3/files?uploadType=media"`,
807
+ $ assistant oauth request --provider google -X POST -H "Content-Type: application/pdf" -d @report.pdf "https://www.googleapis.com/upload/drive/v3/files?uploadType=media"
808
+ $ assistant oauth request --provider google -X POST -H "Content-Type: message/rfc822" -d @draft.eml "https://www.googleapis.com/upload/gmail/v1/users/me/drafts?uploadType=media"`,
808
809
  },
809
810
  {
810
811
  name: "disconnect",
@@ -929,9 +930,14 @@ Requests with unsupported headers, including If-Match and Idempotency-Key,
929
930
  are rejected before calling the provider. A connection using your own OAuth
930
931
  app can preserve those headers.
931
932
 
933
+ The printed base URL is a path prefix. Concatenating a provider path onto it
934
+ works, and so does a host-absolute path such as /upload/gmail/... or
935
+ /v1/charges: the gateway remaps that request onto the grant's prefix.
936
+
932
937
  Examples:
933
938
  $ assistant oauth proxy-url stripe_link
934
- $ eval "$(assistant oauth proxy-url stripe_link --export)" && LINK_API_BASE_URL="$VELLUM_OAUTH_PROXY_BASE_URL" LINK_ACCESS_TOKEN="$VELLUM_OAUTH_PROXY_TOKEN" LINK_NO_REFRESH=1 link-cli payment-methods list --format json`,
939
+ $ eval "$(assistant oauth proxy-url stripe_link --export)" && LINK_API_BASE_URL="$VELLUM_OAUTH_PROXY_BASE_URL" LINK_ACCESS_TOKEN="$VELLUM_OAUTH_PROXY_TOKEN" LINK_NO_REFRESH=1 link-cli payment-methods list --format json
940
+ $ eval "$(assistant oauth proxy-url google --export)" && curl -X POST -H "Authorization: Bearer $VELLUM_OAUTH_PROXY_TOKEN" -H "Content-Type: message/rfc822" --data-binary @draft.eml "$VELLUM_OAUTH_PROXY_BASE_URL/upload/gmail/v1/users/me/drafts?uploadType=media"`,
935
941
  },
936
942
  ],
937
943
  };
@@ -309,6 +309,38 @@ describe("oauth request body encoding", () => {
309
309
  },
310
310
  ]);
311
311
  });
312
+
313
+ test("forwards a Gmail rfc822 draft upload URL and body", async () => {
314
+ const rfc822 =
315
+ "From: user@example.com\r\nTo: user@example.com\r\nSubject: Draft\r\n\r\nHello\r\n";
316
+ const { exitCode } = await runRequestCommand([
317
+ "--provider",
318
+ "google",
319
+ "-s",
320
+ "-X",
321
+ "POST",
322
+ "-H",
323
+ "Content-Type: message/rfc822",
324
+ "-d",
325
+ rfc822,
326
+ "https://www.googleapis.com/upload/gmail/v1/users/me/drafts?uploadType=media",
327
+ ]);
328
+
329
+ expect(exitCode).toBe(0);
330
+ expect(handleRequestCalls).toEqual([
331
+ {
332
+ body: {
333
+ provider: "google",
334
+ url: "https://www.googleapis.com/upload/gmail/v1/users/me/drafts?uploadType=media",
335
+ method: "POST",
336
+ headers: {
337
+ "Content-Type": "message/rfc822",
338
+ },
339
+ parsed_data: rfc822,
340
+ },
341
+ },
342
+ ]);
343
+ });
312
344
  });
313
345
 
314
346
  describe("oauth request body output", () => {
@@ -55,7 +55,8 @@ Fields:
55
55
  isPlatform Whether IS_PLATFORM is set (boolean)
56
56
  baseUrl VELLUM_PLATFORM_URL — the platform gateway base URL
57
57
  assistantId This assistant's platform UUID
58
- hasAssistantApiKey Whether a stored assistant API key is available
58
+ hasAssistantApiKey Whether a stored assistant API key is available; null
59
+ when the credential store could not be read
59
60
  hasWebhookSecret Whether a stored webhook secret is available (needed
60
61
  for email and other inbound webhook channels)
61
62
  available Whether callback registration prerequisites are satisfied
@@ -24,7 +24,7 @@ interface PlatformStatusResult {
24
24
  isPlatform: boolean;
25
25
  baseUrl: string;
26
26
  assistantId: string;
27
- hasAssistantApiKey: boolean;
27
+ hasAssistantApiKey: boolean | null;
28
28
  hasWebhookSecret: boolean;
29
29
  available: boolean;
30
30
  organizationId: string | null;
@@ -95,7 +95,13 @@ export function registerPlatformCommand(program: Command): void {
95
95
  log.info(`Base URL: ${result.baseUrl || "(not set)"}`);
96
96
  log.info(`Assistant ID: ${result.assistantId || "(not set)"}`);
97
97
  log.info(
98
- `Assistant API key: ${result.hasAssistantApiKey ? "set" : "not set"}`,
98
+ `Assistant API key: ${
99
+ result.hasAssistantApiKey === null
100
+ ? "unknown (credential store unreachable)"
101
+ : result.hasAssistantApiKey
102
+ ? "set"
103
+ : "not set"
104
+ }`,
99
105
  );
100
106
  log.info(
101
107
  `Webhook secret: ${result.hasWebhookSecret ? "set" : "not set (run ensure-registration to provision)"}`,