@vantran-se/openchamber-web 1.22.1 → 1.23.0

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 (238) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +11 -1
  3. package/bin/cli.test.js +92 -0
  4. package/bin/lib/cli-startup.js +49 -0
  5. package/bin/lib/commands-startup.js +41 -6
  6. package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
  7. package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
  8. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
  9. package/dist/assets/FilesView-BdTOpew9.js +17 -0
  10. package/dist/assets/GitView-Dqf9vHJh.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
  12. package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
  13. package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
  14. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
  15. package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
  17. package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
  18. package/dist/assets/PlanView-BoJxdRZe.js +5 -0
  19. package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
  21. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  22. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
  23. package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
  25. package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
  26. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
  27. package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
  28. package/dist/assets/de-C8jV7J9y.js +10 -0
  29. package/dist/assets/es-BI6J1M5R.js +16 -0
  30. package/dist/assets/fr-D6oDFt7E.js +14 -0
  31. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  32. package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
  33. package/dist/assets/index-BqhiCmYS.js +39 -0
  34. package/dist/assets/index-FqSOBz0J.css +1 -0
  35. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
  36. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
  37. package/dist/assets/ja-C_4i5EQu.js +16 -0
  38. package/dist/assets/ko-BFbOTpbq.js +16 -0
  39. package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
  40. package/dist/assets/main-CJmb2dF8.js +930 -0
  41. package/dist/assets/markdownCore-DVuZpnli.js +5 -0
  42. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
  43. package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
  44. package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
  45. package/dist/assets/pl-COFvT56l.js +16 -0
  46. package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
  48. package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
  50. package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
  51. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
  52. package/dist/assets/surface-BvRzS58U.js +1 -0
  53. package/dist/assets/tr-B8Q0Go2l.js +7 -0
  54. package/dist/assets/uk-Dk4a-Fn-.js +16 -0
  55. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
  56. package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
  57. package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
  58. package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
  59. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
  61. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  62. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  63. package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
  64. package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
  65. package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
  66. package/dist/index.html +5 -43
  67. package/dist/mini-chat.html +4 -4
  68. package/dist/mobile.html +4 -4
  69. package/package.json +8 -5
  70. package/server/index.d.ts +15 -0
  71. package/server/index.js +82 -13
  72. package/server/lib/agent-tool/runtime.js +2 -19
  73. package/server/lib/changelog/update-notes.js +82 -0
  74. package/server/lib/changelog/update-notes.test.js +59 -0
  75. package/server/lib/data-dir-migration.js +40 -0
  76. package/server/lib/data-dir-migration.test.js +59 -0
  77. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  78. package/server/lib/dev-tunnel/runtime.js +4 -4
  79. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  80. package/server/lib/event-stream/runtime.test.js +12 -12
  81. package/server/lib/fs/DOCUMENTATION.md +5 -0
  82. package/server/lib/fs/routes.js +63 -21
  83. package/server/lib/fs/routes.test.js +187 -1
  84. package/server/lib/git/DOCUMENTATION.md +32 -6
  85. package/server/lib/git/routes.js +40 -4
  86. package/server/lib/git/service.js +291 -215
  87. package/server/lib/git/service.test.js +593 -25
  88. package/server/lib/inherited-env.js +16 -9
  89. package/server/lib/inherited-env.test.js +8 -9
  90. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  91. package/server/lib/mcp-reconnect/runtime.js +122 -0
  92. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  93. package/server/lib/message-queue/DOCUMENTATION.md +161 -0
  94. package/server/lib/message-queue/runtime.js +870 -0
  95. package/server/lib/message-queue/runtime.test.js +482 -0
  96. package/server/lib/network-defaults.js +23 -0
  97. package/server/lib/network-defaults.test.js +52 -0
  98. package/server/lib/openchamber-sessions/routes.js +102 -0
  99. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  100. package/server/lib/opencode/DOCUMENTATION.md +30 -6
  101. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  102. package/server/lib/opencode/config-paths.test.js +116 -0
  103. package/server/lib/opencode/core-routes.js +1 -0
  104. package/server/lib/opencode/feature-routes-runtime.js +7 -0
  105. package/server/lib/opencode/input-history-scope.js +14 -0
  106. package/server/lib/opencode/managed-plugin-config.js +35 -0
  107. package/server/lib/opencode/openchamber-routes.js +64 -4
  108. package/server/lib/opencode/openchamber-routes.test.js +130 -3
  109. package/server/lib/opencode/plugins.js +2 -2
  110. package/server/lib/opencode/providers.js +44 -2
  111. package/server/lib/opencode/providers.test.js +131 -4
  112. package/server/lib/opencode/routes.js +9 -7
  113. package/server/lib/opencode/server-startup-runtime.js +1 -1
  114. package/server/lib/opencode/settings-files.js +214 -0
  115. package/server/lib/opencode/settings-helpers.js +113 -41
  116. package/server/lib/opencode/settings-helpers.test.js +342 -0
  117. package/server/lib/opencode/settings-registry.json +747 -0
  118. package/server/lib/opencode/settings-runtime.js +141 -20
  119. package/server/lib/opencode/settings-runtime.test.js +191 -4
  120. package/server/lib/opencode/shared.js +4 -1
  121. package/server/lib/opencode/shutdown-runtime.js +2 -0
  122. package/server/lib/opencode/skill-routes.js +2 -1
  123. package/server/lib/opencode/snippets.js +1 -2
  124. package/server/lib/package-manager.js +4 -29
  125. package/server/lib/package-manager.test.js +2 -2
  126. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  127. package/server/lib/project-context/routes.http.test.js +18 -0
  128. package/server/lib/project-context/routes.js +15 -0
  129. package/server/lib/project-context/runtime.js +248 -23
  130. package/server/lib/project-context/runtime.test.js +106 -3
  131. package/server/lib/projects/DOCUMENTATION.md +76 -0
  132. package/server/lib/projects/project-config.js +142 -9
  133. package/server/lib/projects/project-id.js +17 -0
  134. package/server/lib/projects/project-setup.js +409 -0
  135. package/server/lib/projects/project-setup.test.js +557 -0
  136. package/server/lib/projects/routes.js +59 -0
  137. package/server/lib/quota/DOCUMENTATION.md +40 -2
  138. package/server/lib/quota/credentials/providers.js +4 -0
  139. package/server/lib/quota/credentials/store.js +1 -1
  140. package/server/lib/quota/credentials/store.test.js +4 -4
  141. package/server/lib/quota/index.js +1 -0
  142. package/server/lib/quota/providers/cline-pass.js +125 -0
  143. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  144. package/server/lib/quota/providers/exe-dev.js +75 -0
  145. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  146. package/server/lib/quota/providers/hyper.js +118 -0
  147. package/server/lib/quota/providers/hyper.test.js +126 -0
  148. package/server/lib/quota/providers/index.js +22 -0
  149. package/server/lib/quota/providers/neuralwatt.js +3 -6
  150. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  151. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  152. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  153. package/server/lib/quota/providers/opencode-go.js +2 -1
  154. package/server/lib/quota/providers/opencode-go.test.js +31 -2
  155. package/server/lib/quota/providers/openrouter.js +119 -24
  156. package/server/lib/quota/providers/openrouter.test.js +330 -0
  157. package/server/lib/quota/routes.js +2 -0
  158. package/server/lib/quota/utils/auth.js +1 -1
  159. package/server/lib/relay/DOCUMENTATION.md +56 -1
  160. package/server/lib/relay/cross-compat.test.js +26 -0
  161. package/server/lib/relay/downstream-scheduler.js +154 -0
  162. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  163. package/server/lib/relay/e2ee.js +10 -8
  164. package/server/lib/relay/flow-control.test.js +177 -0
  165. package/server/lib/relay/host-client.js +37 -8
  166. package/server/lib/relay/host-client.test.js +9 -1
  167. package/server/lib/relay/tunnel-codec.js +9 -0
  168. package/server/lib/relay/tunnel-host.js +69 -18
  169. package/server/lib/relay/tunnel-host.test.js +9 -1
  170. package/server/lib/security/request-security.js +21 -6
  171. package/server/lib/security/request-security.test.js +42 -8
  172. package/server/lib/session-assist/runtime.js +7 -10
  173. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  174. package/server/lib/session-goal/create.js +3 -1
  175. package/server/lib/session-goal/runtime.js +81 -20
  176. package/server/lib/session-goal/runtime.test.js +426 -0
  177. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  178. package/server/lib/skills-catalog/install.js +3 -2
  179. package/server/lib/small-model/DOCUMENTATION.md +7 -2
  180. package/server/lib/small-model/call.js +17 -6
  181. package/server/lib/small-model/call.test.js +52 -0
  182. package/server/lib/small-model/index.js +7 -11
  183. package/server/lib/small-model/routes.js +2 -1
  184. package/server/lib/small-model/runtime-providers.js +15 -7
  185. package/server/lib/small-model/runtime-providers.test.js +7 -1
  186. package/server/lib/system-prompt/runtime.js +2 -21
  187. package/server/lib/terminal/DOCUMENTATION.md +14 -10
  188. package/server/lib/terminal/runtime.js +190 -31
  189. package/server/lib/terminal/runtime.test.js +571 -37
  190. package/server/lib/terminal/shells.js +17 -0
  191. package/server/lib/terminal/shells.test.js +32 -1
  192. package/server/lib/ui-auth/ui-auth.js +4 -1
  193. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  194. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  195. package/server/lib/walkthrough/index.js +1 -0
  196. package/server/lib/walkthrough/model-settings.js +8 -12
  197. package/server/lib/walkthrough/prompt.js +3 -1
  198. package/server/lib/walkthrough/routes.test.js +26 -5
  199. package/server/lib/walkthrough/sources.js +23 -2
  200. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  201. package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
  202. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  203. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  204. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  205. package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
  206. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  207. package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
  208. package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
  209. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  210. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  211. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  212. package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
  213. package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
  214. package/dist/assets/de-DqVn5vme.js +0 -9
  215. package/dist/assets/es-hj43Z53i.js +0 -15
  216. package/dist/assets/fr-BmkZt2mF.js +0 -13
  217. package/dist/assets/index-L_ncV-6H.css +0 -1
  218. package/dist/assets/index-qES19dlJ.js +0 -7
  219. package/dist/assets/ja-DioMIEGF.js +0 -15
  220. package/dist/assets/ko-BqPgaQJb.js +0 -15
  221. package/dist/assets/main-CYgSu6dx.js +0 -928
  222. package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
  223. package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
  224. package/dist/assets/pl-slULBF4R.js +0 -15
  225. package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
  226. package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
  227. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  228. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  229. package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
  230. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  231. package/dist/assets/uk-BkkfxbOs.js +0 -15
  232. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  233. package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
  234. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  235. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  236. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
  237. package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
  238. package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
@@ -0,0 +1,482 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { createMessageQueueRuntime, parseQueuedItemInput } from './runtime.js';
6
+
7
+ const SESSION = 'ses_queue_test_1';
8
+ const DIRECTORY = '/repo';
9
+
10
+ const item = (overrides = {}) => ({
11
+ content: 'follow up',
12
+ text: 'follow up',
13
+ attachments: [],
14
+ sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
15
+ ...overrides,
16
+ });
17
+
18
+ const tempDirs = [];
19
+ const makeDataDir = () => {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-message-queue-'));
21
+ tempDirs.push(dir);
22
+ return dir;
23
+ };
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ /**
31
+ * A fake OpenCode: status map, message tail, command list, and a log of every
32
+ * prompt/command it received.
33
+ */
34
+ const createOpenCode = () => {
35
+ const state = {
36
+ statuses: {},
37
+ tail: [],
38
+ commands: [],
39
+ sent: [],
40
+ failNext: null,
41
+ };
42
+ const fetchImpl = vi.fn(async (url, init = {}) => {
43
+ const { pathname } = new URL(url);
44
+ const method = init.method ?? 'GET';
45
+ if (state.failNext && state.failNext.test(pathname)) {
46
+ state.failNext = null;
47
+ return new Response('boom', { status: 500 });
48
+ }
49
+ if (pathname === '/session/status') return Response.json(state.statuses);
50
+ if (pathname.endsWith('/message')) return Response.json(state.tail);
51
+ if (pathname === '/command') return Response.json(state.commands);
52
+ if (method === 'POST' && (pathname.endsWith('/prompt_async') || pathname.endsWith('/command'))) {
53
+ state.sent.push({ path: pathname, body: JSON.parse(init.body) });
54
+ return new Response(null, { status: 204 });
55
+ }
56
+ return new Response('not found', { status: 404 });
57
+ });
58
+ return { state, fetchImpl };
59
+ };
60
+
61
+ const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs } = {}) => {
62
+ let eventHandler = () => {};
63
+ let statusHandler = () => {};
64
+ const broadcasts = [];
65
+ const promptSent = [];
66
+ const options = {
67
+ globalEventHub: {
68
+ subscribeEvent(handler) { eventHandler = handler; return () => {}; },
69
+ subscribeStatus(handler) { statusHandler = handler; return () => {}; },
70
+ },
71
+ buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`,
72
+ getOpenCodeAuthHeaders: () => ({}),
73
+ sessionKnowledgeRuntime: knowledge,
74
+ broadcastGlobalUiEvent: (event) => broadcasts.push(event),
75
+ onPromptSent: (sessionId) => promptSent.push(sessionId),
76
+ dataDir,
77
+ fetchImpl: openCode.fetchImpl,
78
+ dispatchQuietMs: 0,
79
+ abortHoldMs: 50,
80
+ };
81
+ if (retryDelayMs) options.retryDelayMs = retryDelayMs;
82
+ const runtime = createMessageQueueRuntime(options);
83
+ return {
84
+ runtime,
85
+ openCode,
86
+ dataDir,
87
+ broadcasts,
88
+ promptSent,
89
+ emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }),
90
+ connect: () => statusHandler({ type: 'connect' }),
91
+ };
92
+ };
93
+
94
+ const settle = async (ms = 30) => {
95
+ await new Promise((resolve) => setTimeout(resolve, ms));
96
+ };
97
+
98
+ describe('parseQueuedItemInput', () => {
99
+ it('rejects an item the server could not deliver later', () => {
100
+ expect(() => parseQueuedItemInput({ content: 'x' })).toThrow(TypeError);
101
+ expect(() => parseQueuedItemInput(item({ content: '', text: '' }))).toThrow(TypeError);
102
+ expect(() => parseQueuedItemInput(item({ attachments: [{ filename: 'a.png' }] }))).toThrow(TypeError);
103
+ });
104
+
105
+ it('keeps delivery fields and trims blank edges of the content', () => {
106
+ const parsed = parseQueuedItemInput(item({ content: '\n\nhello\n', text: 'hello', agentMention: 'reviewer' }));
107
+ expect(parsed).toEqual({
108
+ content: 'hello',
109
+ text: 'hello',
110
+ agentMention: 'reviewer',
111
+ attachments: [],
112
+ context: [],
113
+ sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' },
114
+ });
115
+ });
116
+
117
+ it('keeps captured context and rejects a malformed part', () => {
118
+ const context = [
119
+ { kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } }, instructions: '' },
120
+ { kind: 'instruction', text: 'use the skill' },
121
+ { kind: 'synthetic', text: 'conflict payload' },
122
+ ];
123
+ expect(parseQueuedItemInput(item({ context })).context).toEqual([
124
+ { kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } } },
125
+ { kind: 'instruction', text: 'use the skill' },
126
+ { kind: 'synthetic', text: 'conflict payload' },
127
+ ]);
128
+ expect(() => parseQueuedItemInput(item({ context: [{ kind: 'context', text: 'no metadata' }] }))).toThrow(TypeError);
129
+ expect(() => parseQueuedItemInput(item({ context: [{ kind: 'other', text: 'x' }] }))).toThrow(TypeError);
130
+ });
131
+
132
+ it('accepts an item that is only context', () => {
133
+ const parsed = parseQueuedItemInput(item({ content: '', text: '', context: [{ kind: 'synthetic', text: 'just context' }] }));
134
+ expect(parsed.text).toBe('');
135
+ expect(parsed.context).toHaveLength(1);
136
+ });
137
+ });
138
+
139
+ describe('message queue runtime', () => {
140
+ it('delivers the head of the queue when the session goes idle, in order', async () => {
141
+ const { runtime, openCode, emit, promptSent, broadcasts } = createRuntime();
142
+ runtime.start();
143
+ openCode.state.statuses = { [SESSION]: { type: 'busy' } };
144
+
145
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'first', text: 'first' }));
146
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'second', text: 'second' }));
147
+ await settle();
148
+ expect(openCode.state.sent).toHaveLength(0);
149
+
150
+ openCode.state.statuses = {};
151
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
152
+ await settle();
153
+
154
+ expect(openCode.state.sent).toHaveLength(1);
155
+ expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
156
+ expect(openCode.state.sent[0].body).toEqual({
157
+ model: { providerID: 'anthropic', modelID: 'claude' },
158
+ agent: 'build',
159
+ parts: [{ type: 'text', text: 'first' }],
160
+ });
161
+ expect(promptSent).toEqual([SESSION]);
162
+ expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['second']);
163
+ // Clients learned about the in-flight item and then the removal.
164
+ expect(broadcasts.at(-1)).toMatchObject({
165
+ type: 'openchamber:message-queue.updated',
166
+ properties: { session: { sessionId: SESSION, sendingId: null } },
167
+ });
168
+
169
+ // The next turn: busy, then idle again — the second message goes out.
170
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'busy' } } });
171
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
172
+ await settle();
173
+ expect(openCode.state.sent).toHaveLength(2);
174
+ expect(runtime.sessionSnapshot(SESSION).items).toEqual([]);
175
+ });
176
+
177
+ it('does not send into a running turn even when the status event says idle', async () => {
178
+ const { runtime, openCode, emit } = createRuntime();
179
+ runtime.start();
180
+ openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1 } } }];
181
+ await runtime.enqueue(SESSION, DIRECTORY, item());
182
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
183
+ await settle();
184
+ expect(openCode.state.sent).toHaveLength(0);
185
+
186
+ // The reply completes: that alone drains the queue (a missed idle event
187
+ // must not strand it).
188
+ openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1, completed: 2 } } }];
189
+ emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, time: { created: 1, completed: 2 } } } });
190
+ await settle();
191
+ expect(openCode.state.sent).toHaveLength(1);
192
+ });
193
+
194
+ it('treats an unreachable OpenCode as unknown, not idle', async () => {
195
+ const { runtime, openCode, emit } = createRuntime({ retryDelayMs: () => 10 });
196
+ runtime.start();
197
+ await runtime.enqueue(SESSION, DIRECTORY, item());
198
+ openCode.state.failNext = /\/session\/status$/;
199
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
200
+ await settle(5);
201
+ expect(openCode.state.sent).toHaveLength(0);
202
+ // Retried after the status fetch recovers.
203
+ await settle(40);
204
+ expect(openCode.state.sent).toHaveLength(1);
205
+ });
206
+
207
+ it('keeps a failed item and retries with backoff', async () => {
208
+ const { runtime, openCode, emit, broadcasts } = createRuntime({ retryDelayMs: () => 20 });
209
+ runtime.start();
210
+ await runtime.enqueue(SESSION, DIRECTORY, item());
211
+ openCode.state.failNext = /prompt_async$/;
212
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
213
+ await settle(10);
214
+ expect(openCode.state.sent).toHaveLength(0);
215
+ expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
216
+ expect(runtime.sessionSnapshot(SESSION).sendingId).toBeNull();
217
+ expect(broadcasts.at(-1).properties.session.sendingId).toBeNull();
218
+ await settle(40);
219
+ expect(openCode.state.sent).toHaveLength(1);
220
+ expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
221
+ });
222
+
223
+ it('holds delivery briefly after a user abort', async () => {
224
+ const { runtime, openCode, emit } = createRuntime();
225
+ runtime.start();
226
+ await runtime.enqueue(SESSION, DIRECTORY, item());
227
+ emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, error: { name: 'MessageAbortedError' } } } });
228
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
229
+ await settle(10);
230
+ expect(openCode.state.sent).toHaveLength(0);
231
+ await settle(80);
232
+ expect(openCode.state.sent).toHaveLength(1);
233
+ });
234
+
235
+ it('honors a hold until it is released', async () => {
236
+ const { runtime, openCode, emit } = createRuntime();
237
+ runtime.start();
238
+ await runtime.enqueue(SESSION, DIRECTORY, item());
239
+ runtime.setHold(SESSION, true, 60_000);
240
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
241
+ await settle();
242
+ expect(openCode.state.sent).toHaveLength(0);
243
+
244
+ runtime.setHold(SESSION, false);
245
+ await settle();
246
+ expect(openCode.state.sent).toHaveLength(1);
247
+ });
248
+
249
+ it('survives a restart and delivers once OpenCode reconnects', async () => {
250
+ const dataDir = makeDataDir();
251
+ const first = createRuntime({ dataDir });
252
+ first.runtime.start();
253
+ first.openCode.state.statuses = { [SESSION]: { type: 'busy' } };
254
+ await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted' }));
255
+ await first.runtime.flush();
256
+ first.runtime.stop();
257
+
258
+ const second = createRuntime({ dataDir });
259
+ second.runtime.start();
260
+ await second.runtime.load();
261
+ expect(second.runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['persisted']);
262
+ second.connect();
263
+ await settle();
264
+ expect(second.openCode.state.sent).toHaveLength(1);
265
+ expect(second.openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'persisted' }]);
266
+ });
267
+
268
+ it('moves an unreadable queue file aside instead of treating it as empty', async () => {
269
+ const dataDir = makeDataDir();
270
+ fs.writeFileSync(path.join(dataDir, 'message-queue.json'), '{ not json');
271
+ const { runtime } = createRuntime({ dataDir });
272
+ await runtime.load();
273
+ expect(runtime.snapshot().sessions).toEqual([]);
274
+ expect(fs.readdirSync(dataDir).some((name) => name.startsWith('message-queue.json.corrupt-'))).toBe(true);
275
+ });
276
+
277
+ it('refuses to remove or take the item currently being sent', async () => {
278
+ const { runtime, openCode, emit } = createRuntime();
279
+ runtime.start();
280
+ let release;
281
+ // status map, message tail, then the prompt itself (held open until released)
282
+ openCode.fetchImpl.mockImplementationOnce(async () => Response.json({}))
283
+ .mockImplementationOnce(async () => Response.json([]))
284
+ .mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(new Response(null, { status: 204 })); }));
285
+ const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item());
286
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
287
+ await settle();
288
+ expect(runtime.sessionSnapshot(SESSION).sendingId).toBe(itemId);
289
+
290
+ await expect(runtime.remove(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
291
+ await expect(runtime.take(SESSION, itemId)).rejects.toMatchObject({ status: 409 });
292
+ const taken = await runtime.takeAll(SESSION);
293
+ expect(taken.items).toEqual([]);
294
+ expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
295
+
296
+ release();
297
+ await settle();
298
+ expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0);
299
+ });
300
+
301
+ it('take hands back the full payload and leaves the rest queued', async () => {
302
+ const { runtime } = createRuntime();
303
+ runtime.start();
304
+ const attachment = { id: 'a1', filename: 'shot.png', mimeType: 'image/png', size: 3, source: 'local', dataUrl: 'data:image/png;base64,AAA=' };
305
+ const first = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'with image', attachments: [attachment] }));
306
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'plain' }));
307
+
308
+ expect(runtime.sessionSnapshot(SESSION).items[0].attachments[0]).not.toHaveProperty('dataUrl');
309
+ const taken = await runtime.take(SESSION, first.itemId);
310
+ expect(taken.item.attachments[0].dataUrl).toBe(attachment.dataUrl);
311
+ expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['plain']);
312
+
313
+ const all = await runtime.takeAll(SESSION);
314
+ expect(all.items.map((entry) => entry.content)).toEqual(['plain']);
315
+ expect(runtime.snapshot().sessions).toEqual([]);
316
+ });
317
+
318
+ it('names the directory in the broadcast that empties a queue', async () => {
319
+ // The UI keys its projection by directory; without it the client cannot
320
+ // tell which queue just delivered its last message and keeps showing it.
321
+ const { runtime, emit, broadcasts, openCode } = createRuntime();
322
+ runtime.start();
323
+ await runtime.enqueue(SESSION, DIRECTORY, item());
324
+ openCode.state.statuses = {};
325
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
326
+ await settle();
327
+
328
+ expect(openCode.state.sent).toHaveLength(1);
329
+ expect(runtime.snapshot().sessions).toEqual([]);
330
+ expect(broadcasts.at(-1).properties.session).toEqual({ sessionId: SESSION, directory: DIRECTORY, items: [], sendingId: null });
331
+ });
332
+
333
+ it('reorders only with a complete permutation', async () => {
334
+ const { runtime } = createRuntime();
335
+ runtime.start();
336
+ const a = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'a' }));
337
+ const b = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'b' }));
338
+ await expect(runtime.reorder(SESSION, [b.itemId])).rejects.toThrow(TypeError);
339
+ await runtime.reorder(SESSION, [b.itemId, a.itemId]);
340
+ expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['b', 'a']);
341
+ });
342
+
343
+ it('drops the queue of a deleted session', async () => {
344
+ const { runtime, emit, broadcasts } = createRuntime();
345
+ runtime.start();
346
+ await runtime.enqueue(SESSION, DIRECTORY, item());
347
+ emit({ type: 'session.deleted', properties: { info: { id: SESSION } } });
348
+ expect(runtime.snapshot().sessions).toEqual([]);
349
+ expect(broadcasts.at(-1).properties.session).toMatchObject({ sessionId: SESSION, items: [] });
350
+ });
351
+
352
+ it('dispatches a queued slash command through the command endpoint', async () => {
353
+ const { runtime, openCode, emit } = createRuntime();
354
+ runtime.start();
355
+ openCode.state.commands = [{ name: 'review' }];
356
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: '/review src', text: '/review src', sendConfig: { providerID: 'p', modelID: 'm', agent: 'build', variant: 'max' } }));
357
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
358
+ await settle();
359
+ expect(openCode.state.sent).toHaveLength(1);
360
+ expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
361
+ expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' });
362
+ });
363
+
364
+ it('delivers captured context as synthetic parts, instructions first, before project knowledge', async () => {
365
+ const knowledge = {
366
+ resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
367
+ recordDelivered: async () => {},
368
+ };
369
+ const { runtime, openCode, emit } = createRuntime({ knowledge });
370
+ runtime.start();
371
+ const metadata = { openchamberContext: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } };
372
+ await runtime.enqueue(SESSION, DIRECTORY, item({
373
+ agentMention: 'reviewer',
374
+ attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }],
375
+ context: [
376
+ { kind: 'context', text: 'the diff', metadata, instructions: 'how to read it' },
377
+ { kind: 'synthetic', text: 'conflict payload' },
378
+ { kind: 'instruction', text: 'use the skill' },
379
+ ],
380
+ }));
381
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
382
+ await settle();
383
+ expect(openCode.state.sent[0].body.parts).toEqual([
384
+ { type: 'text', text: 'follow up' },
385
+ { type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
386
+ { type: 'text', text: 'how to read it', synthetic: true },
387
+ { type: 'text', text: 'the diff', synthetic: true, metadata },
388
+ { type: 'text', text: 'conflict payload', synthetic: true },
389
+ { type: 'text', text: 'use the skill', synthetic: true },
390
+ { type: 'text', text: 'pinned notes', synthetic: true },
391
+ { type: 'agent', name: 'reviewer' },
392
+ ]);
393
+ });
394
+
395
+ it('keeps files on the command route, which is all that route accepts', async () => {
396
+ const { runtime, openCode, emit } = createRuntime();
397
+ runtime.start();
398
+ openCode.state.commands = [{ name: 'review' }];
399
+ await runtime.enqueue(SESSION, DIRECTORY, item({
400
+ content: '/review',
401
+ text: '/review',
402
+ attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }],
403
+ }));
404
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
405
+ await settle();
406
+ expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
407
+ expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' }]);
408
+ });
409
+
410
+ it('sends a command queued with context as its expanded prompt, context included', async () => {
411
+ // The command route rejects text parts, so a command with captured
412
+ // context takes the prompt route with the template expanded, exactly as
413
+ // the composer does.
414
+ const { runtime, openCode, emit } = createRuntime();
415
+ runtime.start();
416
+ openCode.state.commands = [{ name: 'review', source: 'command', template: 'Review $1 with focus on $2' }];
417
+ const metadata = { openchamberContext: { kind: 'chat-quote', quote: 'q', text: 'why?' } };
418
+ await runtime.enqueue(SESSION, DIRECTORY, item({
419
+ content: '/review src "error handling"',
420
+ text: '/review src "error handling"',
421
+ context: [{ kind: 'context', text: 'quoted', metadata }],
422
+ }));
423
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
424
+ await settle();
425
+ expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
426
+ expect(openCode.state.sent[0].body.parts).toEqual([
427
+ { type: 'text', text: 'Review src with focus on error handling' },
428
+ { type: 'text', text: 'quoted', synthetic: true, metadata },
429
+ ]);
430
+ });
431
+
432
+ it('sends a skill queued with context as an explicit invocation, context included', async () => {
433
+ const { runtime, openCode, emit } = createRuntime();
434
+ runtime.start();
435
+ openCode.state.commands = [{ name: 'grill', source: 'skill', template: 'skill body' }];
436
+ await runtime.enqueue(SESSION, DIRECTORY, item({
437
+ content: '/grill auth',
438
+ text: '/grill auth',
439
+ context: [{ kind: 'synthetic', text: 'focus on tests' }],
440
+ }));
441
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
442
+ await settle();
443
+ expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
444
+ expect(openCode.state.sent[0].body.parts).toEqual([
445
+ { type: 'text', text: '/grill auth' },
446
+ { type: 'text', text: 'focus on tests', synthetic: true },
447
+ { type: 'text', text: 'The user explicitly invoked the grill skill. Use the corresponding skill tool to handle this request.', synthetic: true },
448
+ ]);
449
+ });
450
+
451
+ it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => {
452
+ const { runtime, broadcasts } = createRuntime();
453
+ runtime.start();
454
+ const context = [{ kind: 'synthetic', text: 'a large diff' }];
455
+ const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item({ context }));
456
+ expect(runtime.sessionSnapshot(SESSION).items[0]).not.toHaveProperty('context');
457
+ expect(runtime.sessionSnapshot(SESSION).items[0].text).toBe('follow up');
458
+ expect(broadcasts.at(-1).properties.session.items[0]).not.toHaveProperty('context');
459
+ const taken = await runtime.take(SESSION, itemId);
460
+ expect(taken.item.context).toEqual(context);
461
+ });
462
+
463
+ it('attaches pending project knowledge and records its delivery', async () => {
464
+ const recorded = [];
465
+ const knowledge = {
466
+ resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
467
+ recordDelivered: async (sessionId, directory, signature) => { recorded.push({ sessionId, directory, signature }); },
468
+ };
469
+ const { runtime, openCode, emit } = createRuntime({ knowledge });
470
+ runtime.start();
471
+ await runtime.enqueue(SESSION, DIRECTORY, item({ agentMention: 'reviewer', attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }] }));
472
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
473
+ await settle();
474
+ expect(openCode.state.sent[0].body.parts).toEqual([
475
+ { type: 'text', text: 'follow up' },
476
+ { type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
477
+ { type: 'text', text: 'pinned notes', synthetic: true },
478
+ { type: 'agent', name: 'reviewer' },
479
+ ]);
480
+ expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]);
481
+ });
482
+ });
@@ -0,0 +1,23 @@
1
+ import net from 'node:net';
2
+
3
+ // Node caps each happy-eyeballs connect attempt at 250ms by default
4
+ // (autoSelectFamilyAttemptTimeout). TCP handshakes to provider endpoints that are
5
+ // geographically distant routinely take 300-1500ms, so fetch() from a Node process
6
+ // aborts every attempt (ETIMEDOUT) and surfaces "fetch failed" even though the host
7
+ // is reachable — e.g. the z.ai quota endpoint from an IPv4-only egress (#3399).
8
+ //
9
+ // Every Node process entrypoint that performs provider fetches raises the
10
+ // per-attempt cap. Family autoselection itself stays enabled, so the IPv6→IPv4
11
+ // fallback and ::1/localhost servers keep working; disabling it instead breaks
12
+ // local MCP servers. Runtimes without the setter (Bun's fetch path, older Node)
13
+ // are a no-op.
14
+ export const CONNECT_ATTEMPT_TIMEOUT_MS = 5_000;
15
+
16
+ export const applyConnectAttemptTimeout = (netModule = net) => {
17
+ try {
18
+ netModule.setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_TIMEOUT_MS);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ };
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import net from 'node:net';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ import { applyConnectAttemptTimeout, CONNECT_ATTEMPT_TIMEOUT_MS } from './network-defaults.js';
6
+
7
+ describe('applyConnectAttemptTimeout', () => {
8
+ it('initializes the server entrypoint in a fresh Node process', () => {
9
+ const serverUrl = new URL('../index.js', import.meta.url).href;
10
+ const result = spawnSync('node', ['--input-type=module', '--eval', `
11
+ import net from 'node:net';
12
+ import assert from 'node:assert/strict';
13
+ const family = net.getDefaultAutoSelectFamily();
14
+ net.setDefaultAutoSelectFamilyAttemptTimeout(250);
15
+ await import(${JSON.stringify(serverUrl)});
16
+ assert.equal(net.getDefaultAutoSelectFamilyAttemptTimeout(), 5000);
17
+ assert.equal(net.getDefaultAutoSelectFamily(), family);
18
+ process.exit(0);
19
+ `], { encoding: 'utf8', timeout: 15_000, windowsHide: true });
20
+ expect(result.status, result.stderr).toBe(0);
21
+ });
22
+
23
+ it('raises the per-attempt connect timeout on runtimes that expose the setter', () => {
24
+ const previous = net.getDefaultAutoSelectFamilyAttemptTimeout();
25
+ try {
26
+ expect(applyConnectAttemptTimeout()).toBe(true);
27
+ expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(CONNECT_ATTEMPT_TIMEOUT_MS);
28
+ } finally {
29
+ net.setDefaultAutoSelectFamilyAttemptTimeout(previous);
30
+ }
31
+ });
32
+
33
+ it('is a no-op on runtimes without the setter', () => {
34
+ expect(applyConnectAttemptTimeout({})).toBe(false);
35
+ });
36
+
37
+ it('survives a throwing setter', () => {
38
+ const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn(() => {
39
+ throw new Error('not supported');
40
+ });
41
+ expect(applyConnectAttemptTimeout({ setDefaultAutoSelectFamilyAttemptTimeout })).toBe(false);
42
+ });
43
+
44
+ it('leaves family autoselection itself untouched', () => {
45
+ const setDefaultAutoSelectFamily = vi.fn();
46
+ const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn();
47
+ applyConnectAttemptTimeout({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyAttemptTimeout });
48
+ expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledTimes(1);
49
+ expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledWith(CONNECT_ATTEMPT_TIMEOUT_MS);
50
+ expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled();
51
+ });
52
+ });