@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -5,8 +5,6 @@ import { getDb } from "../persistence/db-connection.js";
5
5
  import { followups } from "../persistence/schema/index.js";
6
6
  import type { FollowUp, FollowUpCreateInput, FollowUpStatus } from "./types.js";
7
7
 
8
- // ── Helpers ──────────────────────────────────────────────────────────
9
-
10
8
  function parseFollowUp(row: typeof followups.$inferSelect): FollowUp {
11
9
  const scheduleId = row.reminderCronId;
12
10
  return {
@@ -23,8 +21,6 @@ function parseFollowUp(row: typeof followups.$inferSelect): FollowUp {
23
21
  };
24
22
  }
25
23
 
26
- // ── CRUD ─────────────────────────────────────────────────────────────
27
-
28
24
  export function createFollowUp(input: FollowUpCreateInput): FollowUp {
29
25
  const db = getDb();
30
26
  const now = Date.now();
@@ -8,6 +8,7 @@ import {
8
8
  MESSAGE_KEYS,
9
9
  negotiateLocale,
10
10
  resolveConversationTitle,
11
+ resolveStoredMessageText,
11
12
  SUPPORTED_LOCALES,
12
13
  t,
13
14
  } from "../index.js";
@@ -103,6 +104,30 @@ describe("resolveConversationTitle", () => {
103
104
  });
104
105
  });
105
106
 
107
+ describe("resolveStoredMessageText", () => {
108
+ test("resolves exact stored keys while preserving surrounding whitespace", () => {
109
+ expect(
110
+ resolveStoredMessageText(
111
+ `\n\n${MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP}`,
112
+ "es",
113
+ ),
114
+ ).toBe(
115
+ `\n\n${
116
+ MESSAGE_CATALOGS.es[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP]
117
+ }`,
118
+ );
119
+ });
120
+
121
+ test("passes legacy and ordinary display text through", () => {
122
+ const legacy =
123
+ "I stopped after repeated malformed tool calls to keep this conversation from growing indefinitely. Please try again, or switch models if the problem continues.";
124
+ expect(resolveStoredMessageText(legacy, "es")).toBe(legacy);
125
+ expect(resolveStoredMessageText("ordinary assistant text", "zh")).toBe(
126
+ "ordinary assistant text",
127
+ );
128
+ });
129
+ });
130
+
106
131
  describe("isMessageKey", () => {
107
132
  test("recognizes catalog keys only", () => {
108
133
  expect(isMessageKey(MESSAGE_KEYS.CONVERSATION_TITLE_UNTITLED)).toBe(true);
@@ -0,0 +1,18 @@
1
+ /** Resolve persisted daemon-authored assistant text at a presentation edge. */
2
+
3
+ import { DEFAULT_LOCALE, type SupportedLocale } from "./locales.js";
4
+ import { isMessageKey, t } from "./messages.js";
5
+
6
+ export function resolveStoredMessageText(
7
+ text: string,
8
+ locale: SupportedLocale = DEFAULT_LOCALE,
9
+ ): string {
10
+ const trimmed = text.trim();
11
+ if (!isMessageKey(trimmed)) {
12
+ return text;
13
+ }
14
+ const start = text.indexOf(trimmed);
15
+ return `${text.slice(0, start)}${t(trimmed, locale)}${text.slice(
16
+ start + trimmed.length,
17
+ )}`;
18
+ }
package/src/i18n/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { resolveStoredMessageText } from "./assistant-text.js";
1
2
  export {
2
3
  DEFAULT_LOCALE,
3
4
  isSupportedLocale,
@@ -16,6 +16,7 @@ import { DEFAULT_LOCALE, type SupportedLocale } from "./locales.js";
16
16
  export const MESSAGE_KEYS = {
17
17
  CONVERSATION_TITLE_GENERATING: "conversation.title.generating",
18
18
  CONVERSATION_TITLE_UNTITLED: "conversation.title.untitled",
19
+ AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP: "agentLoop.unparseableToolRetryStop",
19
20
  PLUGIN_MCP_OAUTH_CREDENTIALS_UNCHECKED:
20
21
  "plugin.uninstall.mcp_oauth_credentials_unchecked",
21
22
  } as const;
@@ -27,30 +28,40 @@ export const MESSAGE_CATALOGS: Record<
27
28
  Record<MessageKey, string>
28
29
  > = {
29
30
  en: {
31
+ "agentLoop.unparseableToolRetryStop":
32
+ "I stopped after repeated malformed tool calls to keep this conversation from growing indefinitely. Please try again, or switch models if the problem continues.",
30
33
  "conversation.title.generating": "Generating title...",
31
34
  "conversation.title.untitled": "Untitled Conversation",
32
35
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
33
36
  "Credential storage is unavailable, so historical plugin MCP OAuth credentials could not be checked.",
34
37
  },
35
38
  es: {
39
+ "agentLoop.unparseableToolRetryStop":
40
+ "Me detuve después de varias llamadas de herramienta con formato incorrecto para evitar que esta conversación creciera indefinidamente. Inténtalo de nuevo o cambia de modelo si el problema continúa.",
36
41
  "conversation.title.generating": "Generando título...",
37
42
  "conversation.title.untitled": "Sin título",
38
43
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
39
44
  "El almacenamiento de credenciales no está disponible, por lo que no se pudieron comprobar las credenciales históricas de OAuth de MCP del plugin.",
40
45
  },
41
46
  ru: {
47
+ "agentLoop.unparseableToolRetryStop":
48
+ "Выполнение остановлено после нескольких некорректных вызовов инструментов, чтобы этот разговор не рос бесконечно. Попробуйте ещё раз или смените модель, если проблема не исчезнет.",
42
49
  "conversation.title.generating": "Создание названия...",
43
50
  "conversation.title.untitled": "Без названия",
44
51
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
45
52
  "Хранилище учетных данных недоступно, поэтому не удалось проверить сохраненные учетные данные OAuth MCP плагина.",
46
53
  },
47
54
  zh: {
55
+ "agentLoop.unparseableToolRetryStop":
56
+ "在工具调用多次格式错误后,我已停止运行,以免此对话无限增长。请重试;如果问题仍然存在,请切换模型。",
48
57
  "conversation.title.generating": "标题生成中...",
49
58
  "conversation.title.untitled": "无标题",
50
59
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
51
60
  "凭据存储不可用,因此无法检查该插件之前的 MCP OAuth 凭据。",
52
61
  },
53
62
  "zh-TW": {
63
+ "agentLoop.unparseableToolRetryStop":
64
+ "在工具呼叫多次格式錯誤後,我已停止執行,以免此對話無限增長。請重試;如果問題仍然存在,請切換模型。",
54
65
  "conversation.title.generating": "標題產生中...",
55
66
  "conversation.title.untitled": "未命名",
56
67
  "plugin.uninstall.mcp_oauth_credentials_unchecked":
@@ -21,19 +21,42 @@ mock.module("../platform-callback-registration.js", () => ({
21
21
 
22
22
  mock.module("../public-ingress-urls.js", () => ({
23
23
  getOAuthCallbackUrl: () => "https://direct.example/webhooks/oauth/callback",
24
+ getMcpOAuthClientMetadataUrl: () =>
25
+ "https://direct.example/oauth/client-metadata.json",
24
26
  }));
25
27
 
26
28
  mock.module("../../config/loader.js", () => ({
27
29
  loadConfig: () => ({}),
28
30
  }));
29
31
 
30
- const { OAUTH_CALLBACK_PATH, resolveOauthCallbackUrl } =
31
- await import("../oauth-callback-url.js");
32
+ const {
33
+ OAUTH_CALLBACK_PATH,
34
+ resolveMcpOAuthClientMetadataUrl,
35
+ resolveOauthCallbackUrl,
36
+ } = await import("../oauth-callback-url.js");
32
37
 
33
38
  afterEach(() => {
34
39
  resolveCallbackUrl.mockClear();
35
40
  });
36
41
 
42
+ describe("resolveMcpOAuthClientMetadataUrl", () => {
43
+ test("returns the metadata URL for the exact direct HTTPS callback", () => {
44
+ expect(
45
+ resolveMcpOAuthClientMetadataUrl(
46
+ "https://direct.example/webhooks/oauth/callback",
47
+ ),
48
+ ).toBe("https://direct.example/oauth/client-metadata.json");
49
+ });
50
+
51
+ test("returns undefined for a callback served somewhere else", () => {
52
+ expect(
53
+ resolveMcpOAuthClientMetadataUrl(
54
+ "https://platform.example/v1/gateway/callbacks/assistant-123/webhooks/oauth/callback",
55
+ ),
56
+ ).toBeUndefined();
57
+ });
58
+ });
59
+
37
60
  describe("resolveOauthCallbackUrl", () => {
38
61
  test("returns whatever the shared resolver decides", async () => {
39
62
  await expect(resolveOauthCallbackUrl()).resolves.toEqual(
@@ -18,7 +18,10 @@
18
18
 
19
19
  import { loadConfig } from "../config/loader.js";
20
20
  import { resolveCallbackUrl } from "./platform-callback-registration.js";
21
- import { getOAuthCallbackUrl } from "./public-ingress-urls.js";
21
+ import {
22
+ getMcpOAuthClientMetadataUrl,
23
+ getOAuthCallbackUrl,
24
+ } from "./public-ingress-urls.js";
22
25
 
23
26
  /**
24
27
  * Path the gateway serves the shared OAuth callback on.
@@ -59,3 +62,29 @@ export async function resolveOauthCallbackUrl(): Promise<string> {
59
62
  OAUTH_REGISTRATION_TYPE,
60
63
  );
61
64
  }
65
+
66
+ /**
67
+ * Return the public Client ID Metadata Document URL when it is compatible
68
+ * with the callback selected for this flow.
69
+ *
70
+ * A URL-based client id must be HTTPS, and its document must publish the exact
71
+ * redirect URI used by the authorization request. The gateway document is
72
+ * derived from public ingress, so a platform-relayed callback cannot use it.
73
+ * Returning undefined preserves Dynamic Client Registration as the SDK's
74
+ * fallback in either case.
75
+ */
76
+ export function resolveMcpOAuthClientMetadataUrl(
77
+ redirectUrl: string,
78
+ ): string | undefined {
79
+ try {
80
+ const config = loadConfig();
81
+ if (getOAuthCallbackUrl(config) !== redirectUrl) {
82
+ return undefined;
83
+ }
84
+
85
+ const metadataUrl = getMcpOAuthClientMetadataUrl(config);
86
+ return new URL(metadataUrl).protocol === "https:" ? metadataUrl : undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
@@ -27,6 +27,10 @@
27
27
  * All public-facing ingress URL construction is centralized here.
28
28
  */
29
29
 
30
+ import {
31
+ buildMcpOAuthCallbackUrl,
32
+ buildMcpOAuthClientMetadataUrl,
33
+ } from "@vellumai/service-contracts/mcp-oauth";
30
34
  import {
31
35
  buildTwilioMediaStreamUrl,
32
36
  buildTwilioStatusWebhookUrl,
@@ -160,8 +164,14 @@ export function getTwilioMediaStreamUrl(config: IngressConfig): string {
160
164
  * Build the OAuth callback URL.
161
165
  */
162
166
  export function getOAuthCallbackUrl(config: IngressConfig): string {
163
- const base = getPublicBaseUrl(config);
164
- return `${base}/webhooks/oauth/callback`;
167
+ return buildMcpOAuthCallbackUrl(getPublicBaseUrl(config));
168
+ }
169
+
170
+ /**
171
+ * Build the public Client ID Metadata Document URL for MCP OAuth.
172
+ */
173
+ export function getMcpOAuthClientMetadataUrl(config: IngressConfig): string {
174
+ return buildMcpOAuthClientMetadataUrl(getPublicBaseUrl(config));
165
175
  }
166
176
 
167
177
  /**
@@ -19,6 +19,7 @@ import {
19
19
  ipcCallPersistent,
20
20
  ipcClassifyRisk,
21
21
  ipcGetFeatureFlags,
22
+ ipcGetVelayStatus,
22
23
  resetPersistentClient,
23
24
  } from "./gateway-client.js";
24
25
 
@@ -139,6 +140,45 @@ describe("ipcGetFeatureFlags", () => {
139
140
  });
140
141
  });
141
142
 
143
+ describe("ipcGetVelayStatus", () => {
144
+ test("parses the shared Velay status response", async () => {
145
+ mockGatewayIpc(null, {
146
+ results: {
147
+ get_velay_status: {
148
+ connected: true,
149
+ publicUrl: "https://example.test",
150
+ },
151
+ },
152
+ });
153
+
154
+ expect(await ipcGetVelayStatus()).toEqual({
155
+ connected: true,
156
+ publicUrl: "https://example.test",
157
+ });
158
+ });
159
+
160
+ test("keeps connected status when publicUrl is malformed", async () => {
161
+ mockGatewayIpc(null, {
162
+ results: {
163
+ get_velay_status: { connected: false, publicUrl: 42 },
164
+ },
165
+ });
166
+
167
+ expect(await ipcGetVelayStatus()).toEqual({
168
+ connected: false,
169
+ publicUrl: null,
170
+ });
171
+ });
172
+
173
+ test("returns null when connected is missing or malformed", async () => {
174
+ mockGatewayIpc(null, {
175
+ results: { get_velay_status: { connected: "yes" } },
176
+ });
177
+
178
+ expect(await ipcGetVelayStatus()).toBeNull();
179
+ });
180
+ });
181
+
142
182
  // ---------------------------------------------------------------------------
143
183
  // ipcClassifyRisk retry semantics
144
184
  // ---------------------------------------------------------------------------
@@ -14,10 +14,13 @@ import {
14
14
  type ClassifyRiskIpcParams,
15
15
  type ClassifyRiskIpcResponse,
16
16
  ClassifyRiskIpcResponseSchema,
17
+ FeatureFlagsIpcResponseSchema,
17
18
  ListWebhookRoutesIpcResponseSchema,
18
19
  type RegisterWebhookRouteIpcParams,
19
20
  RegisterWebhookRouteIpcResponseSchema,
20
21
  UnregisterWebhookRouteIpcResponseSchema,
22
+ type VelayStatusIpcResponse,
23
+ VelayStatusIpcResponseSchema,
21
24
  type WebhookIngressRoute,
22
25
  } from "@vellumai/gateway-client";
23
26
  import {
@@ -148,46 +151,26 @@ export function resetPersistentClient(): void {
148
151
  export async function ipcGetFeatureFlags(
149
152
  timeoutMs?: number,
150
153
  ): Promise<Record<string, boolean | string>> {
151
- const result = await ipcCall("get_feature_flags", undefined, timeoutMs);
152
- if (result && typeof result === "object" && !Array.isArray(result)) {
153
- const filtered: Record<string, boolean | string> = {};
154
- for (const [k, v] of Object.entries(result as Record<string, unknown>)) {
155
- if (typeof v === "boolean" || typeof v === "string") {
156
- filtered[k] = v;
157
- }
158
- }
159
- return filtered;
160
- }
161
- return {};
154
+ const parsed = FeatureFlagsIpcResponseSchema.safeParse(
155
+ await ipcCall("get_feature_flags", undefined, timeoutMs),
156
+ );
157
+ return parsed.success ? parsed.data : {};
162
158
  }
163
159
 
164
160
  // ---------------------------------------------------------------------------
165
161
  // Velay tunnel status
166
162
  // ---------------------------------------------------------------------------
167
163
 
168
- export interface VelayTunnelStatus {
169
- connected: boolean;
170
- publicUrl: string | null;
171
- }
172
-
173
164
  /**
174
165
  * Fetch the current Velay tunnel status from the gateway via IPC.
175
166
  * Returns `null` when the gateway is unreachable or returns an unexpected
176
167
  * response — callers should treat `null` as "gateway not running".
177
168
  */
178
- export async function ipcGetVelayStatus(): Promise<VelayTunnelStatus | null> {
179
- const result = await ipcCall("get_velay_status");
180
- if (!result || typeof result !== "object" || Array.isArray(result)) {
181
- return null;
182
- }
183
- const obj = result as Record<string, unknown>;
184
- if (typeof obj.connected !== "boolean") {
185
- return null;
186
- }
187
- return {
188
- connected: obj.connected,
189
- publicUrl: typeof obj.publicUrl === "string" ? obj.publicUrl : null,
190
- };
169
+ export async function ipcGetVelayStatus(): Promise<VelayStatusIpcResponse | null> {
170
+ const parsed = VelayStatusIpcResponseSchema.safeParse(
171
+ await ipcCall("get_velay_status"),
172
+ );
173
+ return parsed.success ? parsed.data : null;
191
174
  }
192
175
 
193
176
  // ---------------------------------------------------------------------------
@@ -26,6 +26,13 @@ function refreshFlagsAndReconcileProfiles(context: string): void {
26
26
  if (!loaded) {
27
27
  return;
28
28
  }
29
+ // Consumers read the synchronous flag cache in response to this event.
30
+ void publishSyncInvalidation([
31
+ SYNC_TAGS.featureFlagsClient,
32
+ SYNC_TAGS.featureFlagsAssistant,
33
+ ]).catch((err) => {
34
+ log.warn({ err }, "Failed to broadcast feature-flags sync_changed");
35
+ });
29
36
  if (reconcileFlagGatedProfiles()) {
30
37
  // Reuse the config-changed broadcast clients already consume: web
31
38
  // routes the `assistant:self:config` sync tag to the effective profile
@@ -65,16 +72,6 @@ function handleData(chunk: Buffer): void {
65
72
  if (msg.event === "feature_flags_changed") {
66
73
  log.info("Received feature_flags_changed event — refreshing overrides");
67
74
  refreshFlagsAndReconcileProfiles("flags-changed event");
68
- // Fan out to every connected web client so React Query caches
69
- // for `/v1/feature-flags/client-flag-values/` and
70
- // `/v1/assistants/:id/feature-flags` invalidate immediately
71
- // instead of waiting on a 5s polling tick.
72
- publishSyncInvalidation([
73
- SYNC_TAGS.featureFlagsClient,
74
- SYNC_TAGS.featureFlagsAssistant,
75
- ]).catch((err) => {
76
- log.warn({ err }, "Failed to broadcast feature-flags sync_changed");
77
- });
78
75
  }
79
76
  } catch {
80
77
  // Ignore non-JSON lines (e.g. IPC responses on a shared socket)
@@ -265,6 +265,77 @@ describe("LiveVoiceSession assistant turn", () => {
265
265
  },
266
266
  );
267
267
 
268
+ test("offers the newest snapshot of the shared surface's controls to the bridge, until the share ends", async () => {
269
+ const startVoiceTurn = mock(async (_options: VoiceTurnOptions) => ({
270
+ turnId: "bridge-turn-1",
271
+ abort: mock(),
272
+ }));
273
+ const { session, transcriber } = createSessionHarness({
274
+ startFrame: { ...START_FRAME, client: "macos" },
275
+ startVoiceTurn,
276
+ });
277
+ const snapshotNaming = (label: string) => ({
278
+ targets: [
279
+ {
280
+ id: `t-${label}`,
281
+ label,
282
+ role: "AXButton",
283
+ x: 0.8,
284
+ y: 0.05,
285
+ width: 0.05,
286
+ height: 0.03,
287
+ },
288
+ ],
289
+ total: 1,
290
+ });
291
+ await session.start();
292
+ await session.handleClientFrame({
293
+ type: "update_config",
294
+ screenSharing: true,
295
+ });
296
+ await session.handleClientFrame({
297
+ type: "update_config",
298
+ shareTargets: snapshotNaming("Filters"),
299
+ });
300
+ await session.handleClientFrame({
301
+ type: "update_config",
302
+ shareTargets: snapshotNaming("root_Filters"),
303
+ });
304
+ transcriber.emit({ type: "final", text: "Where is Filters?" });
305
+ await session.handleClientFrame({ type: "ptt_release" });
306
+ expect(startVoiceTurn.mock.calls[0]?.[0].shareTargets).toEqual(
307
+ snapshotNaming("root_Filters"),
308
+ );
309
+ await session.close("client_end");
310
+
311
+ // A share that ended takes its snapshot with it.
312
+ const ended = createSessionHarness({
313
+ startFrame: { ...START_FRAME, client: "macos" },
314
+ startVoiceTurn,
315
+ });
316
+ startVoiceTurn.mockClear();
317
+ await ended.session.start();
318
+ await ended.session.handleClientFrame({
319
+ type: "update_config",
320
+ screenSharing: true,
321
+ shareTargets: snapshotNaming("root_Filters"),
322
+ });
323
+ await ended.session.handleClientFrame({
324
+ type: "update_config",
325
+ screenSharing: false,
326
+ });
327
+ await ended.session.handleClientFrame({
328
+ type: "update_config",
329
+ screenSharing: true,
330
+ });
331
+ ended.transcriber.emit({ type: "final", text: "Where is Filters?" });
332
+ await ended.session.handleClientFrame({ type: "ptt_release" });
333
+ expect(startVoiceTurn.mock.calls[0]?.[0]).not.toHaveProperty(
334
+ "shareTargets",
335
+ );
336
+ await ended.session.close("client_end");
337
+ });
338
+
268
339
  test("runs final transcripts through the voice bridge and forwards ordered assistant events", async () => {
269
340
  const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
270
341
  options.callbacks?.assistant_text_delta?.({
@@ -1771,6 +1771,89 @@ describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
1771
1771
  await session.close("client_end");
1772
1772
  }, 10_000);
1773
1773
 
1774
+ test("rearms an empty fallback cycle while the previous reply is active", async () => {
1775
+ const abort = mock();
1776
+ const { frames, session, transcribers, turnCalls } = createHarness({
1777
+ fluxConfig: FLUX_ON,
1778
+ startVoiceTurn: async (turnOptions) => {
1779
+ turnOptions.callbacks?.assistant_text_delta?.(
1780
+ makeTextDelta("Working on the edit."),
1781
+ );
1782
+ return { turnId: "editing-turn", abort };
1783
+ },
1784
+ });
1785
+
1786
+ try {
1787
+ await session.start();
1788
+ await waitFor(() => transcribers.length === 1);
1789
+ const transcriber = transcribers[0]!;
1790
+ await session.handleBinaryAudio(LOUD_CHUNK);
1791
+ transcriber.startOfTurn(0);
1792
+ transcriber.endOfTurn("edit the clip", 0);
1793
+ await waitFor(() => turnCalls.length === 1);
1794
+
1795
+ const speechStarts = countFrames(frames, "speech_started");
1796
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1797
+ // No provider speech start or transcript arrives for this local onset.
1798
+ await waitFor(() => transcriber.stopped);
1799
+ await waitFor(
1800
+ () => transcribers.length >= 2,
1801
+ "An empty fallback cycle blocked the replacement stream behind the reply",
1802
+ );
1803
+ expect(countFrames(frames, "utterance_discarded")).toBe(1);
1804
+ expect(countFrames(frames, "speech_started")).toBe(speechStarts);
1805
+ expect(countFrames(frames, "turn_cancelled")).toBe(0);
1806
+ expect(abort).not.toHaveBeenCalled();
1807
+ expect(turnCalls).toHaveLength(1);
1808
+
1809
+ const replacement = transcribers.at(-1)!;
1810
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1811
+ expect(replacement.received.length).toBeGreaterThan(0);
1812
+ replacement.startOfTurn(0);
1813
+ await waitFor(() => countFrames(frames, "turn_cancelled") === 1);
1814
+ expect(countFrames(frames, "speech_started")).toBe(speechStarts + 1);
1815
+ expect(abort).toHaveBeenCalledTimes(1);
1816
+
1817
+ replacement.endOfTurn("stop editing", 0);
1818
+ await waitFor(() => turnCalls.length === 2);
1819
+ expect(turnCalls[1]?.content).toBe("stop editing");
1820
+ } finally {
1821
+ await session.close("client_end");
1822
+ }
1823
+ }, 10_000);
1824
+
1825
+ test("keeps a nonempty fallback transcript queued behind the previous reply", async () => {
1826
+ const abort = mock();
1827
+ const { frames, session, transcribers, turnCalls } = createHarness({
1828
+ fluxConfig: FLUX_ON,
1829
+ startVoiceTurn: async () => ({ turnId: "editing-turn", abort }),
1830
+ });
1831
+
1832
+ try {
1833
+ await session.start();
1834
+ await waitFor(() => transcribers.length === 1);
1835
+ const transcriber = transcribers[0]!;
1836
+ await session.handleBinaryAudio(LOUD_CHUNK);
1837
+ transcriber.startOfTurn(0);
1838
+ transcriber.endOfTurn("edit the clip", 0);
1839
+ await waitFor(() => turnCalls.length === 1);
1840
+
1841
+ transcriber.pendingFlushText = "show me the result";
1842
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1843
+ await waitFor(() => transcriber.stopped);
1844
+ await flushAsyncCallbacks();
1845
+ expect(turnCalls).toHaveLength(1);
1846
+ expect(countFrames(frames, "utterance_discarded")).toBe(0);
1847
+ expect(abort).not.toHaveBeenCalled();
1848
+
1849
+ turnCalls[0]?.callbacks?.message_complete?.(makeMessageComplete());
1850
+ await waitFor(() => turnCalls.length === 2);
1851
+ expect(turnCalls[1]?.content).toBe("show me the result");
1852
+ } finally {
1853
+ await session.close("client_end");
1854
+ }
1855
+ }, 10_000);
1856
+
1774
1857
  test("routes a closing stream's flush into the turn it released", async () => {
1775
1858
  const { session, transcribers, turnCalls } = createHarness({
1776
1859
  fluxConfig: FLUX_ON,
@@ -88,6 +88,83 @@ describe("parseLiveVoiceClientTextFrame", () => {
88
88
  },
89
89
  );
90
90
 
91
+ test("parses the shared surface's controls, and a clear", () => {
92
+ const shareTargets = {
93
+ targets: [
94
+ {
95
+ id: "t1",
96
+ label: "root_Filters",
97
+ role: "AXButton",
98
+ section: "Toolbar",
99
+ x: 0.8,
100
+ y: 0.05,
101
+ width: 0.05,
102
+ height: 0.03,
103
+ },
104
+ ],
105
+ total: 3,
106
+ };
107
+ expect(
108
+ parseLiveVoiceClientTextFrame(
109
+ JSON.stringify({ type: "update_config", shareTargets }),
110
+ ),
111
+ ).toEqual({ ok: true, frame: { type: "update_config", shareTargets } });
112
+ expect(
113
+ parseLiveVoiceClientTextFrame(
114
+ JSON.stringify({ type: "update_config", shareTargets: null }),
115
+ ),
116
+ ).toEqual({
117
+ ok: true,
118
+ frame: { type: "update_config", shareTargets: null },
119
+ });
120
+ });
121
+
122
+ test("drops an off-shape control rather than refusing the snapshot", () => {
123
+ const result = parseLiveVoiceClientTextFrame(
124
+ JSON.stringify({
125
+ type: "update_config",
126
+ shareTargets: {
127
+ targets: [
128
+ {
129
+ id: "t1",
130
+ label: "Send",
131
+ role: "AXButton",
132
+ x: 2,
133
+ y: 0,
134
+ width: 0.1,
135
+ height: 0.1,
136
+ },
137
+ {
138
+ id: "t2",
139
+ label: "Save",
140
+ role: "AXButton",
141
+ x: 0.1,
142
+ y: 0.1,
143
+ width: 0.1,
144
+ height: 0.1,
145
+ },
146
+ ],
147
+ total: 2,
148
+ },
149
+ }),
150
+ );
151
+ expect(result.ok).toBe(true);
152
+ if (!result.ok || result.frame.type !== "update_config") {
153
+ return;
154
+ }
155
+ expect(result.frame.shareTargets?.targets.map((t) => t.label)).toEqual([
156
+ "Save",
157
+ ]);
158
+ });
159
+
160
+ test("rejects a snapshot with no targets array", () => {
161
+ expect(
162
+ parseLiveVoiceClientTextFrame(
163
+ JSON.stringify({ type: "update_config", shareTargets: { total: 1 } }),
164
+ ).ok,
165
+ ).toBe(false);
166
+ });
167
+
91
168
  test("rejects malformed screen sharing state", () => {
92
169
  expect(
93
170
  parseLiveVoiceClientTextFrame(