@vellumai/assistant 0.12.2 → 0.12.3-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -0,0 +1,15 @@
1
+ import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
2
+ import { desktopAutomationLease } from "./desktop-automation-lease.js";
3
+ import { isVirtualDesktopEnabled } from "./virtual-desktop-feature.js";
4
+
5
+ export async function prepareDesktopHelp(
6
+ context: ToolContext,
7
+ ): Promise<ToolExecutionResult | ((resume: boolean) => Promise<void>)> {
8
+ if (!isVirtualDesktopEnabled()) {
9
+ return {
10
+ content: "Virtual desktop help is unavailable on this assistant.",
11
+ isError: true,
12
+ };
13
+ }
14
+ return desktopAutomationLease.reserveForHuman(context);
15
+ }
@@ -1,8 +1,10 @@
1
+ import * as fs from "node:fs";
1
2
  import { mkdtempSync, rmSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
- import { afterAll, describe, expect, test } from "bun:test";
5
+ import { afterAll, describe, expect, mock, spyOn, test } from "bun:test";
5
6
 
7
+ import { setOverridesForTesting } from "../__tests__/feature-flag-test-helpers.js";
6
8
  import { newFakeDesktop, newViewer, settle } from "./__tests__/fake-desktop.js";
7
9
  import { DesktopDependencyInstaller } from "./desktop-dependencies.js";
8
10
  import {
@@ -13,6 +15,7 @@ import {
13
15
  getDesktopSessionManager,
14
16
  } from "./desktop-session-manager.js";
15
17
  import { DesktopStreamBridge } from "./desktop-stream-bridge.js";
18
+ import { isVirtualDesktopEnabled } from "./virtual-desktop-feature.js";
16
19
 
17
20
  const profileDir = mkdtempSync(join(tmpdir(), "desktop-bridge-test-"));
18
21
  afterAll(() => {
@@ -87,6 +90,63 @@ function slotIsFree(manager: DesktopSessionManager): boolean {
87
90
  }
88
91
 
89
92
  describe("DesktopStreamBridge", () => {
93
+ test("streams without flag or filesystem checks after connecting", async () => {
94
+ const originalContainerized = process.env.IS_CONTAINERIZED;
95
+ const originalPlatform = process.env.IS_PLATFORM;
96
+ const h = newFakeDesktop({ profileDir });
97
+ const isEnabled = mock(isVirtualDesktopEnabled);
98
+ const b = newBridge(h.manager, async () => {}, isEnabled);
99
+ process.env.IS_CONTAINERIZED = "true";
100
+ process.env.IS_PLATFORM = "true";
101
+ setOverridesForTesting({ "assistant-desktop": true });
102
+ try {
103
+ b.connectNow();
104
+ await b.bridge.start();
105
+ expect(isEnabled).toHaveBeenCalled();
106
+ isEnabled.mockClear();
107
+ const stat = spyOn(fs, "statSync");
108
+ const read = spyOn(fs, "readFileSync");
109
+ try {
110
+ const frame = new Uint8Array([1, 2, 3]);
111
+ for (let i = 0; i < 100; i++) {
112
+ b.bridge.handleClientFrame(frame);
113
+ b.tcp.handlers.onData(frame);
114
+ b.tcp.handlers.onDrain();
115
+ }
116
+ expect(b.tcp.writes).toHaveLength(100);
117
+ expect(b.ws.sent).toHaveLength(100);
118
+ setOverridesForTesting({ "assistant-desktop": false });
119
+ b.bridge.handleClientFrame(frame);
120
+ b.tcp.handlers.onData(frame);
121
+ b.tcp.handlers.onDrain();
122
+ expect(b.tcp.writes).toHaveLength(101);
123
+ expect(b.ws.sent).toHaveLength(101);
124
+ expect(b.ws.closeCode).toBeNull();
125
+ expect(b.tcp.ended).toBe(false);
126
+ expect(isEnabled).not.toHaveBeenCalled();
127
+ expect(stat).not.toHaveBeenCalled();
128
+ expect(read).not.toHaveBeenCalled();
129
+ } finally {
130
+ stat.mockRestore();
131
+ read.mockRestore();
132
+ }
133
+ } finally {
134
+ b.bridge.handleClose();
135
+ await h.manager.destroy();
136
+ setOverridesForTesting({});
137
+ if (originalContainerized === undefined) {
138
+ delete process.env.IS_CONTAINERIZED;
139
+ } else {
140
+ process.env.IS_CONTAINERIZED = originalContainerized;
141
+ }
142
+ if (originalPlatform === undefined) {
143
+ delete process.env.IS_PLATFORM;
144
+ } else {
145
+ process.env.IS_PLATFORM = originalPlatform;
146
+ }
147
+ }
148
+ });
149
+
90
150
  test("refuses a disabled desktop before installation or process startup", async () => {
91
151
  const h = newFakeDesktop({ profileDir });
92
152
  let installs = 0;
@@ -124,24 +184,19 @@ describe("DesktopStreamBridge", () => {
124
184
  });
125
185
 
126
186
  for (const direction of ["viewer", "desktop"] as const) {
127
- test(`blocks ${direction} frames when the flag is disabled after connection`, async () => {
187
+ test(`ignores ${direction} frames after the stream closes`, async () => {
128
188
  const h = newFakeDesktop({ profileDir });
129
- let enabled = true;
130
- const b = newBridge(
131
- h.manager,
132
- async () => {},
133
- () => enabled,
134
- );
189
+ const b = newBridge(h.manager);
135
190
  b.connectNow();
136
191
  await b.bridge.start();
137
- enabled = false;
192
+ b.bridge.handleClose();
138
193
  const frame = new Uint8Array([1, 2, 3]);
139
194
  if (direction === "viewer") {
140
195
  b.bridge.handleClientFrame(frame);
141
196
  } else {
142
197
  b.tcp.handlers.onData(frame);
143
198
  }
144
- expect(b.ws.closeCode).toBe(4008);
199
+ expect(b.ws.closeCode).toBeNull();
145
200
  expect(b.ws.sent).toHaveLength(0);
146
201
  expect(b.tcp.writes).toHaveLength(0);
147
202
  expect(b.tcp.ended).toBe(true);
@@ -102,7 +102,7 @@ export class DesktopStreamBridge {
102
102
  try {
103
103
  tcp = await this.connect(DESKTOP_VNC_PORT, {
104
104
  onData: (data) => {
105
- if (!this.checkEnabled()) {
105
+ if (this.closed) {
106
106
  return;
107
107
  }
108
108
  if (this.ws.send(data) === 0) {
@@ -128,7 +128,7 @@ export class DesktopStreamBridge {
128
128
  }
129
129
 
130
130
  handleClientFrame(message: string | Uint8Array | ArrayBuffer): void {
131
- if (!this.checkEnabled() || typeof message === "string") {
131
+ if (this.closed || typeof message === "string") {
132
132
  return;
133
133
  }
134
134
  const bytes =
@@ -148,7 +148,7 @@ export class DesktopStreamBridge {
148
148
  }
149
149
 
150
150
  private flush(): void {
151
- if (!this.checkEnabled()) {
151
+ if (this.closed) {
152
152
  return;
153
153
  }
154
154
  const tcp = this.tcp;
@@ -0,0 +1,132 @@
1
+ import { readAvatarState } from "../avatar/avatar-manifest.js";
2
+ import { readContainedAvatarRaster } from "../avatar/ensure-raster.js";
3
+ import {
4
+ renderNotificationAvatarPng,
5
+ resolveNotificationAccentHex,
6
+ } from "../avatar/notification-avatar.js";
7
+ import { renderCharacterPng } from "../avatar/png-renderer.js";
8
+ import { getResvg, isResvgAvailable } from "../avatar/resvg-lazy.js";
9
+ import { isTemplatePlaceholder } from "../daemon/handlers/identity.js";
10
+ import { getAssistantName } from "../daemon/identity-helpers.js";
11
+ import { getLogger } from "../util/logger.js";
12
+ import { getAvatarImagePath } from "../util/platform.js";
13
+ import { escapeXmlContent } from "../util/xml.js";
14
+
15
+ function wallpaperWordmark(
16
+ width: number,
17
+ height: number,
18
+ name: string | null,
19
+ ): string {
20
+ const normalized = name?.replace(/\s+/g, " ").trim();
21
+ const label =
22
+ normalized && !isTemplatePlaceholder(normalized) ? normalized : "Vellum";
23
+ const text = `<text id="wordmark" font-family="Liberation Sans, Arial, sans-serif" font-size="64" font-weight="700" letter-spacing="1.5">${escapeXmlContent(`${label} OS`)}</text>`;
24
+ const Resvg = getResvg();
25
+ const bounds = new Resvg(
26
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${text}</svg>`,
27
+ ).getBBox();
28
+ if (!bounds || bounds.width === 0) {
29
+ return "";
30
+ }
31
+ const depth = 6;
32
+ const scale = Math.min(
33
+ (height * 0.06) / 64,
34
+ (width * 0.72) / (bounds.width + depth),
35
+ );
36
+ const x = width / 2 - (bounds.x + (bounds.width + depth) / 2) * scale;
37
+ const layers = Array.from({ length: depth }, (_, index) => {
38
+ const offset = depth - index;
39
+ return `<use href="#wordmark" transform="translate(${offset} ${offset})" fill="#40535b"/>`;
40
+ }).join("");
41
+ return `<defs>
42
+ ${text}
43
+ <linearGradient id="letter-face" x2="0" y2="1">
44
+ <stop stop-color="#ffffff"/><stop offset=".48" stop-color="#e0e9e7"/>
45
+ <stop offset="1" stop-color="#9eb4ba"/>
46
+ </linearGradient>
47
+ </defs>
48
+ <g transform="translate(${x} ${height * 0.69}) scale(${scale})">
49
+ <use href="#wordmark" transform="translate(9 12)" fill="#080f14" opacity=".35"/>
50
+ ${layers}
51
+ <use href="#wordmark" fill="url(#letter-face)" stroke="#e8f1ed" stroke-opacity=".4" stroke-width=".5"/>
52
+ </g>`;
53
+ }
54
+
55
+ export function renderDesktopWallpaper(
56
+ width: number,
57
+ height: number,
58
+ avatar: Buffer | null,
59
+ accentHex: string | null,
60
+ assistantName: string | null = null,
61
+ ): Buffer {
62
+ const accent = /^#[\da-f]{6}$/i.test(accentHex ?? "")
63
+ ? accentHex!
64
+ : "#91a6b0";
65
+ const cx = width / 2;
66
+ const cy = height * 0.46;
67
+ const size = Math.min(width, height) * 0.24;
68
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
69
+ <defs>
70
+ <linearGradient id="base" x2="1" y2="1">
71
+ <stop stop-color="#202a30"/><stop offset="1" stop-color="#10171c"/>
72
+ </linearGradient>
73
+ <radialGradient id="glow">
74
+ <stop stop-color="${accent}" stop-opacity=".24"/>
75
+ <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
76
+ </radialGradient>
77
+ <radialGradient id="shadow">
78
+ <stop stop-color="#000" stop-opacity=".25"/>
79
+ <stop offset="1" stop-color="#000" stop-opacity="0"/>
80
+ </radialGradient>
81
+ </defs>
82
+ <path fill="url(#base)" d="M0 0H${width}V${height}H0z"/>
83
+ <ellipse cx="${cx}" cy="${cy}" rx="${height * 0.7}" ry="${height * 0.6}" fill="url(#glow)"/>
84
+ <g fill="none" stroke="${accent}">
85
+ <circle cx="${cx}" cy="${cy}" r="${size * 0.76}" stroke-opacity=".16"/>
86
+ <circle cx="${cx}" cy="${cy}" r="${size * 1.25}" stroke-opacity=".09"/>
87
+ <circle cx="${cx}" cy="${cy}" r="${size * 1.9}" stroke-opacity=".05"/>
88
+ </g>
89
+ ${
90
+ avatar
91
+ ? `<ellipse cx="${cx}" cy="${cy + size * 0.48}" rx="${size * 0.7}" ry="${size * 0.16}" fill="url(#shadow)"/>
92
+ <image x="${cx - size / 2}" y="${cy - size / 2}" width="${size}" height="${size}" href="data:image/png;base64,${avatar.toString("base64")}"/>`
93
+ : ""
94
+ }
95
+ ${wallpaperWordmark(width, height, assistantName)}
96
+ </svg>`;
97
+ const Resvg = getResvg();
98
+ return Buffer.from(new Resvg(svg).render().asPng());
99
+ }
100
+
101
+ export async function renderCurrentDesktopWallpaper(
102
+ width: number,
103
+ height: number,
104
+ ): Promise<Buffer | null> {
105
+ if (!isResvgAvailable()) {
106
+ return null;
107
+ }
108
+ const state = readAvatarState();
109
+ const name = getAssistantName();
110
+ let raster = readContainedAvatarRaster(getAvatarImagePath());
111
+ if (!raster && state.kind === "character" && state.traits) {
112
+ try {
113
+ raster = renderCharacterPng(
114
+ state.traits.bodyShape,
115
+ state.traits.eyeStyle,
116
+ state.traits.color,
117
+ );
118
+ } catch (err) {
119
+ getLogger("desktop-wallpaper").warn(
120
+ { err },
121
+ "Could not render wallpaper avatar",
122
+ );
123
+ }
124
+ }
125
+ return renderDesktopWallpaper(
126
+ width,
127
+ height,
128
+ raster ? await renderNotificationAvatarPng(state, raster) : null,
129
+ resolveNotificationAccentHex(state),
130
+ name,
131
+ );
132
+ }
@@ -0,0 +1,17 @@
1
+ import { writeFile } from "node:fs/promises";
2
+
3
+ import { renderCurrentDesktopWallpaper } from "./desktop-wallpaper-renderer.js";
4
+
5
+ const [width, height, output] = process.argv.slice(2);
6
+ if (
7
+ !output ||
8
+ !Number.isFinite(Number(width)) ||
9
+ !Number.isFinite(Number(height))
10
+ ) {
11
+ throw new Error("Wallpaper dimensions and output path are required");
12
+ }
13
+ const png = await renderCurrentDesktopWallpaper(Number(width), Number(height));
14
+ if (png) {
15
+ await writeFile(output, png);
16
+ }
17
+ process.exit(0);
@@ -1,18 +1,36 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
2
9
  import { tmpdir } from "node:os";
3
10
  import { join } from "node:path";
4
- import { afterEach, beforeEach, expect, test } from "bun:test";
11
+ import {
12
+ afterEach,
13
+ beforeEach,
14
+ expect,
15
+ mock,
16
+ setDefaultTimeout,
17
+ test,
18
+ } from "bun:test";
5
19
 
6
20
  import sharp from "sharp";
7
21
 
8
22
  import {
9
23
  __resetResvgCacheForTests,
10
24
  __setResvgCacheForTests,
25
+ getResvg,
11
26
  } from "../avatar/resvg-lazy.js";
12
- import {
13
- renderCurrentDesktopWallpaper,
14
- renderDesktopWallpaper,
15
- } from "./desktop-wallpaper.js";
27
+ import { renderCurrentDesktopWallpaper } from "./desktop-wallpaper.js";
28
+ import { renderDesktopWallpaper } from "./desktop-wallpaper-renderer.js";
29
+
30
+ // Native resvg renders scan the system font database per instance, and on a
31
+ // loaded CI runner the file's first render runs past bun's 5s default. Each
32
+ // test file runs in its own process, so this doesn't leak.
33
+ setDefaultTimeout(15_000);
16
34
 
17
35
  let workspace: string;
18
36
  let previousWorkspace: string | undefined;
@@ -70,7 +88,7 @@ test("renders the current uploaded avatar into an opaque desktop wallpaper", asy
70
88
  expect((await sharp(result).stats()).isOpaque).toBe(true);
71
89
  });
72
90
 
73
- test("regenerates a character from current traits when its raster is missing", async () => {
91
+ test("renders a missing character raster without changing persisted avatar files", async () => {
74
92
  manifest({
75
93
  kind: "character",
76
94
  source: "pool",
@@ -78,9 +96,18 @@ test("regenerates a character from current traits when its raster is missing", a
78
96
  image: null,
79
97
  accent: null,
80
98
  });
99
+ const avatarDir = join(workspace, "data/avatar");
100
+ writeFileSync(join(avatarDir, "character-traits.json"), "preserve traits");
101
+ writeFileSync(join(avatarDir, "character-ascii.txt"), "preserve ASCII");
102
+ const snapshot = () =>
103
+ readdirSync(avatarDir)
104
+ .sort()
105
+ .map((file) => [file, readFileSync(join(avatarDir, file))]);
106
+ const before = snapshot();
81
107
  const result = (await renderCurrentDesktopWallpaper(480, 300))!;
82
108
  const empty = renderDesktopWallpaper(480, 300, null, null);
83
109
  expect(await centerPixel(result)).not.toEqual(await centerPixel(empty));
110
+ expect(snapshot()).toEqual(before);
84
111
  });
85
112
 
86
113
  test("missing and unreadable avatars produce the same neutral background", async () => {
@@ -189,3 +216,17 @@ test("names containing XML are rendered as text without injecting SVG shapes", a
189
216
  );
190
217
  expect((await wordmarkPixels(png)).length).toBeGreaterThan(0);
191
218
  });
219
+
220
+ test("renders in a child without invoking the assistant's native rasterizer", async () => {
221
+ const render = mock(() => {
222
+ throw new Error("Rendering must not run on the assistant event loop");
223
+ });
224
+ __setResvgCacheForTests({
225
+ available: true,
226
+ Resvg: render as unknown as ReturnType<typeof getResvg>,
227
+ });
228
+ const png = await renderCurrentDesktopWallpaper(1600, 900);
229
+ expect(render).not.toHaveBeenCalled();
230
+ const metadata = await sharp(png!).metadata();
231
+ expect([metadata.width, metadata.height]).toEqual([1600, 900]);
232
+ });
@@ -1,98 +1,13 @@
1
- import { readAvatarState } from "../avatar/avatar-manifest.js";
2
- import {
3
- renderNotificationAvatarPng,
4
- resolveNotificationAccentHex,
5
- } from "../avatar/notification-avatar.js";
6
- import { getResvg, isResvgAvailable } from "../avatar/resvg-lazy.js";
7
- import { isTemplatePlaceholder } from "../daemon/handlers/identity.js";
8
- import { getAssistantName } from "../daemon/identity-helpers.js";
9
- import { escapeXmlContent } from "../util/xml.js";
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
10
6
 
11
- function wallpaperWordmark(
12
- width: number,
13
- height: number,
14
- name: string | null,
15
- ): string {
16
- const normalized = name?.replace(/\s+/g, " ").trim();
17
- const label =
18
- normalized && !isTemplatePlaceholder(normalized) ? normalized : "Vellum";
19
- const text = `<text id="wordmark" font-family="Liberation Sans, Arial, sans-serif" font-size="64" font-weight="700" letter-spacing="1.5">${escapeXmlContent(`${label} OS`)}</text>`;
20
- const Resvg = getResvg();
21
- const bounds = new Resvg(
22
- `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${text}</svg>`,
23
- ).getBBox();
24
- if (!bounds || bounds.width === 0) {
25
- return "";
26
- }
27
- const depth = 6;
28
- const scale = Math.min(
29
- (height * 0.06) / 64,
30
- (width * 0.72) / (bounds.width + depth),
31
- );
32
- const x = width / 2 - (bounds.x + (bounds.width + depth) / 2) * scale;
33
- const layers = Array.from({ length: depth }, (_, index) => {
34
- const offset = depth - index;
35
- return `<use href="#wordmark" transform="translate(${offset} ${offset})" fill="#40535b"/>`;
36
- }).join("");
37
- return `<defs>
38
- ${text}
39
- <linearGradient id="letter-face" x2="0" y2="1">
40
- <stop stop-color="#ffffff"/><stop offset=".48" stop-color="#e0e9e7"/>
41
- <stop offset="1" stop-color="#9eb4ba"/>
42
- </linearGradient>
43
- </defs>
44
- <g transform="translate(${x} ${height * 0.69}) scale(${scale})">
45
- <use href="#wordmark" transform="translate(9 12)" fill="#080f14" opacity=".35"/>
46
- ${layers}
47
- <use href="#wordmark" fill="url(#letter-face)" stroke="#e8f1ed" stroke-opacity=".4" stroke-width=".5"/>
48
- </g>`;
49
- }
7
+ import { isResvgAvailable } from "../avatar/resvg-lazy.js";
8
+ import { resolveWorkerCommand } from "../util/worker-process.js";
50
9
 
51
- export function renderDesktopWallpaper(
52
- width: number,
53
- height: number,
54
- avatar: Buffer | null,
55
- accentHex: string | null,
56
- assistantName: string | null = null,
57
- ): Buffer {
58
- const accent = /^#[\da-f]{6}$/i.test(accentHex ?? "")
59
- ? accentHex!
60
- : "#91a6b0";
61
- const cx = width / 2;
62
- const cy = height * 0.46;
63
- const size = Math.min(width, height) * 0.24;
64
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
65
- <defs>
66
- <linearGradient id="base" x2="1" y2="1">
67
- <stop stop-color="#202a30"/><stop offset="1" stop-color="#10171c"/>
68
- </linearGradient>
69
- <radialGradient id="glow">
70
- <stop stop-color="${accent}" stop-opacity=".24"/>
71
- <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
72
- </radialGradient>
73
- <radialGradient id="shadow">
74
- <stop stop-color="#000" stop-opacity=".25"/>
75
- <stop offset="1" stop-color="#000" stop-opacity="0"/>
76
- </radialGradient>
77
- </defs>
78
- <path fill="url(#base)" d="M0 0H${width}V${height}H0z"/>
79
- <ellipse cx="${cx}" cy="${cy}" rx="${height * 0.7}" ry="${height * 0.6}" fill="url(#glow)"/>
80
- <g fill="none" stroke="${accent}">
81
- <circle cx="${cx}" cy="${cy}" r="${size * 0.76}" stroke-opacity=".16"/>
82
- <circle cx="${cx}" cy="${cy}" r="${size * 1.25}" stroke-opacity=".09"/>
83
- <circle cx="${cx}" cy="${cy}" r="${size * 1.9}" stroke-opacity=".05"/>
84
- </g>
85
- ${
86
- avatar
87
- ? `<ellipse cx="${cx}" cy="${cy + size * 0.48}" rx="${size * 0.7}" ry="${size * 0.16}" fill="url(#shadow)"/>
88
- <image x="${cx - size / 2}" y="${cy - size / 2}" width="${size}" height="${size}" href="data:image/png;base64,${avatar.toString("base64")}"/>`
89
- : ""
90
- }
91
- ${wallpaperWordmark(width, height, assistantName)}
92
- </svg>`;
93
- const Resvg = getResvg();
94
- return Buffer.from(new Resvg(svg).render().asPng());
95
- }
10
+ const runFile = promisify(execFile);
96
11
 
97
12
  export async function renderCurrentDesktopWallpaper(
98
13
  width: number,
@@ -101,13 +16,30 @@ export async function renderCurrentDesktopWallpaper(
101
16
  if (!isResvgAvailable()) {
102
17
  return null;
103
18
  }
104
- const state = readAvatarState();
105
- const name = getAssistantName();
106
- return renderDesktopWallpaper(
107
- width,
108
- height,
109
- await renderNotificationAvatarPng(state),
110
- resolveNotificationAccentHex(state),
111
- name,
112
- );
19
+ const directory = await mkdtemp(join(tmpdir(), "vellum-wallpaper-"));
20
+ const output = join(directory, "wallpaper.png");
21
+ try {
22
+ const [executable, ...args] = resolveWorkerCommand(
23
+ new URL("./desktop-wallpaper-worker.ts", import.meta.url),
24
+ undefined,
25
+ );
26
+ await runFile(
27
+ executable!,
28
+ [...args, String(width), String(height), output],
29
+ {
30
+ timeout: 30_000,
31
+ killSignal: "SIGKILL",
32
+ maxBuffer: 64 * 1024,
33
+ windowsHide: true,
34
+ },
35
+ );
36
+ return await readFile(output).catch((error: NodeJS.ErrnoException) => {
37
+ if (error.code === "ENOENT") {
38
+ return null;
39
+ }
40
+ throw error;
41
+ });
42
+ } finally {
43
+ await rm(directory, { recursive: true, force: true });
44
+ }
113
45
  }
@@ -6,7 +6,7 @@ const VIRTUAL_DESKTOP_FLAG = "assistant-desktop" as const;
6
6
 
7
7
  /** Gates desktop streaming and control to enabled, platform-hosted containers. */
8
8
  export function isVirtualDesktopEnabled(
9
- config: AssistantConfig,
9
+ config?: AssistantConfig,
10
10
  containerized: boolean = getIsContainerized(),
11
11
  platformHosted: boolean = getIsPlatform(),
12
12
  ): boolean {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * McpServerManager.start() applies the shared fair cap after every
3
+ * configured server has been attempted in parallel. A slow earlier
4
+ * server must not prevent later ones from being listed, and later
5
+ * servers must keep tools when the global cap binds.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ import type { ResolvedMcpConfig } from "../../config/schemas/mcp.js";
11
+
12
+ const toolsByServer = new Map<string, Array<{ name: string }>>();
13
+ const connectDelays = new Map<string, number>();
14
+
15
+ mock.module("../client.js", () => ({
16
+ McpClient: class {
17
+ constructor(readonly serverId: string) {}
18
+ get isConnected() {
19
+ return true;
20
+ }
21
+ get lastError() {
22
+ return null;
23
+ }
24
+ async connect() {
25
+ const delay = connectDelays.get(this.serverId) ?? 0;
26
+ if (delay > 0) {
27
+ await new Promise((resolve) => setTimeout(resolve, delay));
28
+ }
29
+ }
30
+ async listTools() {
31
+ return (toolsByServer.get(this.serverId) ?? []).map((tool) => ({
32
+ name: tool.name,
33
+ description: `${this.serverId} ${tool.name}`,
34
+ inputSchema: { type: "object", properties: {} },
35
+ }));
36
+ }
37
+ async disconnect() {}
38
+ },
39
+ }));
40
+
41
+ const { McpServerManager } = await import("../manager.js");
42
+
43
+ function httpServer(): ResolvedMcpConfig["servers"][string] {
44
+ return {
45
+ source: "workspace",
46
+ transport: {
47
+ type: "streamable-http",
48
+ url: "https://example.invalid/mcp",
49
+ },
50
+ };
51
+ }
52
+
53
+ function configWith(ids: string[]): ResolvedMcpConfig {
54
+ return {
55
+ servers: Object.fromEntries(ids.map((id) => [id, httpServer()])),
56
+ };
57
+ }
58
+
59
+ describe("McpServerManager tool selection", () => {
60
+ beforeEach(() => {
61
+ toolsByServer.clear();
62
+ connectDelays.clear();
63
+ });
64
+
65
+ test("later servers keep tools when eight servers exceed the global cap", async () => {
66
+ const ids = Array.from(
67
+ { length: 8 },
68
+ (_, i) => `server-${String(i + 1).padStart(2, "0")}`,
69
+ );
70
+ for (const id of ids) {
71
+ toolsByServer.set(
72
+ id,
73
+ Array.from({ length: 10 }, (_, i) => ({ name: `tool_${i}` })),
74
+ );
75
+ }
76
+
77
+ const manager = new McpServerManager();
78
+ const started = await manager.start(configWith(ids));
79
+
80
+ expect(started.discoveredToolCount).toBe(80);
81
+ expect(started.keptToolCount).toBe(50);
82
+ expect(started.truncatedServerIds.length).toBeGreaterThan(0);
83
+ expect(
84
+ started.servers.every((server) => server.tools.length > 0),
85
+ "Fair selection must not empty servers 6-8 merely because they were last.",
86
+ ).toBe(true);
87
+ await manager.stop();
88
+ });
89
+
90
+ test("a slow earlier server does not prevent later servers from connecting", async () => {
91
+ toolsByServer.set("slow", [{ name: "slow_tool" }]);
92
+ toolsByServer.set("fast", [{ name: "fast_tool" }]);
93
+ connectDelays.set("slow", 40);
94
+
95
+ const manager = new McpServerManager();
96
+ const startedAt = Date.now();
97
+ const started = await manager.start(configWith(["slow", "fast"]));
98
+ const elapsed = Date.now() - startedAt;
99
+
100
+ expect(started.connectedServerCount).toBe(2);
101
+ expect(started.servers.map((server) => server.serverId).sort()).toEqual([
102
+ "fast",
103
+ "slow",
104
+ ]);
105
+ expect(
106
+ elapsed < 80,
107
+ "Servers connect in parallel, so one 40ms delay should not serialize both.",
108
+ ).toBe(true);
109
+ await manager.stop();
110
+ });
111
+ });