@vantran-se/openchamber-web 1.24.4 → 2.0.2

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 (367) hide show
  1. package/README.md +2 -8
  2. package/bin/lib/commands-connect-url.js +3 -8
  3. package/bin/lib/commands-tunnel.js +3 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-B82Oye3V.js} +1 -1
  5. package/dist/assets/DiffView-DJLmWn6Z.js +4 -0
  6. package/dist/assets/FilesView-DjksmZRo.js +17 -0
  7. package/dist/assets/{GitView-Byu2DY7s.js → GitView-DiUsUIgr.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-CYwWYLr3.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-CmctyRI4.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-DOHHcbgJ.js} +1 -1
  11. package/dist/assets/LinearIssuesView-DaqnylcV.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-HJNvWt7L.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-nDZRGg2I.js +12 -0
  14. package/dist/assets/OnboardingScreen-C2B5Bsof.js +1 -0
  15. package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-C4FLBjpt.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-C_1S9c9n.js} +1 -1
  17. package/dist/assets/PlanView-_eZvb-G5.js +5 -0
  18. package/dist/assets/PluginPane-sM8fcZIV.js +3 -0
  19. package/dist/assets/SettingsView-BRfr46hb.js +29 -0
  20. package/dist/assets/SettingsWindow-CcUDwQHg.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-CrN1ZGwl.js +1 -0
  22. package/dist/assets/ToolOutputDialog-Cij4jI09.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-BXzwQ-JY.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-DSWTLJNH.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-CpUUgY8H.js} +2 -2
  26. package/dist/assets/appThemeRegistry-B5tHTkR8.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-D7D8qV8p.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-cq9uQ30i.js} +1 -1
  29. package/dist/assets/de-CUO1-KqF.js +10 -0
  30. package/dist/assets/es-BlbC6sGJ.js +16 -0
  31. package/dist/assets/fr-D2nwe_0W.js +14 -0
  32. package/dist/assets/index-BQOoYIOu.js +1 -0
  33. package/dist/assets/index-CLcM8HKx.css +1 -0
  34. package/dist/assets/index-CvLgnuNI.js +41 -0
  35. package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D0obJ2p8.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-DB3_rjyW.js} +1 -1
  37. package/dist/assets/ja-CA7vnDsZ.js +16 -0
  38. package/dist/assets/ko-Chy6a9l-.js +16 -0
  39. package/dist/assets/main-B9JFK3vF.js +896 -0
  40. package/dist/assets/main-Bhiqnrhd.js +2 -0
  41. package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-tfqOFaBd.js} +4 -4
  42. package/dist/assets/miniChat-B0UOL5zA.js +2 -0
  43. package/dist/assets/mobile-BJppFIEo.js +2 -0
  44. package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-Db6KuwCP.js} +3 -3
  45. package/dist/assets/pl-CTR5A_1P.js +16 -0
  46. package/dist/assets/pt-BR-BE1O1KEx.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-Yz5sfoFh.js +2 -0
  48. package/dist/assets/renderMobileApp-QQ-yB8aY.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-DKDdzRzm.js +27 -0
  50. package/dist/assets/scheduledTasksApi-C5sCuNqf.js +1 -0
  51. package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-xURoo5t_.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-BZLGuYTU.js} +1 -1
  53. package/dist/assets/tr-Bh5vK6cW.js +7 -0
  54. package/dist/assets/uk-CLDkZFy_.js +16 -0
  55. package/dist/assets/useAppFontEffects-BxrtCnfR.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CeXuR-SR.js} +2 -2
  57. package/dist/assets/useRootScrollLock-DgWxezc1.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-DOuaQUZ0.js} +2 -2
  59. package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
  60. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
  61. package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
  62. package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
  63. package/dist/assets/{web-components-DxX8bZ1o.js → web-components-63H1h21j.js} +2 -2
  64. package/dist/assets/zh-CN-DODgcmYj.js +16 -0
  65. package/dist/assets/zh-TW-ByNfIwLA.js +16 -0
  66. package/dist/index.html +13 -7
  67. package/dist/mini-chat.html +5 -5
  68. package/dist/mobile.html +5 -5
  69. package/package.json +6 -5
  70. package/server/index.d.ts +1 -0
  71. package/server/index.js +246 -91
  72. package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
  73. package/server/lib/agent-tool/runtime.js +135 -54
  74. package/server/lib/agent-tool/runtime.test.js +155 -87
  75. package/server/lib/browser-control/DOCUMENTATION.md +37 -2
  76. package/server/lib/browser-control/provider.js +217 -0
  77. package/server/lib/browser-control/provider.test.js +251 -0
  78. package/server/lib/context-obligatory/runtime.js +61 -50
  79. package/server/lib/context-obligatory/runtime.test.js +62 -162
  80. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  81. package/server/lib/event-stream/delta-coalescer.js +0 -0
  82. package/server/lib/event-stream/delta-coalescer.test.js +68 -57
  83. package/server/lib/event-stream/directory-ws-bridge.js +1 -1
  84. package/server/lib/event-stream/global-hub.js +10 -1
  85. package/server/lib/event-stream/global-hub.test.js +27 -26
  86. package/server/lib/event-stream/index.js +6 -0
  87. package/server/lib/event-stream/protocol.js +26 -19
  88. package/server/lib/event-stream/protocol.test.js +18 -7
  89. package/server/lib/event-stream/rebind.test.js +5 -5
  90. package/server/lib/event-stream/runtime.test.js +17 -15
  91. package/server/lib/event-stream/translate-v2.js +313 -0
  92. package/server/lib/event-stream/translate-v2.test.js +209 -0
  93. package/server/lib/event-stream/upstream-reader.test.js +16 -16
  94. package/server/lib/fs/DOCUMENTATION.md +4 -2
  95. package/server/lib/fs/byte-range.js +35 -0
  96. package/server/lib/fs/byte-range.test.js +31 -0
  97. package/server/lib/fs/routes.js +47 -42
  98. package/server/lib/fs/routes.test.js +123 -18
  99. package/server/lib/git/service.js +12 -0
  100. package/server/lib/github/DOCUMENTATION.md +1 -1
  101. package/server/lib/github/pr-status.js +22 -2
  102. package/server/lib/github/pr-status.test.js +27 -14
  103. package/server/lib/guests/DOCUMENTATION.md +13 -0
  104. package/server/lib/guests/catalog.js +12 -0
  105. package/server/lib/guests/catalog.test.js +33 -0
  106. package/server/lib/guests/routes.js +10 -1
  107. package/server/lib/guests/service.js +192 -14
  108. package/server/lib/guests/service.test.js +201 -1
  109. package/server/lib/guests/surface.js +630 -0
  110. package/server/lib/guests/surface.test.js +486 -0
  111. package/server/lib/markdown-image-grants/routes.js +18 -5
  112. package/server/lib/markdown-image-grants/routes.test.js +9 -6
  113. package/server/lib/message-queue/DOCUMENTATION.md +33 -22
  114. package/server/lib/message-queue/runtime.js +155 -100
  115. package/server/lib/message-queue/runtime.test.js +168 -82
  116. package/server/lib/notifications/DOCUMENTATION.md +28 -0
  117. package/server/lib/notifications/control-stream.http.test.js +68 -0
  118. package/server/lib/notifications/emitter-runtime.js +15 -0
  119. package/server/lib/notifications/emitter-runtime.test.js +32 -0
  120. package/server/lib/notifications/push-runtime.js +5 -1
  121. package/server/lib/notifications/push-runtime.test.js +2 -2
  122. package/server/lib/notifications/routes.js +1 -1
  123. package/server/lib/notifications/runtime.js +115 -26
  124. package/server/lib/notifications/template-runtime.js +13 -15
  125. package/server/lib/notifications/template-runtime.test.js +16 -9
  126. package/server/lib/notifications/trigger-runtime.test.js +187 -0
  127. package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
  128. package/server/lib/openchamber-control/actions.js +3 -2
  129. package/server/lib/openchamber-control/error.js +1 -1
  130. package/server/lib/openchamber-control/file-open.js +59 -0
  131. package/server/lib/openchamber-control/file-open.test.js +57 -0
  132. package/server/lib/openchamber-control/routes.test.js +12 -0
  133. package/server/lib/openchamber-control/service.js +102 -49
  134. package/server/lib/openchamber-control/service.test.js +84 -44
  135. package/server/lib/openchamber-sessions/archive-store.js +224 -0
  136. package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
  137. package/server/lib/openchamber-sessions/opencode-client.js +22 -0
  138. package/server/lib/openchamber-sessions/routes.js +398 -328
  139. package/server/lib/openchamber-sessions/routes.test.js +777 -740
  140. package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
  141. package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
  142. package/server/lib/opencode/DOCUMENTATION.md +449 -29
  143. package/server/lib/opencode/agents.js +233 -327
  144. package/server/lib/opencode/auth.js +41 -44
  145. package/server/lib/opencode/auth.test.js +92 -0
  146. package/server/lib/opencode/cli-upgrade.d.ts +4 -0
  147. package/server/lib/opencode/cli-upgrade.js +18 -0
  148. package/server/lib/opencode/cli-upgrade.test.js +36 -0
  149. package/server/lib/opencode/commands.js +164 -145
  150. package/server/lib/opencode/compatibility.d.ts +16 -0
  151. package/server/lib/opencode/compatibility.js +88 -0
  152. package/server/lib/opencode/compatibility.test.js +62 -0
  153. package/server/lib/opencode/config-entity-routes.js +58 -29
  154. package/server/lib/opencode/config-entity-v2.test.js +290 -0
  155. package/server/lib/opencode/config-mutation-response.js +17 -7
  156. package/server/lib/opencode/config-mutation-response.test.js +4 -7
  157. package/server/lib/opencode/config-paths.test.js +2 -2
  158. package/server/lib/opencode/config-v2.d.ts +183 -0
  159. package/server/lib/opencode/config-v2.js +801 -0
  160. package/server/lib/opencode/config-v2.test.js +466 -0
  161. package/server/lib/opencode/credential-db.js +144 -0
  162. package/server/lib/opencode/credential-db.test.js +104 -0
  163. package/server/lib/opencode/env-runtime.js +48 -9
  164. package/server/lib/opencode/env-runtime.test.js +45 -0
  165. package/server/lib/opencode/feature-routes-runtime.js +15 -5
  166. package/server/lib/opencode/lifecycle-process.test.js +7 -4
  167. package/server/lib/opencode/lifecycle.js +85 -30
  168. package/server/lib/opencode/lifecycle.test.js +80 -8
  169. package/server/lib/opencode/login-shell-env.js +27 -0
  170. package/server/lib/opencode/managed-config-file.js +119 -0
  171. package/server/lib/opencode/managed-config-file.test.js +152 -0
  172. package/server/lib/opencode/managed-plugin-config.js +33 -13
  173. package/server/lib/opencode/mcp.js +43 -149
  174. package/server/lib/opencode/network-runtime.js +6 -7
  175. package/server/lib/opencode/openchamber-routes.test.js +2 -5
  176. package/server/lib/opencode/plugin-routes.js +3 -3
  177. package/server/lib/opencode/plugin-routes.test.js +6 -24
  178. package/server/lib/opencode/plugins.js +91 -40
  179. package/server/lib/opencode/plugins.test.js +28 -15
  180. package/server/lib/opencode/providers.js +63 -83
  181. package/server/lib/opencode/providers.test.js +128 -59
  182. package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
  183. package/server/lib/opencode/proxy.js +179 -129
  184. package/server/lib/opencode/pwa-manifest-routes.js +4 -2
  185. package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
  186. package/server/lib/opencode/response-envelope.js +14 -0
  187. package/server/lib/opencode/response-envelope.test.js +23 -0
  188. package/server/lib/opencode/routes-upgrade.test.js +73 -116
  189. package/server/lib/opencode/routes.js +121 -428
  190. package/server/lib/opencode/server-utils-runtime.js +10 -4
  191. package/server/lib/opencode/session-activity.js +78 -0
  192. package/server/lib/opencode/session-runtime.js +21 -10
  193. package/server/lib/opencode/session-runtime.test.js +9 -9
  194. package/server/lib/opencode/settings-helpers.js +9 -9
  195. package/server/lib/opencode/settings-helpers.test.js +7 -12
  196. package/server/lib/opencode/settings-registry.json +6 -8
  197. package/server/lib/opencode/settings-runtime.js +18 -1
  198. package/server/lib/opencode/settings-runtime.test.js +31 -2
  199. package/server/lib/opencode/shared.js +54 -22
  200. package/server/lib/opencode/shared.test.js +8 -4
  201. package/server/lib/opencode/shutdown-runtime.js +54 -7
  202. package/server/lib/opencode/shutdown-runtime.test.js +224 -30
  203. package/server/lib/opencode/skill-routes.js +23 -24
  204. package/server/lib/opencode/skills.js +7 -5
  205. package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
  206. package/server/lib/opencode/upgrade-capability.test.js +1 -1
  207. package/server/lib/opencode/v1-migration-topup.js +288 -0
  208. package/server/lib/opencode/v1-migration-topup.test.js +266 -0
  209. package/server/lib/opencode/v2-install.d.ts +8 -0
  210. package/server/lib/opencode/v2-install.js +158 -0
  211. package/server/lib/opencode/v2-install.test.js +124 -0
  212. package/server/lib/opencode/watcher.js +10 -3
  213. package/server/lib/opencode/watcher.test.js +17 -21
  214. package/server/lib/opencode/websearch-config.js +66 -0
  215. package/server/lib/package-manager.js +55 -68
  216. package/server/lib/package-manager.test.js +241 -75
  217. package/server/lib/permission-auto-accept/runtime.js +38 -22
  218. package/server/lib/permission-auto-accept/runtime.test.js +39 -17
  219. package/server/lib/quota/DOCUMENTATION.md +4 -0
  220. package/server/lib/quota/providers/xai.js +4 -4
  221. package/server/lib/relay/tunnel-host.js +4 -1
  222. package/server/lib/relay/tunnel-host.test.js +7 -0
  223. package/server/lib/routing/DOCUMENTATION.md +52 -21
  224. package/server/lib/routing/defaults.js +23 -6
  225. package/server/lib/routing/jev.js +17 -7
  226. package/server/lib/routing/routes.http.test.js +68 -33
  227. package/server/lib/routing/routes.js +65 -22
  228. package/server/lib/routing/runtime.js +145 -48
  229. package/server/lib/routing/runtime.test.js +79 -55
  230. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
  231. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
  232. package/server/lib/scheduled-tasks/runtime.js +81 -74
  233. package/server/lib/scheduled-tasks/runtime.test.js +53 -1
  234. package/server/lib/session-assist/DOCUMENTATION.md +8 -3
  235. package/server/lib/session-assist/context.js +122 -46
  236. package/server/lib/session-assist/context.test.js +117 -49
  237. package/server/lib/session-assist/runtime.js +52 -22
  238. package/server/lib/session-assist/runtime.test.js +145 -173
  239. package/server/lib/session-goal/DOCUMENTATION.md +23 -15
  240. package/server/lib/session-goal/create.js +9 -12
  241. package/server/lib/session-goal/create.test.js +27 -28
  242. package/server/lib/session-goal/runtime.js +164 -77
  243. package/server/lib/session-goal/runtime.test.js +271 -519
  244. package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
  245. package/server/lib/session-knowledge/runtime.js +59 -41
  246. package/server/lib/session-knowledge/runtime.test.js +90 -20
  247. package/server/lib/small-model/DOCUMENTATION.md +154 -208
  248. package/server/lib/small-model/client.js +113 -0
  249. package/server/lib/small-model/index.js +296 -158
  250. package/server/lib/small-model/index.test.js +423 -256
  251. package/server/lib/small-model/routes.js +1 -1
  252. package/server/lib/spaces/DOCUMENTATION.md +807 -0
  253. package/server/lib/spaces/code-in-bait.js +185 -0
  254. package/server/lib/spaces/code-in.js +641 -0
  255. package/server/lib/spaces/code-in.test.js +1048 -0
  256. package/server/lib/spaces/errors.js +8 -0
  257. package/server/lib/spaces/exec-http.js +94 -0
  258. package/server/lib/spaces/gatekeeper-channel.js +202 -0
  259. package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
  260. package/server/lib/spaces/gatekeeper-program.cjs +914 -0
  261. package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
  262. package/server/lib/spaces/hardening.js +457 -0
  263. package/server/lib/spaces/hardening.test.js +368 -0
  264. package/server/lib/spaces/host-git.js +127 -0
  265. package/server/lib/spaces/host-git.test.js +137 -0
  266. package/server/lib/spaces/labels.js +234 -0
  267. package/server/lib/spaces/labels.test.js +219 -0
  268. package/server/lib/spaces/layout.js +184 -0
  269. package/server/lib/spaces/layout.test.js +30 -0
  270. package/server/lib/spaces/manager.js +82 -0
  271. package/server/lib/spaces/manager.test.js +114 -0
  272. package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
  273. package/server/lib/spaces/places/contract-suite.js +156 -0
  274. package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
  275. package/server/lib/spaces/places/contract.test.js +6 -0
  276. package/server/lib/spaces/places/docker-engine.js +126 -0
  277. package/server/lib/spaces/places/docker-live-support.js +202 -0
  278. package/server/lib/spaces/places/docker-tools.js +186 -0
  279. package/server/lib/spaces/places/docker.js +587 -0
  280. package/server/lib/spaces/places/docker.test.js +1610 -0
  281. package/server/lib/spaces/places/escape-suite.js +1018 -0
  282. package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
  283. package/server/lib/spaces/places/fake-docker.js +375 -0
  284. package/server/lib/spaces/places/memory-place.js +103 -0
  285. package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
  286. package/server/lib/spaces/places/registry.js +43 -0
  287. package/server/lib/spaces/places/registry.test.js +50 -0
  288. package/server/lib/spaces/places/server.docker.live.test.js +225 -0
  289. package/server/lib/spaces/run-command.js +261 -0
  290. package/server/lib/spaces/run-command.test.js +396 -0
  291. package/server/lib/spaces/space-server.js +190 -0
  292. package/server/lib/spaces/space-server.test.js +335 -0
  293. package/server/lib/spaces/tools-filler.js +82 -0
  294. package/server/lib/spaces/tools-filler.test.js +148 -0
  295. package/server/lib/spaces/tools-pack.js +49 -0
  296. package/server/lib/spaces/tools-pack.test.js +57 -0
  297. package/server/lib/spaces/tools.js +138 -0
  298. package/server/lib/spaces/tools.test.js +153 -0
  299. package/server/lib/tts/openai-sdk.js +11 -0
  300. package/server/lib/tts/service.js +7 -5
  301. package/server/lib/tts/stt.js +3 -2
  302. package/server/lib/tunnels/DOCUMENTATION.md +2 -0
  303. package/server/lib/tunnels/routes.js +7 -0
  304. package/server/lib/tunnels/routes.test.js +55 -0
  305. package/server/lib/ui-auth/ui-auth.js +38 -2
  306. package/server/lib/ui-auth/ui-auth.test.js +42 -0
  307. package/server/lib/ui-auth/ui-passkeys.js +12 -10
  308. package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
  309. package/server/lib/walkthrough/pull-request.js +61 -8
  310. package/server/lib/walkthrough/pull-request.test.js +54 -1
  311. package/server/lib/walkthrough/routes.js +20 -0
  312. package/server/lib/walkthrough/routes.test.js +19 -0
  313. package/server/opencode-proxy.test.js +190 -166
  314. package/server/opencode-worktree-readiness.test.js +5 -4
  315. package/dist/assets/DiffView-B2CD9wC8.js +0 -4
  316. package/dist/assets/FilesView-C4vMFclc.js +0 -13
  317. package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
  320. package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
  321. package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
  322. package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
  323. package/dist/assets/SettingsView-CSinuH-1.js +0 -29
  324. package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
  326. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
  328. package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
  329. package/dist/assets/de-Bro6_zyl.js +0 -10
  330. package/dist/assets/es-Bb9XQZvd.js +0 -16
  331. package/dist/assets/fr-Dgfzn-IL.js +0 -14
  332. package/dist/assets/index-CtQNmqUL.js +0 -1
  333. package/dist/assets/index-DqaMWNrC.css +0 -1
  334. package/dist/assets/index-Dqc6USl3.js +0 -40
  335. package/dist/assets/ja-BYsakGFh.js +0 -16
  336. package/dist/assets/ko-BSaQz9aN.js +0 -16
  337. package/dist/assets/main-B62_pjL5.js +0 -2
  338. package/dist/assets/main-BEdcOI2P.js +0 -896
  339. package/dist/assets/miniChat-LT8Rqofr.js +0 -2
  340. package/dist/assets/mobile-CqarEynh.js +0 -2
  341. package/dist/assets/pl-OIviPdoj.js +0 -16
  342. package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
  344. package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
  346. package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
  347. package/dist/assets/tr-CQI-KPre.js +0 -7
  348. package/dist/assets/uk-CqW7R4DA.js +0 -16
  349. package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
  350. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
  351. package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
  352. package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
  353. package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
  354. package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
  355. package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
  356. package/server/lib/routing/feature-flag.js +0 -11
  357. package/server/lib/small-model/call.js +0 -851
  358. package/server/lib/small-model/call.test.js +0 -1292
  359. package/server/lib/small-model/catalog.js +0 -13
  360. package/server/lib/small-model/resolve.js +0 -131
  361. package/server/lib/small-model/resolve.test.js +0 -197
  362. package/server/lib/small-model/runtime-providers.js +0 -164
  363. package/server/lib/small-model/runtime-providers.test.js +0 -118
  364. package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
  365. package/server/lib/system-prompt/runtime.js +0 -49
  366. package/server/lib/system-prompt/runtime.test.js +0 -76
  367. package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
@@ -1,363 +1,530 @@
1
1
  import fs from 'fs';
2
+ import { registerSmallModelRoutes } from './routes.js';
3
+ import http from 'node:http';
2
4
  import os from 'os';
3
5
  import path from 'path';
4
- import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
5
7
 
6
- // The settings override is read straight from disk at module load, so without
7
- // this the suite would resolve whatever small model the developer running it
8
- // happens to have configured.
8
+ // The settings override is read straight from disk, so without this the suite
9
+ // would resolve whatever small model the developer running it has configured.
9
10
  const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'small-model-settings-'));
10
11
  process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
11
12
 
12
- vi.mock('../opencode/auth.js', () => ({ readAuthFile: vi.fn() }));
13
- vi.mock('../opencode/shared.js', () => ({
14
- readConfig: vi.fn(),
15
- readConfigLayers: vi.fn(),
16
- }));
17
- vi.mock('./catalog.js', () => ({
18
- getModelCatalog: vi.fn(),
19
- getCatalogProvider: vi.fn(),
20
- }));
21
- vi.mock('./call.js', () => ({
22
- DEDICATED_WIRE_FORMAT_PROVIDERS: new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']),
23
- callSmallModel: vi.fn(),
24
- resolveProviderLogin: vi.fn(async ({ auth, providerID }) => {
25
- const entry = auth?.[providerID];
26
- return entry && typeof entry === 'object' ? entry : null;
27
- }),
28
- }));
29
- vi.mock('./runtime-providers.js', () => ({
30
- getRuntimeProviderSnapshot: vi.fn(async () => null),
31
- }));
32
-
33
13
  const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
34
- const { readAuthFile } = await import('../opencode/auth.js');
35
- const { getRuntimeProviderSnapshot } = await import('./runtime-providers.js');
36
- const { readConfigLayers } = await import('../opencode/shared.js');
37
- const { getModelCatalog } = await import('./catalog.js');
38
- const { callSmallModel } = await import('./call.js');
39
-
40
- describe('unsupported small-model providers', () => {
41
- beforeEach(() => {
42
- readAuthFile.mockReturnValue({
43
- 'claude-code': {
44
- type: 'oauth',
45
- access: 'claude-cli-managed',
46
- refresh: 'claude-cli-managed',
47
- },
48
- });
49
- readConfigLayers.mockReturnValue({ mergedConfig: {} });
50
- getModelCatalog.mockResolvedValue({});
51
- callSmallModel.mockReset();
52
- getRuntimeProviderSnapshot.mockResolvedValue(null);
53
- });
54
-
55
- it('rejects Claude Code with an actionable error before transport dispatch', async () => {
56
- await expect(generateSmallModelText({
57
- prompt: 'summarize this',
58
- model: 'claude-code/haiku',
59
- })).rejects.toMatchObject({
60
- statusCode: 422,
61
- code: 'small-model-provider-unsupported',
62
- });
63
- expect(callSmallModel).not.toHaveBeenCalled();
64
- });
14
+ const { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } = await import('./client.js');
15
+
16
+ // A real OpenCode stub over HTTP. The module talks to OpenCode through
17
+ // `@opencode/client`, so driving it with a socket exercises the same request
18
+ // shapes the running server sees — no module substitution involved.
19
+ const state = {
20
+ models: [],
21
+ providers: [],
22
+ defaultModel: null,
23
+ generate: () => ({ text: 'generated' }),
24
+ requests: [],
25
+ generateErrors: [],
26
+ };
65
27
 
66
- it('does not offer Claude Code in the Small Model picker', async () => {
67
- expect(await listAuthenticatedProviders()).not.toContain('claude-code');
68
- });
28
+ const MODEL = (overrides = {}) => ({
29
+ id: 'claude-haiku-4-5',
30
+ modelID: 'claude-haiku-4-5',
31
+ providerID: 'anthropic',
32
+ name: 'Claude Haiku',
33
+ enabled: true,
34
+ limit: { context: 8_000, output: 4_000 },
35
+ ...overrides,
36
+ });
69
37
 
70
- // A plugin can publish an OpenAI-compatible endpoint for Claude Code, but it
71
- // is a façade over the Claude Agent SDK: every call spawns the CLI and
72
- // spends the user's Claude subscription. The refusal is about that cost, so
73
- // an available endpoint must not lift it.
74
- it('still refuses Claude Code when a plugin publishes an HTTP endpoint for it', async () => {
75
- getRuntimeProviderSnapshot.mockResolvedValue({
76
- providers: new Map([['claude-code', { id: 'claude-code', apiKey: 'plugin-key', baseURL: 'http://127.0.0.1:60668/v1', anonymousZen: false }]]),
77
- connected: new Set(['claude-code']),
78
- });
38
+ const LOCATION = { directory: '/proj', project: { id: 'p', directory: '/proj', canonical: '/proj' } };
79
39
 
80
- await expect(generateSmallModelText({
81
- prompt: 'summarize this',
82
- model: 'claude-code/haiku',
83
- })).rejects.toMatchObject({ code: 'small-model-provider-unsupported' });
84
- expect(await listAuthenticatedProviders()).not.toContain('claude-code');
40
+ let server;
41
+ let baseUrl;
85
42
 
86
- getRuntimeProviderSnapshot.mockResolvedValue(null);
43
+ const readBody = (req) => new Promise((resolve) => {
44
+ let raw = '';
45
+ req.on('data', (chunk) => { raw += chunk; });
46
+ req.on('end', () => {
47
+ try {
48
+ resolve(raw ? JSON.parse(raw) : {});
49
+ } catch {
50
+ resolve({});
51
+ }
87
52
  });
88
53
  });
89
54
 
90
- describe('provider availability for the model pickers', () => {
91
- beforeEach(() => {
92
- readAuthFile.mockReturnValue({ openai: { type: 'api', key: 'sk-test' } });
93
- readConfigLayers.mockReturnValue({ mergedConfig: {} });
94
- getModelCatalog.mockResolvedValue({});
95
- getRuntimeProviderSnapshot.mockResolvedValue(null);
96
- });
97
-
98
- const snapshot = (providers, connected) => ({
99
- providers: new Map(providers.map((provider) => [provider.id, provider])),
100
- connected: new Set(connected ?? providers.map((provider) => provider.id)),
55
+ beforeAll(async () => {
56
+ server = http.createServer(async (req, res) => {
57
+ const url = new URL(req.url, 'http://localhost');
58
+ const body = req.method === 'POST' ? await readBody(req) : {};
59
+ state.requests.push({ method: req.method, path: url.pathname, body, headers: req.headers });
60
+ const send = (payload) => {
61
+ res.writeHead(200, { 'content-type': 'application/json' });
62
+ res.end(JSON.stringify(payload));
63
+ };
64
+ if (url.pathname === '/api/model') return send({ location: LOCATION, data: state.models });
65
+ if (url.pathname === '/api/model/default') return send({ location: LOCATION, data: state.defaultModel });
66
+ if (url.pathname === '/api/provider') return send({ location: LOCATION, data: state.providers });
67
+ if (url.pathname === '/api/experimental/generate') {
68
+ const error = state.generateErrors.shift();
69
+ if (error) {
70
+ res.writeHead(400, { 'content-type': 'application/json' });
71
+ return res.end(JSON.stringify(error));
72
+ }
73
+ return send({ location: LOCATION, data: state.generate(body) });
74
+ }
75
+ res.writeHead(404).end('{}');
101
76
  });
77
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
78
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
79
+ });
102
80
 
103
- it('offers a plugin provider that OpenCode resolved at runtime', async () => {
104
- getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
105
- { id: 'llmapi', apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1', anonymousZen: false },
106
- ]));
81
+ afterAll(() => {
82
+ server?.close();
83
+ fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
84
+ });
107
85
 
108
- expect(await listAuthenticatedProviders()).toEqual(expect.arrayContaining(['openai', 'llmapi']));
86
+ beforeEach(() => {
87
+ state.models = [MODEL()];
88
+ state.providers = [{ id: 'anthropic', name: 'Anthropic', activation: 'auto', package: 'x' }];
89
+ state.defaultModel = MODEL();
90
+ state.generate = () => ({ text: 'generated' });
91
+ state.requests = [];
92
+ state.generateErrors = [];
93
+ configureOpenCodeRuntimeProviders({
94
+ buildOpenCodeUrl: (requestPath) => `${baseUrl}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`,
95
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
109
96
  });
97
+ resetOpenCodeRuntimeProviders();
98
+ });
110
99
 
111
- it('hides a provider with no endpoint to send a request to', async () => {
112
- getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
113
- { id: 'endpointless', apiKey: 'plugin-key', baseURL: null, anonymousZen: false },
114
- ]));
100
+ const lastGenerate = () => state.requests.filter((entry) => entry.path === '/api/experimental/generate').at(-1);
115
101
 
116
- expect(await listAuthenticatedProviders()).not.toContain('endpointless');
102
+ describe('generateSmallModelText', () => {
103
+ const unavailable = { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' };
104
+ const options = { prompt: 'write a commit', model: 'zai-coding-plan/glm-5.3-flash' };
105
+
106
+ it('retries a cold catalog once with the same model and prompt', async () => {
107
+ state.generateErrors = [unavailable];
108
+ const result = await generateSmallModelText(options);
109
+ expect(result.text).toBe('generated');
110
+ const calls = state.requests.filter((entry) => entry.path === '/api/experimental/generate');
111
+ expect(calls).toHaveLength(2);
112
+ expect(calls[0].body).toEqual(calls[1].body);
117
113
  });
118
114
 
119
- it('never offers opencode zen without a real login', async () => {
120
- // The zen sentinel is not a credential, so the snapshot carries no apiKey.
121
- getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
122
- { id: 'opencode', apiKey: null, baseURL: 'https://opencode.ai/zen/v1', anonymousZen: true },
123
- ]));
115
+ it('reports persistent model unavailability after one retry', async () => {
116
+ state.generateErrors = [unavailable, unavailable];
117
+ await expect(generateSmallModelText(options)).rejects.toMatchObject({
118
+ message: unavailable.message, statusCode: 503, code: 'small-model-unavailable',
119
+ });
120
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(2);
121
+ });
124
122
 
125
- expect(await listAuthenticatedProviders()).not.toContain('opencode');
123
+ it('does not retry other invalid requests', async () => {
124
+ state.generateErrors = [{ _tag: 'InvalidRequestError', message: 'Invalid prompt' }];
125
+ await expect(generateSmallModelText(options)).rejects.toMatchObject({ message: 'Invalid prompt' });
126
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
126
127
  });
127
128
 
128
- it('keeps the auth.json providers when OpenCode cannot be reached', async () => {
129
- getRuntimeProviderSnapshot.mockResolvedValue(null);
129
+ it('does not retry provider failures with the same message', async () => {
130
+ state.generateErrors = [{ _tag: 'ServiceUnavailableError', message: unavailable.message }];
131
+ await expect(generateSmallModelText(options)).rejects.toMatchObject({ _tag: 'ServiceUnavailableError' });
132
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
133
+ });
130
134
 
131
- expect(await listAuthenticatedProviders()).toContain('openai');
135
+ it('cancels without sending the retry', async () => {
136
+ const controller = new AbortController();
137
+ state.generateErrors = [unavailable];
138
+ const pending = generateSmallModelText({ ...options, signal: controller.signal });
139
+ const timer = setTimeout(() => controller.abort(), 100);
140
+ try {
141
+ await expect(pending).rejects.toThrow();
142
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
132
146
  });
133
- });
134
147
 
135
- // 8k context leaves 4k input tokens after the output reserve → 16k chars.
136
- const CATALOG = {
137
- anthropic: {
138
- id: 'anthropic',
139
- models: {
140
- 'claude-haiku-4-5': { id: 'claude-haiku-4-5', limit: { context: 8_000 }, structured_output: true },
141
- 'legacy-tiny': { id: 'legacy-tiny', limit: { context: 8_000 }, structured_output: false },
142
- 'unlisted-capability': { id: 'unlisted-capability', limit: { context: 8_000 } },
143
- },
144
- },
145
- };
148
+ it('honors the timeout during the retry delay', async () => {
149
+ state.generateErrors = [unavailable];
150
+ await expect(generateSmallModelText({ ...options, timeoutMs: 100 })).rejects.toThrow();
151
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
152
+ });
146
153
 
147
- const request = (overrides = {}) => ({
148
- prompt: 'x'.repeat(20_000),
149
- model: 'anthropic/claude-haiku-4-5',
150
- directory: '/proj',
151
- ...overrides,
152
- });
154
+ it('sends the prompt to /api/generate on the resolved model', async () => {
155
+ const result = await generateSmallModelText({ prompt: 'summarize this', directory: '/proj' });
153
156
 
154
- describe('generateSmallModelText — oversized input', () => {
155
- beforeEach(() => {
156
- readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } });
157
- readConfigLayers.mockReturnValue({ mergedConfig: {} });
158
- getModelCatalog.mockResolvedValue(CATALOG);
159
- callSmallModel.mockReset();
160
- callSmallModel.mockResolvedValue('generated');
157
+ // The fixture's only model is a haiku: the family scan finds it before
158
+ // OpenCode's default is even asked.
159
+ expect(result).toMatchObject({ text: 'generated', providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'small' });
160
+ expect(lastGenerate().body).toEqual({
161
+ prompt: 'summarize this',
162
+ model: { id: 'claude-haiku-4-5', providerID: 'anthropic' },
163
+ });
161
164
  });
162
165
 
163
- afterEach(() => {
164
- vi.clearAllMocks();
166
+ it('scopes the request to the directory', async () => {
167
+ await generateSmallModelText({ prompt: 'hi', directory: '/proj/sub dir' });
168
+
169
+ expect(lastGenerate().headers['x-opencode-directory']).toBe(encodeURIComponent('/proj/sub dir'));
165
170
  });
166
171
 
167
- it('truncates and flags the response by default', async () => {
168
- const result = await generateSmallModelText(request());
172
+ it('leads the prompt with the system instructions', async () => {
173
+ await generateSmallModelText({ prompt: 'the task', system: 'you are terse', directory: '/proj' });
169
174
 
170
- expect(result.inputTruncated).toBe(true);
171
- const sent = callSmallModel.mock.calls.at(-1)[0].prompt;
172
- expect(sent.length).toBeLessThan(20_000);
173
- expect(sent.endsWith('…')).toBe(true);
175
+ expect(lastGenerate().body.prompt).toBe('you are terse\n\nthe task');
174
176
  });
175
177
 
176
- it('refuses without calling the provider when the caller cannot survive truncation', async () => {
177
- await expect(generateSmallModelText(request({ onOverflow: 'error' })))
178
- .rejects.toMatchObject({
179
- statusCode: 413,
180
- code: 'context-too-small',
181
- requiredChars: 20_000,
182
- availableChars: 16_000,
183
- });
178
+ it('honours an explicit request model', async () => {
179
+ const result = await generateSmallModelText({ prompt: 'hi', model: 'openai/gpt-5.6-luna', directory: '/proj' });
184
180
 
185
- expect(callSmallModel).not.toHaveBeenCalled();
181
+ expect(result.source).toBe('request');
182
+ expect(lastGenerate().body.model).toEqual({ id: 'gpt-5.6-luna', providerID: 'openai' });
186
183
  });
187
184
 
188
- it('leaves an input that fits untouched under either policy', async () => {
189
- const result = await generateSmallModelText(request({ prompt: 'short prompt', onOverflow: 'error' }));
185
+ it('refuses Claude Code before any request reaches the model', async () => {
186
+ await expect(generateSmallModelText({ prompt: 'hi', model: 'claude-code/haiku', directory: '/proj' }))
187
+ .rejects.toMatchObject({ statusCode: 422, code: 'small-model-provider-unsupported' });
190
188
 
191
- expect(result.inputTruncated).toBeUndefined();
192
- expect(callSmallModel.mock.calls.at(-1)[0].prompt).toBe('short prompt');
189
+ expect(lastGenerate()).toBeUndefined();
193
190
  });
194
191
 
195
- it('forwards schema, timeout, and abort signal to the transport', async () => {
196
- const controller = new AbortController();
197
- const schema = { type: 'object' };
198
-
199
- await generateSmallModelText(request({
200
- prompt: 'short',
201
- responseSchema: schema,
202
- timeoutMs: 240_000,
203
- signal: controller.signal,
204
- }));
205
-
206
- expect(callSmallModel.mock.calls.at(-1)[0]).toMatchObject({
207
- responseSchema: schema,
208
- timeoutMs: 240_000,
209
- signal: controller.signal,
192
+ it('stays on the session provider when the caller forbids switching', async () => {
193
+ const result = await generateSmallModelText({
194
+ prompt: 'hi',
195
+ directory: '/proj',
196
+ preferredProviderID: 'openai',
197
+ preferredModelID: 'gpt-5.6-luna',
198
+ restrictToPreferredProvider: true,
210
199
  });
200
+
201
+ expect(result).toMatchObject({ providerID: 'openai', modelID: 'gpt-5.6-luna', source: 'session-model' });
211
202
  });
212
- });
213
203
 
214
- describe('describeSmallModel — capability reporting', () => {
215
- beforeEach(() => {
216
- readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } });
217
- readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/claude-haiku-4-5' } });
218
- getModelCatalog.mockResolvedValue(CATALOG);
204
+ it('prefers the small model of the session provider over the session model itself', async () => {
205
+ state.models = [
206
+ MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
207
+ MODEL({ id: 'claude-haiku-4-5', modelID: 'claude-haiku-4-5', family: 'claude-haiku', time: { released: 1 } }),
208
+ MODEL({ id: 'claude-haiku-5', modelID: 'claude-haiku-5', family: 'claude-haiku', time: { released: 2 } }),
209
+ MODEL({ id: 'claude-haiku-6', modelID: 'claude-haiku-6', family: 'claude-haiku', time: { released: 3 }, status: 'beta' }),
210
+ MODEL({ id: 'gpt-5.6-luna', modelID: 'gpt-5.6-luna', providerID: 'openai', family: 'gpt-luna' }),
211
+ ];
212
+
213
+ const result = await generateSmallModelText({
214
+ prompt: 'hi',
215
+ directory: '/proj',
216
+ preferredProviderID: 'anthropic',
217
+ preferredModelID: 'claude-sonnet-5',
218
+ restrictToPreferredProvider: true,
219
+ });
220
+
221
+ // Newest active haiku of the session provider; the other provider's
222
+ // gpt-luna ranks higher in the family list but is another subscription.
223
+ expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-haiku-5', source: 'session-provider-small' });
224
+ expect(lastGenerate().body.model).toEqual({ id: 'claude-haiku-5', providerID: 'anthropic' });
219
225
  });
220
226
 
221
- afterEach(() => {
222
- vi.clearAllMocks();
227
+ it('without a session, takes the newest small model of any provider before the default', async () => {
228
+ state.models = [
229
+ MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
230
+ MODEL({ id: 'claude-haiku-5', modelID: 'claude-haiku-5', family: 'claude-haiku', time: { released: 9 } }),
231
+ MODEL({ id: 'gemini-3.5-flash', modelID: 'gemini-3.5-flash', providerID: 'google', family: 'gemini-flash', time: { released: 1 } }),
232
+ MODEL({ id: 'gemini-3.6-flash', modelID: 'gemini-3.6-flash', providerID: 'google', family: 'gemini-flash', time: { released: 2 } }),
233
+ ];
234
+ state.defaultModel = state.models[0];
235
+
236
+ const result = await generateSmallModelText({ prompt: 'hi', directory: '/proj' });
237
+
238
+ // gemini-flash outranks claude-haiku in the family list, so the newest
239
+ // gemini-flash wins even though haiku was released later.
240
+ expect(result).toMatchObject({ providerID: 'google', modelID: 'gemini-3.6-flash', source: 'small' });
223
241
  });
224
242
 
225
- it('reports the input budget and a known structured-output capability', async () => {
226
- const described = await describeSmallModel({ directory: '/proj' });
243
+ it('with a session but no restriction, still prefers the session provider, then any provider', async () => {
244
+ state.models = [
245
+ MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
246
+ MODEL({ id: 'gemini-3.6-flash', modelID: 'gemini-3.6-flash', providerID: 'google', family: 'gemini-flash' }),
247
+ ];
248
+ state.defaultModel = state.models[0];
227
249
 
228
- expect(described).toMatchObject({
229
- providerID: 'anthropic',
230
- modelID: 'claude-haiku-4-5',
231
- inputCharBudget: 16_000,
232
- contextTokens: 8_000,
233
- contextKnown: true,
234
- structuredOutput: true,
235
- hasLogin: true,
250
+ const result = await generateSmallModelText({
251
+ prompt: 'hi',
252
+ directory: '/proj',
253
+ preferredProviderID: 'anthropic',
254
+ preferredModelID: 'claude-sonnet-5',
236
255
  });
237
- });
238
256
 
239
- it('reports hasLogin false when the resolved provider has no usable credential', async () => {
240
- readAuthFile.mockReturnValue({});
257
+ // Anthropic has no small family here, and the caller allows switching.
258
+ expect(result).toMatchObject({ providerID: 'google', modelID: 'gemini-3.6-flash', source: 'small' });
259
+ });
241
260
 
242
- const described = await describeSmallModel({ directory: '/proj' });
261
+ it('reads the family from the model id when the catalog has none (custom provider)', async () => {
262
+ state.models = [
263
+ MODEL({ id: 'my-big-model', modelID: 'my-big-model', providerID: 'my-proxy' }),
264
+ MODEL({ id: 'gemini-3.6-flash', modelID: 'gemini-3.6-flash', providerID: 'my-proxy' }),
265
+ MODEL({ id: 'gpt-5.4-nano', modelID: 'gpt-5.4-nano', providerID: 'my-proxy' }),
266
+ ];
243
267
 
244
- expect(described).toMatchObject({
245
- providerID: 'anthropic',
246
- modelID: 'claude-haiku-4-5',
247
- hasLogin: false,
268
+ const result = await generateSmallModelText({
269
+ prompt: 'hi',
270
+ directory: '/proj',
271
+ preferredProviderID: 'my-proxy',
272
+ preferredModelID: 'my-big-model',
273
+ restrictToPreferredProvider: true,
248
274
  });
275
+
276
+ expect(result).toMatchObject({ providerID: 'my-proxy', modelID: 'gemini-3.6-flash', source: 'session-provider-small' });
249
277
  });
250
278
 
251
- it('reports an explicit false so callers can block the model', async () => {
252
- readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/legacy-tiny' } });
279
+ it('never picks Claude Code as the small model of any provider', async () => {
280
+ state.models = [MODEL({ id: 'haiku', modelID: 'haiku', providerID: 'claude-code', family: 'claude-haiku' })];
253
281
 
254
- const described = await describeSmallModel({ directory: '/proj' });
282
+ const result = await generateSmallModelText({ prompt: 'hi', directory: '/proj' });
255
283
 
256
- expect(described.structuredOutput).toBe(false);
284
+ // Only Claude Code has a small family here, so the scan yields nothing
285
+ // and OpenCode's default (the fixture's anthropic haiku) is used.
286
+ expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'default' });
257
287
  });
258
288
 
259
- it('reports null — not false — when the catalog omits the capability', async () => {
260
- readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted-capability' } });
289
+ it('ignores a disabled small model and falls back to the session model', async () => {
290
+ state.models = [
291
+ MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
292
+ MODEL({ id: 'claude-haiku-5', modelID: 'claude-haiku-5', family: 'claude-haiku', enabled: false }),
293
+ ];
261
294
 
262
- const described = await describeSmallModel({ directory: '/proj' });
295
+ const result = await generateSmallModelText({
296
+ prompt: 'hi',
297
+ directory: '/proj',
298
+ preferredProviderID: 'anthropic',
299
+ preferredModelID: 'claude-sonnet-5',
300
+ restrictToPreferredProvider: true,
301
+ });
263
302
 
264
- expect(described.structuredOutput).toBeNull();
303
+ expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-sonnet-5', source: 'session-model' });
265
304
  });
266
- });
267
305
 
268
- // The input reserve and the requested output budget are the same number seen
269
- // from two sides; if they drift, a caller that asks for a large answer overruns
270
- // the model's context and the failure looks like a truncation bug.
271
- describe('output budget and input reserve', () => {
272
- beforeEach(() => {
273
- readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } });
274
- readConfigLayers.mockReturnValue({ mergedConfig: {} });
275
- getModelCatalog.mockResolvedValue({
276
- anthropic: {
277
- id: 'anthropic',
278
- models: {
279
- roomy: { id: 'roomy', limit: { context: 100_000, output: 8_000 } },
280
- unlisted: { id: 'unlisted', limit: { context: 100_000 } },
281
- },
282
- },
283
- });
284
- callSmallModel.mockReset();
285
- callSmallModel.mockResolvedValue('generated');
306
+ it('refuses rather than switch provider when the session has no model of its own', async () => {
307
+ await expect(generateSmallModelText({
308
+ prompt: 'hi',
309
+ directory: '/proj',
310
+ preferredProviderID: 'openai',
311
+ restrictToPreferredProvider: true,
312
+ })).rejects.toMatchObject({ statusCode: 404 });
286
313
  });
287
314
 
288
- afterEach(() => {
289
- vi.clearAllMocks();
315
+ it('reports a missing OpenCode as no small model', async () => {
316
+ configureOpenCodeRuntimeProviders(null);
317
+
318
+ await expect(generateSmallModelText({ prompt: 'hi', directory: '/proj' }))
319
+ .rejects.toMatchObject({ statusCode: 404 });
290
320
  });
321
+ });
291
322
 
292
- it('caps the request at the model\'s advertised output limit', async () => {
293
- await generateSmallModelText({
294
- prompt: 'short',
295
- model: 'anthropic/roomy',
296
- maxOutputTokens: 24_000,
297
- });
323
+ describe('oversized input', () => {
324
+ // 8k context leaves 4k input tokens after the default output reserve → 16k chars.
325
+ const huge = (overrides = {}) => ({ prompt: 'x'.repeat(20_000), directory: '/proj', ...overrides });
326
+
327
+ it('truncates and flags the response by default', async () => {
328
+ const result = await generateSmallModelText(huge());
298
329
 
299
- expect(callSmallModel.mock.calls.at(-1)[0].maxOutputTokens).toBe(8_000);
330
+ expect(result.inputTruncated).toBe(true);
331
+ const sent = lastGenerate().body.prompt;
332
+ expect(sent.length).toBeLessThan(20_000);
333
+ expect(sent.endsWith('…')).toBe(true);
300
334
  });
301
335
 
302
- it('honours the requested budget when the catalog states no output limit', async () => {
303
- await generateSmallModelText({
304
- prompt: 'short',
305
- model: 'anthropic/unlisted',
306
- maxOutputTokens: 24_000,
336
+ it('refuses without calling the model when the caller cannot survive truncation', async () => {
337
+ await expect(generateSmallModelText(huge({ onOverflow: 'error' }))).rejects.toMatchObject({
338
+ statusCode: 413,
339
+ code: 'context-too-small',
340
+ requiredChars: 20_000,
341
+ availableChars: 16_000,
307
342
  });
308
343
 
309
- expect(callSmallModel.mock.calls.at(-1)[0].maxOutputTokens).toBe(24_000);
344
+ expect(lastGenerate()).toBeUndefined();
310
345
  });
311
346
 
312
347
  it('reserves exactly the requested output budget from the input allowance', async () => {
313
- // 100k context − 24k reserved for the answer = 76k tokens ≈ 304k chars.
348
+ state.models = [MODEL({ limit: { context: 100_000, output: 32_000 } })];
349
+ state.defaultModel = state.models[0];
350
+
314
351
  await expect(generateSmallModelText({
315
352
  prompt: 'x'.repeat(304_001),
316
- model: 'anthropic/unlisted',
353
+ directory: '/proj',
317
354
  maxOutputTokens: 24_000,
318
355
  onOverflow: 'error',
319
356
  })).rejects.toMatchObject({ code: 'context-too-small', availableChars: 304_000 });
357
+ });
320
358
 
321
- await expect(generateSmallModelText({
322
- prompt: 'x'.repeat(303_999),
323
- model: 'anthropic/unlisted',
324
- maxOutputTokens: 24_000,
325
- onOverflow: 'error',
326
- })).resolves.toBeTruthy();
359
+ it('falls back to a conservative context when OpenCode does not list the model', async () => {
360
+ state.models = [];
361
+
362
+ await expect(generateSmallModelText({ prompt: 'x'.repeat(300_000), directory: '/proj', onOverflow: 'error' }))
363
+ .rejects.toMatchObject({ availableChars: 60_000 * 4 });
364
+ });
365
+ });
366
+
367
+ describe('structured output', () => {
368
+ const schema = { type: 'object', properties: { title: { type: 'string' } } };
369
+
370
+ it('puts the schema in the prompt and returns the parsed JSON text', async () => {
371
+ state.generate = () => ({ text: '{"title":"ok"}' });
372
+
373
+ const result = await generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema });
374
+
375
+ expect(result.text).toBe('{"title":"ok"}');
376
+ expect(lastGenerate().body.prompt).toContain('Reply with JSON matching this schema and nothing else:');
377
+ expect(lastGenerate().body.prompt).toContain('"title"');
378
+ });
379
+
380
+ it('tolerates a json fence', async () => {
381
+ state.generate = () => ({ text: '```json\n{"title":"ok"}\n```' });
382
+
383
+ const result = await generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema });
384
+
385
+ expect(JSON.parse(result.text)).toEqual({ title: 'ok' });
386
+ });
387
+
388
+ it('retries once when the reply is not JSON', async () => {
389
+ let calls = 0;
390
+ state.generate = () => {
391
+ calls += 1;
392
+ return { text: calls === 1 ? 'Sure! Here you go.' : '{"title":"ok"}' };
393
+ };
394
+
395
+ const result = await generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema });
396
+
397
+ expect(calls).toBe(2);
398
+ expect(result.text).toBe('{"title":"ok"}');
399
+ });
400
+
401
+ it('gives up with structured-output-unsupported after the retry', async () => {
402
+ state.generate = () => ({ text: 'I cannot do that.' });
403
+
404
+ await expect(generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema }))
405
+ .rejects.toMatchObject({ statusCode: 422, code: 'structured-output-unsupported' });
406
+
407
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(2);
408
+ });
409
+ });
410
+
411
+ describe('describeSmallModel', () => {
412
+ it('reports the default model and its budget', async () => {
413
+ const described = await describeSmallModel({ directory: '/proj' });
414
+
415
+ expect(described).toMatchObject({
416
+ providerID: 'anthropic',
417
+ modelID: 'claude-haiku-4-5',
418
+ source: 'small',
419
+ inputCharBudget: 16_000,
420
+ contextTokens: 8_000,
421
+ contextKnown: true,
422
+ hasLogin: true,
423
+ outputTokenLimit: 4_000,
424
+ // /api/generate has no structured-output mode, so the capability is
425
+ // never a settled `false` — callers must try it.
426
+ structuredOutput: null,
427
+ });
327
428
  });
328
429
 
329
- it('reports the same budget through describeSmallModel', async () => {
330
- readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted' } });
430
+ it('reports the override model instead of the default', async () => {
431
+ fs.writeFileSync(
432
+ path.join(TEMP_DATA_DIR, 'settings.json'),
433
+ JSON.stringify({ smallModelUseDefault: false, smallModelOverride: 'openai/gpt-5.6-luna' }),
434
+ );
435
+
436
+ try {
437
+ const described = await describeSmallModel({ directory: '/proj' });
438
+ expect(described).toMatchObject({ providerID: 'openai', modelID: 'gpt-5.6-luna', source: 'settings' });
439
+ } finally {
440
+ fs.rmSync(path.join(TEMP_DATA_DIR, 'settings.json'), { force: true });
441
+ }
442
+ });
331
443
 
332
- const described = await describeSmallModel({ directory: '/proj', outputReserveTokens: 24_000 });
444
+ it('reports hasLogin false for a model OpenCode has disabled', async () => {
445
+ state.models = [MODEL({ enabled: false })];
333
446
 
334
- expect(described.inputCharBudget).toBe(304_000);
447
+ expect(await describeSmallModel({ directory: '/proj' })).toMatchObject({ hasLogin: false });
335
448
  });
336
449
 
337
- // A caller that wants "as much room as this model allows" cannot name a
338
- // number before knowing which model it got, so it hands over the decision.
339
- it('lets the reserve be decided from the resolved model\'s limits', async () => {
340
- readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/roomy' } });
450
+ it('lets the reserve be decided from the resolved model limits', async () => {
451
+ state.models = [MODEL({ limit: { context: 100_000, output: 8_000 } })];
452
+ state.defaultModel = state.models[0];
341
453
 
342
454
  const described = await describeSmallModel({
343
455
  directory: '/proj',
344
456
  outputReserveTokens: ({ contextTokens, outputTokenLimit }) => Math.min(contextTokens / 10, outputTokenLimit),
345
457
  });
346
458
 
347
- // 100k context, 8k output limit -> 8k reserved, leaving 92k tokens.
459
+ // 100k context, 8k output limit → 8k reserved, leaving 92k tokens.
348
460
  expect(described.outputTokens).toBe(8_000);
349
461
  expect(described.inputCharBudget).toBe(92_000 * 4);
350
462
  });
351
463
 
352
- it('reports the reserve it used so the caller can request the same number', async () => {
353
- readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted' } });
464
+ it('answers null when OpenCode is not reachable', async () => {
465
+ configureOpenCodeRuntimeProviders(null);
466
+
467
+ expect(await describeSmallModel({ directory: '/proj' })).toBeNull();
468
+ });
469
+ });
470
+
471
+ describe('listAuthenticatedProviders', () => {
472
+ it('offers a provider that has at least one enabled model', async () => {
473
+ expect(await listAuthenticatedProviders()).toEqual(['anthropic']);
474
+ });
475
+
476
+ it('hides a provider whose models are all disabled', async () => {
477
+ state.models = [MODEL({ enabled: false })];
478
+
479
+ expect(await listAuthenticatedProviders()).toEqual([]);
480
+ });
481
+
482
+ // The provider list comes back empty on setups where models are perfectly
483
+ // usable, so it can only add names, never remove them.
484
+ it('derives providers from the model list when the provider list is empty', async () => {
485
+ state.providers = [];
486
+
487
+ expect(await listAuthenticatedProviders()).toEqual(['anthropic']);
488
+ });
489
+
490
+ it('never offers Claude Code', async () => {
491
+ state.models = [MODEL(), MODEL({ id: 'sonnet', modelID: 'sonnet', providerID: 'claude-code' })];
354
492
 
355
- const described = await describeSmallModel({ directory: '/proj', outputReserveTokens: 24_000 });
493
+ expect(await listAuthenticatedProviders()).not.toContain('claude-code');
494
+ });
495
+
496
+ it('answers an empty list when OpenCode is not reachable', async () => {
497
+ configureOpenCodeRuntimeProviders(null);
356
498
 
357
- expect(described.outputTokens).toBe(24_000);
499
+ expect(await listAuthenticatedProviders()).toEqual([]);
358
500
  });
359
501
  });
360
502
 
361
- afterAll(() => {
362
- fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
503
+
504
+ describe('small model failure response', () => {
505
+ it('preserves the model-specific reason without advice to change settings', async () => {
506
+ let generate;
507
+ registerSmallModelRoutes({
508
+ get() {},
509
+ post(_path, handler) { generate = handler; },
510
+ }, {
511
+ getSmallModelService: async () => ({ generateSmallModelText }),
512
+ });
513
+ state.generateErrors = [
514
+ { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
515
+ { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
516
+ ];
517
+ let status;
518
+ let payload;
519
+ const response = {
520
+ status(value) { status = value; return response; },
521
+ json(value) { payload = value; },
522
+ };
523
+ await generate({ body: { prompt: 'commit', model: 'zai-coding-plan/glm-5.3-flash' } }, response);
524
+ expect(status).toBe(503);
525
+ expect(payload).toEqual({
526
+ error: 'Model unavailable: zai-coding-plan/glm-5.3-flash',
527
+ code: 'small-model-unavailable',
528
+ });
529
+ });
363
530
  });