@vantran-se/openchamber-web 1.24.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-BjJHrzmU.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
  5. package/dist/assets/DiffView-CjxUovbM.js +4 -0
  6. package/dist/assets/FilesView-DFWD0RQR.js +17 -0
  7. package/dist/assets/{GitView-CDOJy5Ay.js → GitView-BWa7e5vw.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-D3S0Rjic.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
  10. package/dist/assets/{JsonTreeView-D0C9nU7K.js → JsonTreeView-3sGwqexh.js} +1 -1
  11. package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
  14. package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
  15. package/dist/assets/{PatchDiff-DnEhjXcC.js → PatchDiff-DS-IMU1j.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-DyxZVVVR.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
  17. package/dist/assets/PlanView-BCk_VobP.js +5 -0
  18. package/dist/assets/PluginPane-BPP1dbck.js +3 -0
  19. package/dist/assets/SettingsView-kMmrpn43.js +29 -0
  20. package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
  22. package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-0pyr_mHY.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
  25. package/dist/assets/{WalkthroughView-yNj4nOP5.js → WalkthroughView-lap5btCF.js} +2 -2
  26. package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
  27. package/dist/assets/{commitAndPush-DvJKa3Ks.js → commitAndPush-CwGJfJG-.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DxTBMuu-.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
  29. package/dist/assets/de-CndEg67T.js +10 -0
  30. package/dist/assets/es-DglvPlaC.js +16 -0
  31. package/dist/assets/fr-DejPCpbS.js +14 -0
  32. package/dist/assets/index-C2r64QfD.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-D7FkBMP6.js → isDiffPlainText-D47a6hMP.js} +1 -1
  36. package/dist/assets/{isFilePlainText-CL2vsrIZ.js → isFilePlainText-B-mGgeKl.js} +1 -1
  37. package/dist/assets/ja-CVpQawCd.js +16 -0
  38. package/dist/assets/ko-Bw446h9j.js +16 -0
  39. package/dist/assets/main-Bz4WWXbr.js +2 -0
  40. package/dist/assets/main-oreqDsOV.js +896 -0
  41. package/dist/assets/{markdownCore-Dh7WEsdM.js → markdownCore-D2e5sp9y.js} +4 -4
  42. package/dist/assets/miniChat-D1v2gsBI.js +2 -0
  43. package/dist/assets/mobile-DBj3lCf2.js +2 -0
  44. package/dist/assets/{patchFileDiff-Bsba8KFp.js → patchFileDiff-BpeUOV91.js} +3 -3
  45. package/dist/assets/pl-BYNlPdhm.js +16 -0
  46. package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
  48. package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
  50. package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
  51. package/dist/assets/{shikiHighlight-8_3csmr6.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-CHo2fCgW.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
  53. package/dist/assets/tr-DzmbxLy-.js +7 -0
  54. package/dist/assets/uk-C7vQnwro.js +16 -0
  55. package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-DkQ_gqpn.js → useInlineCommentController-CuUuakrt.js} +2 -2
  57. package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-CdzUmUyZ.js → vendor-codemirror-language-data-CQa7_1cr.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-B8IcVQAA.js → web-components-B6tKQyJN.js} +2 -2
  64. package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
  65. package/dist/assets/zh-TW-DtII5bDb.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-COVQhWGd.js +0 -4
  316. package/dist/assets/FilesView-B1V5JxFX.js +0 -13
  317. package/dist/assets/LinearIssuesView-D7FKg1XJ.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-BrFjAVEf.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-DWepTafh.js +0 -12
  320. package/dist/assets/OnboardingScreen-Dyxxiqdt.js +0 -1
  321. package/dist/assets/PlanView-LW2z_EAP.js +0 -5
  322. package/dist/assets/PluginPane-B4tKno2h.js +0 -3
  323. package/dist/assets/SettingsView-DEaMulN9.js +0 -29
  324. package/dist/assets/SettingsWindow-FLDJXOgQ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +0 -1
  326. package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CkR4msKP.js +0 -8
  328. package/dist/assets/appThemeRegistry-BuUKhbYQ.js +0 -1
  329. package/dist/assets/de-DgxgFyGG.js +0 -10
  330. package/dist/assets/es-BFsZj5F_.js +0 -16
  331. package/dist/assets/fr-jdZ5KKQi.js +0 -14
  332. package/dist/assets/index-CKHjPKbq.js +0 -1
  333. package/dist/assets/index-Cbci4i1W.js +0 -40
  334. package/dist/assets/index-DqaMWNrC.css +0 -1
  335. package/dist/assets/ja-C5kKrhO4.js +0 -16
  336. package/dist/assets/ko-DXDTT_0O.js +0 -16
  337. package/dist/assets/main-CZALtVek.js +0 -896
  338. package/dist/assets/main-CgXMBNvG.js +0 -2
  339. package/dist/assets/miniChat-CwTmDixi.js +0 -2
  340. package/dist/assets/mobile-CLf2vCtO.js +0 -2
  341. package/dist/assets/pl-D-7x4Y8Q.js +0 -16
  342. package/dist/assets/pt-BR-CSBZrHa8.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +0 -2
  344. package/dist/assets/renderMobileApp-V4hYd32H.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +0 -25
  346. package/dist/assets/scheduledTasksApi-DzjbLd8P.js +0 -1
  347. package/dist/assets/tr-Dr-oJ22L.js +0 -7
  348. package/dist/assets/uk-9Nx83UcI.js +0 -16
  349. package/dist/assets/useAppFontEffects-khZXpFL_.js +0 -7333
  350. package/dist/assets/useRootScrollLock-DdvHf9Pt.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-Bxy_Hj3r.js +0 -16
  354. package/dist/assets/zh-TW-0BB4gpHM.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,3 +1,6 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
1
4
  import express from 'express';
2
5
  import request from 'supertest';
3
6
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -14,10 +17,20 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
14
17
  error: null,
15
18
  updatedAt: Date.now(),
16
19
  }));
17
- const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
18
- const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
20
+ // `@opencode/client` unwraps single-record responses, so these mocks return
21
+ // the record itself; only the paged/list endpoints keep a `{ data }` envelope.
22
+ const sessionCreateMock = vi.fn(async () => ({ id: 'ses_123' }));
23
+ const sessionForkMock = vi.fn(async () => ({ id: 'ses_fork', title: 'Forked session' }));
19
24
  const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
20
- const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
25
+ const sessionGetMock = vi.fn(async ({ sessionID }) => ({ id: sessionID, location: { directory: '/repo/app' } }));
26
+ const sessionUpdateMock = vi.fn(async () => undefined);
27
+ const sessionPromptMock = vi.fn(async () => ({ id: 'msg_dispatched' }));
28
+ const sessionSyntheticMock = vi.fn(async () => ({ id: 'msg_synthetic' }));
29
+ const sessionSwitchModelMock = vi.fn(async () => undefined);
30
+ const sessionSwitchAgentMock = vi.fn(async () => undefined);
31
+ const modelListMock = vi.fn(async () => ({ data: [] }));
32
+ const agentListMock = vi.fn(async () => ({ data: [] }));
33
+ const configGetMock = vi.fn(async () => ([]));
21
34
 
22
35
  let existingSessionMessages = [];
23
36
  let dispatchedUserMessageSeq = 0;
@@ -34,57 +47,67 @@ const recordedSessionMessages = async () => {
34
47
  data: [
35
48
  ...existingSessionMessages,
36
49
  {
37
- info: {
38
- id: `msg_dispatched_${dispatchedUserMessageSeq}`,
39
- role: 'user',
40
- time: { created: 1000 + dispatchedUserMessageSeq },
41
- },
50
+ id: `msg_dispatched_${dispatchedUserMessageSeq}`,
51
+ type: 'user',
52
+ time: { created: 1000 + dispatchedUserMessageSeq },
42
53
  },
43
54
  ],
44
55
  };
45
56
  };
46
57
 
47
- // Selection inputs are fetched whenever a request names a model, agent, or
48
- // variant, so every prompt-dispatching fetch mock must answer them.
49
- const selectionInputResponse = (url) => {
50
- const text = String(url);
51
- if (text.includes('/config/providers')) {
52
- return {
53
- ok: true,
54
- json: async () => ({
55
- providers: [
56
- { id: 'openai', models: [{ id: 'gpt-5.5', variants: { high: {} } }] },
57
- { id: 'anthropic', models: [{ id: 'claude-sonnet-5', variants: { high: {} } }] },
58
- ],
59
- }),
60
- };
61
- }
62
- if (text.includes('/agent')) {
63
- return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
64
- }
65
- if (text.includes('/config')) return { ok: true, json: async () => ({}) };
66
- return null;
58
+ // v2 serves one flat model catalogue and a flat agent list through the client,
59
+ // so the selection inputs are stubbed on the client mocks rather than on fetch.
60
+ const CATALOG_MODELS = [
61
+ { id: 'gpt-5.5', modelID: 'gpt-5.5', providerID: 'openai', variants: [{ id: 'high' }] },
62
+ { id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', providerID: 'anthropic', variants: [{ id: 'high' }] },
63
+ ];
64
+ const CATALOG_AGENTS = [
65
+ { id: 'build', name: 'Build', mode: 'primary', hidden: false },
66
+ { id: 'plan', name: 'plan', mode: 'primary', hidden: false },
67
+ ];
68
+ const useCatalog = ({ models = CATALOG_MODELS, agents = CATALOG_AGENTS, config = [] } = {}) => {
69
+ modelListMock.mockImplementation(async () => ({ data: models }));
70
+ agentListMock.mockImplementation(async () => ({ data: agents }));
71
+ configGetMock.mockImplementation(async () => config);
67
72
  };
68
- const sessionCommandMock = vi.fn(async () => ({ data: {} }));
73
+
74
+ const sessionCommandMock = vi.fn(async () => undefined);
69
75
  const commandListMock = vi.fn(async () => ({ data: [] }));
70
76
  globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
71
77
  globalThis.__openchamberGetWorktreeBootstrapStatusMock = getWorktreeBootstrapStatusMock;
72
78
 
73
79
  let registerOpenChamberSessionRoutes;
74
-
75
- vi.mock('@opencode-ai/sdk/v2', () => ({
76
- createOpencodeClient: () => ({
77
- session: {
78
- create: sessionCreateMock,
79
- fork: sessionForkMock,
80
- messages: sessionMessagesMock,
81
- command: sessionCommandMock,
82
- update: sessionUpdateMock,
83
- },
84
- command: {
85
- list: commandListMock,
80
+ let createSessionMetadataStore;
81
+ let createOpenCodeSessionMetadata;
82
+
83
+ // Every `OpenCode.make` call is recorded so tests can assert on the scoping
84
+ // headers the routes build.
85
+ const clientOptions = [];
86
+
87
+ vi.mock('@opencode/client', () => ({
88
+ OpenCode: {
89
+ make: (options) => {
90
+ clientOptions.push(options);
91
+ return {
92
+ session: {
93
+ create: sessionCreateMock,
94
+ fork: sessionForkMock,
95
+ get: sessionGetMock,
96
+ update: sessionUpdateMock,
97
+ command: sessionCommandMock,
98
+ prompt: sessionPromptMock,
99
+ synthetic: sessionSyntheticMock,
100
+ switchModel: sessionSwitchModelMock,
101
+ switchAgent: sessionSwitchAgentMock,
102
+ },
103
+ message: { list: sessionMessagesMock },
104
+ command: { list: commandListMock },
105
+ model: { list: modelListMock },
106
+ agent: { list: agentListMock },
107
+ config: { get: configGetMock },
108
+ };
86
109
  },
87
- }),
110
+ },
88
111
  }));
89
112
 
90
113
  vi.mock('../git/index.js', () => ({
@@ -93,13 +116,84 @@ vi.mock('../git/index.js', () => ({
93
116
  resolvePrimaryWorktreeRoot: async (directory) => ({ root: directory === '/repo/worktrees/side-task' ? '/repo/app' : directory }),
94
117
  }));
95
118
 
119
+ /**
120
+ * Archive state is OpenChamber's own now, so the routes take a store rather
121
+ * than talking to OpenCode. The tests use an in-memory one with the same
122
+ * contract as `archive-store.js`.
123
+ */
124
+ const createMemorySessionMetadataStore = () => {
125
+ const entries = new Map();
126
+ const merge = (current, patch) => {
127
+ const base = { ...(current ?? {}) };
128
+ for (const [key, value] of Object.entries(patch)) {
129
+ if (value === null) delete base[key];
130
+ else if (value && typeof value === 'object' && !Array.isArray(value)) base[key] = merge(base[key], value);
131
+ else base[key] = value;
132
+ }
133
+ return base;
134
+ };
135
+ return {
136
+ entries,
137
+ failFor: null,
138
+ get: async (id) => entries.get(id) ?? {},
139
+ has: async (id) => entries.has(id),
140
+ getAll: async () => Object.fromEntries(entries),
141
+ setSessionMetadata: async (id, patch) => {
142
+ const merged = merge(entries.get(id), patch);
143
+ if (Object.keys(merged).length === 0) entries.delete(id);
144
+ else entries.set(id, merged);
145
+ return merged;
146
+ },
147
+ };
148
+ };
149
+
150
+ const createMemoryArchiveStore = () => {
151
+ const entries = new Map();
152
+ let failFor = new Set();
153
+ return {
154
+ failIds: (ids) => { failFor = new Set(ids); },
155
+ entries,
156
+ getAll: async () => Object.fromEntries(entries),
157
+ list: async () => Object.fromEntries(entries),
158
+ isArchived: async (id) => entries.has(id),
159
+ archivedAt: async (id) => entries.get(id) ?? null,
160
+ archive: async (ids, archivedAt) => {
161
+ const stamp = Number.isSafeInteger(archivedAt) && archivedAt > 0 ? archivedAt : 1;
162
+ const archived = [];
163
+ const failedIds = [];
164
+ for (const id of ids) {
165
+ if (failFor.has(id)) { failedIds.push(id); continue; }
166
+ entries.set(id, stamp);
167
+ archived.push({ id, archivedAt: stamp });
168
+ }
169
+ return { archived, failedIds };
170
+ },
171
+ unarchive: async (ids) => {
172
+ const restored = [];
173
+ const failedIds = [];
174
+ for (const id of ids) {
175
+ if (failFor.has(id)) { failedIds.push(id); continue; }
176
+ entries.delete(id);
177
+ restored.push({ id, archivedAt: null });
178
+ }
179
+ return { restored, failedIds };
180
+ },
181
+ };
182
+ };
183
+
96
184
  const createApp = (overrides = {}, options = {}) => {
97
185
  const app = express();
98
186
  if (options.globalJson !== false) {
99
187
  app.use(express.json());
100
188
  }
101
189
  const calls = [];
190
+ const archiveStore = overrides.archiveStore ?? createMemoryArchiveStore();
191
+ const sessionMetadataStore = overrides.sessionMetadataStore ?? createMemorySessionMetadataStore();
192
+ const broadcastGlobalUiEvent = overrides.broadcastGlobalUiEvent ?? vi.fn();
102
193
  registerOpenChamberSessionRoutes(app, {
194
+ archiveStore,
195
+ sessionMetadataStore,
196
+ broadcastGlobalUiEvent,
103
197
  readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'proj_1', path: '/repo/app' }] }),
104
198
  sanitizeProjects: (projects) => projects,
105
199
  validateDirectoryPath: async (directory) => ({ ok: true, directory }),
@@ -108,12 +202,13 @@ const createApp = (overrides = {}, options = {}) => {
108
202
  waitForOpenCodeReady: vi.fn(async () => undefined),
109
203
  ...overrides,
110
204
  });
111
- return { app, calls };
205
+ return { app, calls, archiveStore, sessionMetadataStore, broadcastGlobalUiEvent };
112
206
  };
113
207
 
114
208
  describe('openchamber session routes', () => {
115
209
  beforeAll(async () => {
116
210
  ({ registerOpenChamberSessionRoutes } = await import('./routes.js'));
211
+ ({ createSessionMetadataStore, createOpenCodeSessionMetadata } = await import('./session-metadata-store.js'));
117
212
  });
118
213
 
119
214
  beforeEach(() => {
@@ -126,6 +221,7 @@ describe('openchamber session routes', () => {
126
221
  updatedAt: Date.now(),
127
222
  }));
128
223
  sessionCreateMock.mockClear();
224
+ sessionUpdateMock.mockClear();
129
225
  sessionForkMock.mockClear();
130
226
  existingSessionMessages = [];
131
227
  dispatchedUserMessageSeq = 0;
@@ -133,205 +229,299 @@ describe('openchamber session routes', () => {
133
229
  sessionMessagesMock.mockImplementation(recordedSessionMessages);
134
230
  sessionCommandMock.mockReset();
135
231
  sessionCommandMock.mockResolvedValue({ data: {} });
232
+ clientOptions.length = 0;
136
233
  commandListMock.mockReset();
137
234
  commandListMock.mockResolvedValue({ data: [] });
138
- sessionUpdateMock.mockReset();
139
- sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
235
+ sessionGetMock.mockReset();
236
+ sessionGetMock.mockImplementation(async ({ sessionID }) => ({ id: sessionID, location: { directory: '/repo/app' } }));
237
+ sessionPromptMock.mockReset();
238
+ sessionPromptMock.mockImplementation(async () => ({ id: 'msg_dispatched' }));
239
+ sessionSyntheticMock.mockReset();
240
+ sessionSyntheticMock.mockImplementation(async () => ({ id: 'msg_synthetic' }));
241
+ sessionSwitchModelMock.mockReset();
242
+ sessionSwitchModelMock.mockImplementation(async () => undefined);
243
+ sessionSwitchAgentMock.mockReset();
244
+ sessionSwitchAgentMock.mockImplementation(async () => undefined);
245
+ modelListMock.mockReset();
246
+ modelListMock.mockImplementation(async () => ({ data: [] }));
247
+ agentListMock.mockReset();
248
+ agentListMock.mockImplementation(async () => ({ data: [] }));
249
+ configGetMock.mockReset();
250
+ useCatalog();
140
251
  });
141
252
 
142
253
  describe('archiving a batch of sessions', () => {
143
- it('archives every id against the resolved directory and returns the archived sessions', async () => {
144
- const { app } = createApp();
254
+ it('archives every id and reports what it stored', async () => {
255
+ const { app, archiveStore, broadcastGlobalUiEvent } = createApp();
145
256
  const response = await request(app)
146
257
  .post('/api/openchamber/sessions/archive')
147
- .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
258
+ .send({ ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
148
259
  .expect(200);
149
260
 
150
- expect(response.body.directory).toBe('/repo/app');
151
- expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
261
+ expect(response.body.archived).toEqual([
262
+ { id: 'ses_a', archivedAt: 1700 },
263
+ { id: 'ses_b', archivedAt: 1700 },
264
+ ]);
152
265
  expect(response.body.failedIds).toEqual([]);
153
- expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
154
- expect(sessionUpdateMock).toHaveBeenCalledWith({
155
- sessionID: 'ses_a',
156
- directory: '/repo/app',
157
- time: { archived: 1700 },
266
+ await expect(archiveStore.getAll()).resolves.toEqual({ ses_a: 1700, ses_b: 1700 });
267
+ expect(broadcastGlobalUiEvent).toHaveBeenCalledWith({
268
+ type: 'openchamber:session-archived',
269
+ properties: { sessionID: 'ses_a', archivedAt: 1700 },
158
270
  });
159
271
  });
160
272
 
161
273
  it('keeps archiving after a failed session and reports it as failed', async () => {
162
- sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
163
- if (sessionID === 'ses_b') throw new Error('session.update failed');
164
- return { data: { id: sessionID } };
165
- });
274
+ const archiveStore = createMemoryArchiveStore();
275
+ archiveStore.failIds(['ses_b']);
276
+ const { app } = createApp({ archiveStore });
166
277
 
167
- const { app } = createApp();
168
278
  const response = await request(app)
169
279
  .post('/api/openchamber/sessions/archive')
170
- .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
280
+ .send({ ids: ['ses_a', 'ses_b', 'ses_c'] })
171
281
  .expect(200);
172
282
 
173
283
  expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
174
284
  expect(response.body.failedIds).toEqual(['ses_b']);
175
285
  });
176
286
 
177
- it('reports a session the server did not confirm as failed instead of archived', async () => {
178
- sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
179
- sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
180
- ));
287
+ it('does not announce a session it failed to store', async () => {
288
+ const archiveStore = createMemoryArchiveStore();
289
+ archiveStore.failIds(['ses_b']);
290
+ const { app, broadcastGlobalUiEvent } = createApp({ archiveStore });
181
291
 
182
- const { app } = createApp();
183
- const response = await request(app)
292
+ await request(app)
184
293
  .post('/api/openchamber/sessions/archive')
185
- .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
294
+ .send({ ids: ['ses_a', 'ses_b'] })
186
295
  .expect(200);
187
296
 
188
- expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
189
- expect(response.body.failedIds).toEqual(['ses_b']);
297
+ const announced = broadcastGlobalUiEvent.mock.calls.map(([event]) => event.properties.sessionID);
298
+ expect(announced).toEqual(['ses_a']);
299
+ });
300
+
301
+ it('restores a batch and announces the cleared state', async () => {
302
+ const { app, archiveStore, broadcastGlobalUiEvent } = createApp();
303
+ await request(app).post('/api/openchamber/sessions/archive').send({ ids: ['ses_a'] }).expect(200);
304
+ broadcastGlobalUiEvent.mockClear();
305
+
306
+ const response = await request(app)
307
+ .post('/api/openchamber/sessions/unarchive')
308
+ .send({ ids: ['ses_a'] })
309
+ .expect(200);
310
+
311
+ expect(response.body.restored).toEqual([{ id: 'ses_a', archivedAt: null }]);
312
+ expect(response.body.failedIds).toEqual([]);
313
+ await expect(archiveStore.getAll()).resolves.toEqual({});
314
+ expect(broadcastGlobalUiEvent).toHaveBeenCalledWith({
315
+ type: 'openchamber:session-archived',
316
+ properties: { sessionID: 'ses_a', archivedAt: null },
317
+ });
190
318
  });
191
319
 
192
320
  it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
193
- const { app } = createApp();
321
+ const { app, archiveStore } = createApp();
194
322
 
195
- await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
323
+ await request(app).post('/api/openchamber/sessions/archive').send({ ids: [] }).expect(400);
196
324
  await request(app)
197
325
  .post('/api/openchamber/sessions/archive')
198
- .send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
326
+ .send({ ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
199
327
  .expect(400);
200
328
  await request(app)
201
329
  .post('/api/openchamber/sessions/archive')
202
- .send({ directory: '/repo/app', ids: ['ses_a', ''] })
330
+ .send({ ids: ['ses_a', ''] })
203
331
  .expect(400);
204
332
  await request(app)
205
333
  .post('/api/openchamber/sessions/archive')
206
- .send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
334
+ .send({ ids: ['ses_a'], archivedAt: -1 })
207
335
  .expect(400);
336
+ await request(app).post('/api/openchamber/sessions/unarchive').send({ ids: [] }).expect(400);
208
337
 
209
- expect(sessionUpdateMock).not.toHaveBeenCalled();
338
+ await expect(archiveStore.getAll()).resolves.toEqual({});
210
339
  });
340
+ });
211
341
 
212
- it('rejects a directory the runtime does not accept', async () => {
342
+ describe('session metadata OpenChamber owns', () => {
343
+ it('merges a patch onto the OpenCode record and writes it back with PATCH', async () => {
344
+ sessionGetMock.mockImplementationOnce(async ({ sessionID }) => ({
345
+ id: sessionID,
346
+ location: { directory: '/repo/app' },
347
+ metadata: { openchamber: { kind: 'review', assist: { recap: 'from v1' } } },
348
+ }));
349
+ // The real store over the same mocked client the routes use.
350
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-routes-metadata-'));
213
351
  const { app } = createApp({
214
- validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
352
+ sessionMetadataStore: createSessionMetadataStore({
353
+ dataDir,
354
+ openCode: createOpenCodeSessionMetadata({
355
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
356
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }),
357
+ }),
358
+ }),
215
359
  });
216
360
 
361
+ const response = await request(app)
362
+ .post('/api/openchamber/sessions/ses_v1/metadata')
363
+ .send({ patch: { openchamber: { goal: { status: 'active' } } } })
364
+ .expect(200);
365
+
366
+ const merged = { openchamber: { kind: 'review', assist: { recap: 'from v1' }, goal: { status: 'active' } } };
367
+ expect(response.body.metadata).toEqual(merged);
368
+ expect(sessionUpdateMock).toHaveBeenCalledWith({ sessionID: 'ses_v1', metadata: merged });
369
+ fs.rmSync(dataDir, { recursive: true, force: true });
370
+ });
371
+
372
+ it('merge-patches metadata, returns the merged object, and announces it', async () => {
373
+ const { app, sessionMetadataStore, broadcastGlobalUiEvent } = createApp();
374
+
217
375
  await request(app)
218
- .post('/api/openchamber/sessions/archive')
219
- .send({ directory: '/elsewhere', ids: ['ses_a'] })
220
- .expect(400);
376
+ .post('/api/openchamber/sessions/ses_a/metadata')
377
+ .send({ patch: { openchamber: { assist: { recap: 'first' } } } })
378
+ .expect(200);
379
+
380
+ const response = await request(app)
381
+ .post('/api/openchamber/sessions/ses_a/metadata')
382
+ .send({ patch: { openchamber: { goal: { status: 'active' } } } })
383
+ .expect(200);
221
384
 
222
- expect(sessionUpdateMock).not.toHaveBeenCalled();
385
+ // The second write must not erase the first: OpenCode's PATCH merged, so
386
+ // this does too.
387
+ expect(response.body).toEqual({
388
+ metadata: { openchamber: { assist: { recap: 'first' }, goal: { status: 'active' } } },
389
+ });
390
+ await expect(sessionMetadataStore.get('ses_a')).resolves.toEqual(response.body.metadata);
391
+ expect(broadcastGlobalUiEvent).toHaveBeenLastCalledWith({
392
+ type: 'openchamber:session-metadata',
393
+ properties: { sessionID: 'ses_a', metadata: response.body.metadata },
394
+ });
223
395
  });
224
- });
225
396
 
226
- it('creates a session for a directory', async () => {
227
- const originalFetch = globalThis.fetch;
228
- globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
229
- try {
397
+ it('deletes a key when the patch value is null', async () => {
230
398
  const { app } = createApp();
399
+ await request(app)
400
+ .post('/api/openchamber/sessions/ses_a/metadata')
401
+ .send({ patch: { openchamber: { goal: { id: 'g1' }, assist: { recap: 'r' } } } })
402
+ .expect(200);
403
+
231
404
  const response = await request(app)
232
- .post('/api/openchamber/sessions')
233
- .send({ directory: '/repo/app', title: 'Side task' })
405
+ .post('/api/openchamber/sessions/ses_a/metadata')
406
+ .send({ patch: { openchamber: { assist: null } } })
234
407
  .expect(200);
235
408
 
236
- expect(response.body.sessionId).toBeTruthy();
237
- expect(response.body.sessionId).toBe('ses_123');
238
- expect(response.body.directory).toBe('/repo/app');
239
- expect(response.body.promptDispatched).toBe(false);
240
- expect(globalThis.fetch).toHaveBeenCalledWith(
241
- 'http://opencode.test/session?directory=%2Frepo%2Fapp',
242
- expect.objectContaining({
243
- method: 'POST',
244
- body: JSON.stringify({ directory: '/repo/app', title: 'Side task' }),
245
- }),
246
- );
247
- expect(sessionCreateMock).not.toHaveBeenCalled();
248
- } finally {
249
- globalThis.fetch = originalFetch;
250
- }
251
- });
409
+ expect(response.body).toEqual({ metadata: { openchamber: { goal: { id: 'g1' } } } });
410
+ });
252
411
 
253
- it('percent-encodes the directory header for non-ASCII checkout paths', async () => {
254
- const originalFetch = globalThis.fetch;
255
- globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
256
- try {
412
+ it('reads back what it stored, per session', async () => {
257
413
  const { app } = createApp();
258
- await request(app)
259
- .post('/api/openchamber/sessions')
260
- .send({ directory: '/home/user/Masaüstü/projeler', title: 'Side task' })
414
+ await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({ patch: { a: 1 } }).expect(200);
415
+
416
+ await expect(request(app).get('/api/openchamber/sessions/ses_a/metadata').expect(200))
417
+ .resolves.toMatchObject({ body: { metadata: { a: 1 } } });
418
+ await expect(request(app).get('/api/openchamber/sessions/ses_b/metadata').expect(200))
419
+ .resolves.toMatchObject({ body: { metadata: {} } });
420
+ });
421
+
422
+ it('rejects a missing or non-object patch', async () => {
423
+ const { app, broadcastGlobalUiEvent } = createApp();
424
+
425
+ await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({}).expect(400);
426
+ await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({ patch: 'nope' }).expect(400);
427
+ await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({ patch: ['a'] }).expect(400);
428
+
429
+ expect(broadcastGlobalUiEvent).not.toHaveBeenCalled();
430
+ });
431
+
432
+ it('routes every write through the server-owned writer when one is injected', async () => {
433
+ const persistSessionMetadata = vi.fn(async () => ({ openchamber: { goal: { status: 'active' } } }));
434
+ const { app, sessionMetadataStore } = createApp({ persistSessionMetadata });
435
+
436
+ const response = await request(app)
437
+ .post('/api/openchamber/sessions/ses_a/metadata')
438
+ .send({ patch: { openchamber: { goal: { status: 'active' } } }, directory: '/repo/app' })
261
439
  .expect(200);
262
440
 
263
- expect(globalThis.fetch).toHaveBeenCalledWith(
264
- expect.any(String),
265
- expect.objectContaining({
266
- headers: expect.objectContaining({
267
- 'x-opencode-directory': encodeURIComponent('/home/user/Masaüstü/projeler'),
268
- }),
269
- }),
441
+ expect(persistSessionMetadata).toHaveBeenCalledWith(
442
+ 'ses_a',
443
+ { openchamber: { goal: { status: 'active' } } },
444
+ { directory: '/repo/app' },
270
445
  );
271
- } finally {
272
- globalThis.fetch = originalFetch;
273
- }
446
+ expect(response.body.metadata).toEqual({ openchamber: { goal: { status: 'active' } } });
447
+ // The injected writer owns the store; the route must not write twice.
448
+ await expect(sessionMetadataStore.get('ses_a')).resolves.toEqual({});
449
+ });
274
450
  });
275
451
 
276
- it('parses JSON body without global middleware', async () => {
277
- const originalFetch = globalThis.fetch;
278
- globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
279
- try {
280
- const { app } = createApp({}, { globalJson: false });
281
- const response = await request(app)
282
- .post('/api/openchamber/sessions')
283
- .send({ directory: '/repo/app' })
284
- .expect(200);
452
+ it('creates a session for a directory', async () => {
453
+ const { app } = createApp();
454
+ const response = await request(app)
455
+ .post('/api/openchamber/sessions')
456
+ .send({ directory: '/repo/app', title: 'Side task' })
457
+ .expect(200);
458
+
459
+ expect(response.body.sessionId).toBe('ses_123');
460
+ expect(response.body.directory).toBe('/repo/app');
461
+ expect(response.body.promptDispatched).toBe(false);
462
+ expect(sessionCreateMock).toHaveBeenCalledWith({
463
+ location: { directory: '/repo/app' },
464
+ title: 'Side task',
465
+ });
466
+ });
285
467
 
286
- expect(response.body.sessionId).toBe('ses_123');
287
- expect(response.body.directory).toBe('/repo/app');
288
- } finally {
289
- globalThis.fetch = originalFetch;
290
- }
468
+ it('percent-encodes the directory header for non-ASCII checkout paths', async () => {
469
+ const { app } = createApp();
470
+ await request(app)
471
+ .post('/api/openchamber/sessions')
472
+ .send({ directory: '/home/user/Masaüstü/projeler', title: 'Side task' })
473
+ .expect(200);
474
+
475
+ expect(clientOptions.at(-1)?.headers['x-opencode-directory'])
476
+ .toBe(encodeURIComponent('/home/user/Masaüstü/projeler'));
477
+ });
478
+
479
+ it('parses JSON body without global middleware', async () => {
480
+ const { app } = createApp({}, { globalJson: false });
481
+ const response = await request(app)
482
+ .post('/api/openchamber/sessions')
483
+ .send({ directory: '/repo/app' })
484
+ .expect(200);
485
+
486
+ expect(response.body.sessionId).toBe('ses_123');
487
+ expect(response.body.directory).toBe('/repo/app');
291
488
  });
292
489
 
293
490
  it('emits a session-created event after creating a session', async () => {
294
- const originalFetch = globalThis.fetch;
295
491
  const emitSessionCreatedEvent = vi.fn();
296
- globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
297
- try {
298
- const { app } = createApp({ emitSessionCreatedEvent });
299
- await request(app)
300
- .post('/api/openchamber/sessions')
301
- .send({ directory: '/repo/app', title: 'Side task' })
302
- .expect(200);
492
+ const { app } = createApp({ emitSessionCreatedEvent });
493
+ await request(app)
494
+ .post('/api/openchamber/sessions')
495
+ .send({ directory: '/repo/app', title: 'Side task' })
496
+ .expect(200);
497
+
498
+ expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
499
+ sessionID: 'ses_123',
500
+ directory: '/repo/app',
501
+ title: 'Side task',
502
+ promptDispatched: false,
503
+ dispatchedAsCommand: false,
504
+ }));
505
+ });
303
506
 
304
- expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
305
- sessionID: 'ses_123',
306
- directory: '/repo/app',
307
- title: 'Side task',
308
- promptDispatched: false,
309
- dispatchedAsCommand: false,
310
- }));
311
- } finally {
312
- globalThis.fetch = originalFetch;
313
- }
507
+ it('resolves a default agent stored by v1 under its display name', async () => {
508
+ useCatalog({ agents: [{ id: 'build', name: 'Build', mode: 'primary' }, { id: 'plan', name: 'Plan', mode: 'primary' }] });
509
+ const { app } = createApp({
510
+ readSettingsFromDiskMigrated: async () => ({
511
+ defaultAgent: 'Plan',
512
+ projects: [{ id: 'proj_1', path: '/repo/app' }],
513
+ }),
514
+ });
515
+
516
+ const response = await request(app)
517
+ .post('/api/openchamber/sessions')
518
+ .send({ directory: '/repo/app', prompt: 'Run this' })
519
+ .expect(200);
520
+
521
+ expect(response.body.agent).toBe('plan');
314
522
  });
315
523
 
316
524
  it('resolves default model and agent when prompt omits them', async () => {
317
- const originalFetch = globalThis.fetch;
318
- const fetchMock = vi.fn(async (url) => {
319
- const text = String(url);
320
- if (text.includes('/prompt_async')) {
321
- return { ok: true, text: async () => '' };
322
- }
323
- if (text.includes('/config/providers')) {
324
- return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
325
- }
326
- if (text.includes('/agent')) {
327
- return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
328
- }
329
- if (text.includes('/config')) {
330
- return { ok: true, json: async () => ({}) };
331
- }
332
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
333
- });
334
- globalThis.fetch = fetchMock;
335
525
  const { app } = createApp({
336
526
  readSettingsFromDiskMigrated: async () => ({
337
527
  defaultModel: 'openai/gpt-5.5',
@@ -339,75 +529,71 @@ describe('openchamber session routes', () => {
339
529
  projects: [{ id: 'proj_1', path: '/repo/app' }],
340
530
  }),
341
531
  });
342
- try {
343
- const response = await request(app)
344
- .post('/api/openchamber/sessions')
345
- .send({ directory: '/repo/app', prompt: 'Run this' })
346
- .expect(200);
347
-
348
- expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
349
- expect(response.body.agent).toBe('build');
350
- expect(fetchMock).toHaveBeenCalledWith(
351
- 'http://opencode.test/config/providers?directory=%2Frepo%2Fapp',
352
- expect.any(Object),
353
- );
354
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
355
- expect(JSON.parse(promptCall?.[1]?.body)).toMatchObject({
356
- model: { providerID: 'openai', modelID: 'gpt-5.5' },
357
- agent: 'build',
358
- });
359
- } finally {
360
- globalThis.fetch = originalFetch;
361
- }
532
+ const response = await request(app)
533
+ .post('/api/openchamber/sessions')
534
+ .send({ directory: '/repo/app', prompt: 'Run this' })
535
+ .expect(200);
536
+
537
+ expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
538
+ expect(response.body.agent).toBe('build');
539
+ expect(modelListMock).toHaveBeenCalled();
540
+ expect(sessionSwitchModelMock).toHaveBeenCalledWith({
541
+ sessionID: 'ses_123',
542
+ model: { id: 'gpt-5.5', providerID: 'openai' },
543
+ });
544
+ expect(sessionSwitchAgentMock).toHaveBeenCalledWith({ sessionID: 'ses_123', agent: 'build' });
362
545
  });
363
546
 
364
- it('lets the routing hook replace an Auto default before the prompt leaves', async () => {
365
- const originalFetch = globalThis.fetch;
366
- const fetchMock = vi.fn(async (url) => {
367
- const text = String(url);
368
- if (text.includes('/prompt_async')) {
369
- return { ok: true, text: async () => '' };
370
- }
371
- if (text.includes('/config/providers')) {
372
- return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
373
- }
374
- if (text.includes('/agent')) {
375
- return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
376
- }
377
- if (text.includes('/config')) {
378
- return { ok: true, json: async () => ({}) };
379
- }
380
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
381
- });
382
- globalThis.fetch = fetchMock;
383
- const seen = [];
384
- const resolvePromptBody = vi.fn(async (body, target) => {
385
- seen.push({ model: body.model, target });
386
- body.model = { providerID: 'openai', modelID: 'gpt-5.5' };
387
- });
547
+ it('resolves an Auto default through the routing hook before switching the session', async () => {
548
+ useCatalog();
549
+ const resolveAutoSelection = vi.fn(async () => ({
550
+ model: { providerID: 'openai', id: 'gpt-5.5', variant: 'high' },
551
+ agent: 'plan',
552
+ decision: {},
553
+ }));
388
554
  const { app } = createApp({
389
555
  readSettingsFromDiskMigrated: async () => ({
390
556
  defaultModel: 'openchamber/auto',
391
557
  defaultAgent: 'build',
392
558
  projects: [{ id: 'proj_1', path: '/repo/app' }],
393
559
  }),
394
- resolvePromptBody,
560
+ resolveAutoSelection,
395
561
  });
396
- try {
397
- await request(app)
398
- .post('/api/openchamber/sessions')
399
- .send({ directory: '/repo/app', prompt: 'Run this' })
400
- .expect(200);
562
+ const response = await request(app)
563
+ .post('/api/openchamber/sessions')
564
+ .send({ directory: '/repo/app', prompt: 'Run this' })
565
+ .expect(200);
566
+
567
+ expect(resolveAutoSelection).toHaveBeenCalledWith({
568
+ sessionId: 'ses_123',
569
+ directory: '/repo/app',
570
+ model: { providerID: 'openchamber', id: 'auto' },
571
+ agent: 'build',
572
+ requestText: 'Run this',
573
+ });
574
+ expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
575
+ expect(response.body.agent).toBe('plan');
576
+ expect(sessionSwitchModelMock).toHaveBeenCalledWith({
577
+ sessionID: 'ses_123',
578
+ model: { id: 'gpt-5.5', providerID: 'openai', variant: 'high' },
579
+ });
580
+ expect(sessionSwitchAgentMock).toHaveBeenCalledWith({ sessionID: 'ses_123', agent: 'plan' });
581
+ });
401
582
 
402
- expect(seen).toEqual([{
403
- model: { providerID: 'openchamber', modelID: 'auto' },
404
- target: { sessionId: 'ses_123', directory: '/repo/app' },
405
- }]);
406
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
407
- expect(JSON.parse(promptCall?.[1]?.body).model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
408
- } finally {
409
- globalThis.fetch = originalFetch;
410
- }
583
+ it('refuses an Auto default when routing is not wired in', async () => {
584
+ useCatalog();
585
+ const { app } = createApp({
586
+ readSettingsFromDiskMigrated: async () => ({
587
+ defaultModel: 'openchamber/auto',
588
+ projects: [{ id: 'proj_1', path: '/repo/app' }],
589
+ }),
590
+ });
591
+ const response = await request(app)
592
+ .post('/api/openchamber/sessions')
593
+ .send({ directory: '/repo/app', prompt: 'Run this' });
594
+ expect(response.status).toBe(400);
595
+ expect(response.body.error).toMatch(/not available/);
596
+ expect(sessionSwitchModelMock).not.toHaveBeenCalled();
411
597
  });
412
598
 
413
599
  it.each([
@@ -417,24 +603,7 @@ describe('openchamber session routes', () => {
417
603
  ['/ses_existing/send', { directory: '/repo/worktrees/side-task' }],
418
604
  ['/ses_existing/fork', { directory: '/repo/worktrees/side-task' }],
419
605
  ])('prefers project defaults for %s with %j', async (endpoint, scope) => {
420
- const originalFetch = globalThis.fetch;
421
- const fetchMock = vi.fn(async (url) => {
422
- const text = String(url);
423
- if (text.includes('/prompt_async')) {
424
- return { ok: true, text: async () => '' };
425
- }
426
- if (text.includes('/config/providers')) {
427
- return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
428
- }
429
- if (text.includes('/agent')) {
430
- return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
431
- }
432
- if (text.includes('/config')) {
433
- return { ok: true, json: async () => ({}) };
434
- }
435
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
436
- });
437
- globalThis.fetch = fetchMock;
606
+ useCatalog();
438
607
  const { app } = createApp({
439
608
  readSettingsFromDiskMigrated: async () => ({
440
609
  defaultModel: 'openai/gpt-5.5',
@@ -442,150 +611,106 @@ describe('openchamber session routes', () => {
442
611
  projects: [{ id: 'proj_1', path: '/repo/app', defaultAgent: 'plan' }],
443
612
  }),
444
613
  });
445
- try {
446
- const response = await request(app)
447
- .post(`/api/openchamber/sessions${endpoint}`)
448
- .send({ ...scope, prompt: 'Run this' })
449
- .expect(200);
450
614
 
451
- expect(response.body.agent).toBe('plan');
452
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
453
- expect(JSON.parse(promptCall?.[1]?.body)).toMatchObject({ agent: 'plan' });
454
- } finally {
455
- globalThis.fetch = originalFetch;
456
- }
615
+ const response = await request(app)
616
+ .post(`/api/openchamber/sessions${endpoint}`)
617
+ .send({ ...scope, prompt: 'Run this' })
618
+ .expect(200);
619
+
620
+ expect(response.body.agent).toBe('plan');
621
+ // v2 puts the agent on the session, not in the prompt body.
622
+ expect(sessionSwitchAgentMock).toHaveBeenCalledWith(expect.objectContaining({ agent: 'plan' }));
457
623
  });
458
624
 
459
625
  it('dispatches an initial prompt when model is provided', async () => {
460
- const originalFetch = globalThis.fetch;
461
- const fetchMock = vi.fn(async (url) => {
462
- if (String(url).includes('/prompt_async')) {
463
- return { ok: true, text: async () => '' };
464
- }
465
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
466
- });
467
- globalThis.fetch = fetchMock;
468
- try {
469
- const { app } = createApp();
470
- const response = await request(app)
471
- .post('/api/openchamber/sessions')
472
- .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
473
- .expect(200);
474
-
475
- expect(response.body.sessionId).toBe('ses_123');
476
- expect(response.body.promptDispatched).toBe(true);
477
- expect(fetchMock).toHaveBeenCalledWith(
478
- 'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fapp',
479
- expect.objectContaining({ method: 'POST' }),
480
- );
481
- } finally {
482
- globalThis.fetch = originalFetch;
483
- }
626
+ const { app } = createApp();
627
+ const response = await request(app)
628
+ .post('/api/openchamber/sessions')
629
+ .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
630
+ .expect(200);
631
+
632
+ expect(response.body.sessionId).toBe('ses_123');
633
+ expect(response.body.promptDispatched).toBe(true);
634
+ expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_123', text: 'Run this' });
484
635
  });
485
636
 
486
637
  it('creates goal metadata before dispatching the initial goal prompt', async () => {
487
- const originalFetch = globalThis.fetch;
488
- const fetchMock = vi.fn(async (url) => {
489
- if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
490
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
491
- });
492
638
  const createSessionGoal = vi.fn(async () => undefined);
493
- globalThis.fetch = fetchMock;
494
- try {
495
- const { app } = createApp({ createSessionGoal });
496
- const response = await request(app)
497
- .post('/api/openchamber/sessions')
498
- .send({
499
- directory: '/repo/app',
500
- prompt: 'Finish and verify the migration',
501
- model: 'openai/gpt-5.5',
502
- goal: true,
503
- goalTokenBudget: 200000,
504
- })
505
- .expect(200);
506
-
507
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
508
- const promptPayload = JSON.parse(promptCall[1].body);
509
- expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
510
- sessionID: 'ses_123',
639
+ const { app } = createApp({ createSessionGoal });
640
+ const response = await request(app)
641
+ .post('/api/openchamber/sessions')
642
+ .send({
511
643
  directory: '/repo/app',
512
- objective: 'Finish and verify the migration',
513
- tokenBudget: 200000,
514
- providerID: 'openai',
515
- modelID: 'gpt-5.5',
516
- }));
517
- expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
518
- expect(promptPayload.parts).toEqual([
519
- { type: 'text', text: 'Finish and verify the migration' },
520
- expect.objectContaining({ type: 'text', synthetic: true }),
521
- ]);
522
- expect(response.body).toMatchObject({ goalEnabled: true, goalTokenBudget: 200000, promptDispatched: true });
523
- } finally {
524
- globalThis.fetch = originalFetch;
525
- }
644
+ prompt: 'Finish and verify the migration',
645
+ model: 'openai/gpt-5.5',
646
+ goal: true,
647
+ goalTokenBudget: 200000,
648
+ })
649
+ .expect(200);
650
+
651
+ expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
652
+ sessionID: 'ses_123',
653
+ directory: '/repo/app',
654
+ objective: 'Finish and verify the migration',
655
+ tokenBudget: 200000,
656
+ providerID: 'openai',
657
+ modelID: 'gpt-5.5',
658
+ }));
659
+ expect(createSessionGoal.mock.invocationCallOrder[0])
660
+ .toBeLessThan(sessionPromptMock.mock.invocationCallOrder[0]);
661
+ // v2 cannot append to a message, so the goal reminder follows the prompt as
662
+ // its own synthetic message.
663
+ expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_123', text: 'Finish and verify the migration' });
664
+ expect(sessionSyntheticMock).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_123', resume: false }));
665
+ expect(sessionPromptMock.mock.invocationCallOrder[0])
666
+ .toBeLessThan(sessionSyntheticMock.mock.invocationCallOrder[0]);
667
+ expect(response.body).toMatchObject({ goalEnabled: true, goalTokenBudget: 200000, promptDispatched: true });
526
668
  });
527
669
 
528
670
  it('rejects invalid goal requests before creating a session', async () => {
529
- const originalFetch = globalThis.fetch;
530
- const fetchMock = vi.fn();
531
- globalThis.fetch = fetchMock;
532
- try {
533
- const { app } = createApp();
534
- await request(app)
535
- .post('/api/openchamber/sessions')
536
- .send({ directory: '/repo/app', goal: true })
537
- .expect(400, { error: 'prompt is required when goal is enabled' });
538
- await request(app)
539
- .post('/api/openchamber/sessions')
540
- .send({ directory: '/repo/app', prompt: 'Run', goalTokenBudget: 200000 })
541
- .expect(400, { error: 'goalTokenBudget requires goal' });
542
- await request(app)
543
- .post('/api/openchamber/sessions')
544
- .send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
545
- .expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
546
- expect(fetchMock).not.toHaveBeenCalled();
547
- } finally {
548
- globalThis.fetch = originalFetch;
549
- }
671
+ const { app } = createApp();
672
+ await request(app)
673
+ .post('/api/openchamber/sessions')
674
+ .send({ directory: '/repo/app', goal: true })
675
+ .expect(400, { error: 'prompt is required when goal is enabled' });
676
+ await request(app)
677
+ .post('/api/openchamber/sessions')
678
+ .send({ directory: '/repo/app', prompt: 'Run', goalTokenBudget: 200000 })
679
+ .expect(400, { error: 'goalTokenBudget requires goal' });
680
+ await request(app)
681
+ .post('/api/openchamber/sessions')
682
+ .send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
683
+ .expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
684
+
685
+ expect(sessionCreateMock).not.toHaveBeenCalled();
550
686
  });
551
687
 
552
688
  it('creates a worktree before creating a session', async () => {
553
- const originalFetch = globalThis.fetch;
554
- globalThis.fetch = vi.fn(async (url) => {
555
- if (String(url).includes('/prompt_async')) {
556
- return { ok: true, text: async () => '' };
557
- }
558
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
559
- });
560
- try {
561
- const { app } = createApp();
562
- const response = await request(app)
563
- .post('/api/openchamber/sessions')
564
- .send({
565
- directory: '/repo/app',
566
- worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
567
- setUpstream: false,
568
- prompt: 'Run this',
569
- model: 'openai/gpt-5.5',
570
- })
571
- .expect(200);
572
-
573
- expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
574
- mode: 'new',
575
- name: 'side-task',
576
- branchName: 'openchamber/side-task',
577
- startRef: 'main',
689
+ const { app } = createApp();
690
+ const response = await request(app)
691
+ .post('/api/openchamber/sessions')
692
+ .send({
693
+ directory: '/repo/app',
694
+ worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
578
695
  setUpstream: false,
579
- });
580
- expect(response.body.directory).toBe('/repo/worktrees/side-task');
581
- expect(response.body.worktree.path).toBe('/repo/worktrees/side-task');
582
- expect(globalThis.fetch).toHaveBeenCalledWith(
583
- 'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fworktrees%2Fside-task',
584
- expect.objectContaining({ method: 'POST' }),
585
- );
586
- } finally {
587
- globalThis.fetch = originalFetch;
588
- }
696
+ prompt: 'Run this',
697
+ model: 'openai/gpt-5.5',
698
+ })
699
+ .expect(200);
700
+
701
+ expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
702
+ mode: 'new',
703
+ name: 'side-task',
704
+ branchName: 'openchamber/side-task',
705
+ startRef: 'main',
706
+ setUpstream: false,
707
+ });
708
+ expect(response.body.directory).toBe('/repo/worktrees/side-task');
709
+ expect(response.body.worktree.path).toBe('/repo/worktrees/side-task');
710
+ expect(sessionCreateMock).toHaveBeenCalledWith(expect.objectContaining({
711
+ location: { directory: '/repo/worktrees/side-task' },
712
+ }));
713
+ expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_123', text: 'Run this' });
589
714
  });
590
715
 
591
716
  it('waits for the worktree bootstrap to complete before creating the session', async () => {
@@ -595,38 +720,24 @@ describe('openchamber session routes', () => {
595
720
  { status: 'ready', phase: 'setup-ready', error: null, updatedAt: 3 },
596
721
  ];
597
722
  getWorktreeBootstrapStatusMock.mockImplementation(async () => statuses.shift() || statuses[statuses.length - 1]);
598
- const originalFetch = globalThis.fetch;
599
- globalThis.fetch = vi.fn(async (url) => {
600
- if (String(url).includes('/prompt_async')) {
601
- return { ok: true, text: async () => '' };
602
- }
603
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
604
- });
605
- try {
606
- const { app } = createApp();
607
- const response = await request(app)
608
- .post('/api/openchamber/sessions')
609
- .send({
610
- directory: '/repo/app',
611
- worktree: { name: 'side-task' },
612
- prompt: 'Run this',
613
- model: 'openai/gpt-5.5',
614
- })
615
- .expect(200);
616
723
 
617
- expect(response.body.promptDispatched).toBe(true);
618
- const sessionCreateCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/session?directory'));
619
- const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
620
- expect(sessionCreateCalls.length).toBeGreaterThanOrEqual(1);
621
- expect(promptCalls.length).toBeGreaterThanOrEqual(1);
622
- const createIndex = globalThis.fetch.mock.calls.indexOf(sessionCreateCalls[0]);
623
- const promptIndex = globalThis.fetch.mock.calls.indexOf(promptCalls[0]);
624
- expect(getWorktreeBootstrapStatusMock).toHaveBeenCalled();
625
- expect(createIndex).toBeGreaterThan(-1);
626
- expect(promptIndex).toBeGreaterThan(createIndex);
627
- } finally {
628
- globalThis.fetch = originalFetch;
629
- }
724
+ const { app } = createApp();
725
+ const response = await request(app)
726
+ .post('/api/openchamber/sessions')
727
+ .send({
728
+ directory: '/repo/app',
729
+ worktree: { name: 'side-task' },
730
+ prompt: 'Run this',
731
+ model: 'openai/gpt-5.5',
732
+ })
733
+ .expect(200);
734
+
735
+ expect(response.body.promptDispatched).toBe(true);
736
+ expect(getWorktreeBootstrapStatusMock).toHaveBeenCalled();
737
+ expect(getWorktreeBootstrapStatusMock.mock.invocationCallOrder[0])
738
+ .toBeLessThan(sessionCreateMock.mock.invocationCallOrder[0]);
739
+ expect(sessionCreateMock.mock.invocationCallOrder[0])
740
+ .toBeLessThan(sessionPromptMock.mock.invocationCallOrder[0]);
630
741
  });
631
742
 
632
743
  it('fails the create when the worktree bootstrap failed', async () => {
@@ -636,380 +747,306 @@ describe('openchamber session routes', () => {
636
747
  error: 'branch already exists',
637
748
  updatedAt: Date.now(),
638
749
  }));
639
- const originalFetch = globalThis.fetch;
640
- globalThis.fetch = vi.fn(async (url) => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
641
- try {
642
- const { app } = createApp();
643
- await request(app)
644
- .post('/api/openchamber/sessions')
645
- .send({
646
- directory: '/repo/app',
647
- worktree: { name: 'side-task' },
648
- prompt: 'Run this',
649
- model: 'openai/gpt-5.5',
650
- })
651
- .expect(500, { error: 'Worktree bootstrap failed: branch already exists' });
652
- const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
653
- expect(promptCalls.length).toBe(0);
654
- } finally {
655
- globalThis.fetch = originalFetch;
656
- }
750
+
751
+ const { app } = createApp();
752
+ await request(app)
753
+ .post('/api/openchamber/sessions')
754
+ .send({
755
+ directory: '/repo/app',
756
+ worktree: { name: 'side-task' },
757
+ prompt: 'Run this',
758
+ model: 'openai/gpt-5.5',
759
+ })
760
+ .expect(500, { error: 'Worktree bootstrap failed: branch already exists' });
761
+
762
+ expect(sessionPromptMock).not.toHaveBeenCalled();
657
763
  });
658
764
 
659
765
  it('sends a goal prompt to an existing session after creating goal metadata', async () => {
660
- const originalFetch = globalThis.fetch;
661
- const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
662
766
  const createSessionGoal = vi.fn(async () => undefined);
663
- globalThis.fetch = fetchMock;
664
- try {
665
- setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]);
666
- const { app } = createApp({ createSessionGoal });
667
- const response = await request(app)
668
- .post('/api/openchamber/sessions/ses_source/send')
669
- .send({
670
- directory: '/repo/app',
671
- prompt: 'Apply and verify the review feedback',
672
- model: 'openai/gpt-5.5',
673
- agent: 'build',
674
- variant: 'high',
675
- goal: true,
676
- goalTokenBudget: 200000,
677
- })
678
- .expect(200);
767
+ setSessionMessages([{ id: 'msg_before', type: 'assistant', time: { created: 10, completed: 20 } }]);
679
768
 
680
- expect(response.body).toMatchObject({
681
- action: 'send',
682
- sessionId: 'ses_source',
683
- directory: '/repo/app',
684
- promptDispatched: true,
685
- goalEnabled: true,
686
- baselineAssistantMessageId: 'msg_before',
687
- });
688
- expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
689
- sessionID: 'ses_source',
769
+ const { app } = createApp({ createSessionGoal });
770
+ const response = await request(app)
771
+ .post('/api/openchamber/sessions/ses_source/send')
772
+ .send({
690
773
  directory: '/repo/app',
691
- objective: 'Apply and verify the review feedback',
692
- }));
693
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
694
- expect(promptCall?.[0]).toBe('http://opencode.test/session/ses_source/prompt_async?directory=%2Frepo%2Fapp');
695
- expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder.at(-1));
696
- } finally {
697
- globalThis.fetch = originalFetch;
698
- }
774
+ prompt: 'Apply and verify the review feedback',
775
+ model: 'openai/gpt-5.5',
776
+ agent: 'build',
777
+ variant: 'high',
778
+ goal: true,
779
+ goalTokenBudget: 200000,
780
+ })
781
+ .expect(200);
782
+
783
+ expect(response.body).toMatchObject({
784
+ action: 'send',
785
+ sessionId: 'ses_source',
786
+ directory: '/repo/app',
787
+ promptDispatched: true,
788
+ goalEnabled: true,
789
+ baselineAssistantMessageId: 'msg_before',
790
+ });
791
+ expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
792
+ sessionID: 'ses_source',
793
+ directory: '/repo/app',
794
+ objective: 'Apply and verify the review feedback',
795
+ }));
796
+ expect(sessionPromptMock).toHaveBeenCalledWith({
797
+ sessionID: 'ses_source',
798
+ text: 'Apply and verify the review feedback',
799
+ });
800
+ expect(createSessionGoal.mock.invocationCallOrder[0])
801
+ .toBeLessThan(sessionPromptMock.mock.invocationCallOrder[0]);
699
802
  });
700
803
 
701
- it('uses the expanded slash-command template as the goal objective before command dispatch', async () => {
702
- const originalFetch = globalThis.fetch;
804
+ it('dispatches a slash command and keeps the typed prompt as the goal objective', async () => {
805
+ // v2 no longer publishes a command's template (`CommandInfo` is just name
806
+ // and description), so the objective is what the user typed.
703
807
  const createSessionGoal = vi.fn(async () => undefined);
704
- commandListMock.mockResolvedValue({
705
- data: [{
706
- name: 'issue--to-pr',
707
- template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.',
708
- }],
709
- });
710
- globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url));
711
- try {
712
- const { app } = createApp({ createSessionGoal });
713
- const response = await request(app)
714
- .post('/api/openchamber/sessions/ses_source/send')
715
- .send({
716
- directory: '/repo/app',
717
- prompt: '/issue--to-pr LIN-123',
718
- model: 'openai/gpt-5.5',
719
- agent: 'build',
720
- goal: true,
721
- })
722
- .expect(200);
808
+ commandListMock.mockResolvedValue({ data: [{ name: 'issue--to-pr', description: 'Issue to PR' }] });
723
809
 
724
- expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
725
- objective: 'Take LIN-123 from issue through a verified pull request. Confirm the PR covers LIN-123.',
726
- }));
727
- expect(sessionCommandMock).toHaveBeenCalledWith(expect.objectContaining({
728
- command: 'issue--to-pr',
729
- arguments: 'LIN-123',
730
- }));
731
- expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
732
- expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
733
- expect(globalThis.fetch.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
734
- } finally {
735
- globalThis.fetch = originalFetch;
736
- }
810
+ const { app } = createApp({ createSessionGoal });
811
+ const response = await request(app)
812
+ .post('/api/openchamber/sessions/ses_source/send')
813
+ .send({
814
+ directory: '/repo/app',
815
+ prompt: '/issue--to-pr LIN-123',
816
+ model: 'openai/gpt-5.5',
817
+ agent: 'build',
818
+ goal: true,
819
+ })
820
+ .expect(200);
821
+
822
+ expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
823
+ objective: '/issue--to-pr LIN-123',
824
+ }));
825
+ expect(sessionCommandMock).toHaveBeenCalledWith(expect.objectContaining({
826
+ name: 'issue--to-pr',
827
+ text: 'LIN-123',
828
+ }));
829
+ expect(createSessionGoal.mock.invocationCallOrder[0])
830
+ .toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
831
+ expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
832
+ expect(sessionPromptMock).not.toHaveBeenCalled();
737
833
  });
738
834
 
739
- it('reuses the previous session selection when send omits model, agent, and variant', async () => {
740
- const originalFetch = globalThis.fetch;
741
- const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
742
- globalThis.fetch = fetchMock;
743
- try {
744
- setSessionMessages([
745
- {
746
- info: {
747
- id: 'msg_user',
748
- role: 'user',
749
- agent: 'plan',
750
- model: { providerID: 'anthropic', modelID: 'claude-sonnet-5', variant: 'high' },
751
- time: { created: 5 },
752
- },
753
- },
754
- { info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
755
- ]);
756
- const { app } = createApp();
757
- const response = await request(app)
758
- .post('/api/openchamber/sessions/ses_source/send')
759
- .send({ directory: '/repo/app', prompt: 'Continue where you left off' })
760
- .expect(200);
835
+ it('admits standing project context ahead of a dispatched slash command', async () => {
836
+ commandListMock.mockResolvedValue({ data: [{ name: 'review', description: 'Review' }] });
837
+ const recordDelivered = vi.fn(async () => undefined);
838
+ const sessionKnowledgeRuntime = {
839
+ resolvePendingForSession: vi.fn(async () => ({ text: 'Memory guidance', signature: 'sig_1' })),
840
+ recordDelivered,
841
+ };
761
842
 
762
- expect(response.body).toMatchObject({
763
- action: 'send',
764
- sessionId: 'ses_source',
765
- model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
766
- agent: 'plan',
767
- variant: 'high',
768
- promptDispatched: true,
769
- });
770
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
771
- const promptBody = JSON.parse(promptCall[1].body);
772
- expect(promptBody).toMatchObject({
773
- model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
774
- agent: 'plan',
775
- variant: 'high',
776
- });
777
- // The default-selection inputs (config/providers/agents) must not be consulted.
778
- expect(fetchMock.mock.calls.every(([url]) => String(url).includes('/prompt_async'))).toBe(true);
779
- } finally {
780
- globalThis.fetch = originalFetch;
781
- }
843
+ const { app } = createApp({ sessionKnowledgeRuntime });
844
+ await request(app)
845
+ .post('/api/openchamber/sessions/ses_source/send')
846
+ .send({ directory: '/repo/app', prompt: '/review', model: 'openai/gpt-5.5', agent: 'build' })
847
+ .expect(200);
848
+
849
+ expect(sessionSyntheticMock).toHaveBeenCalledWith(expect.objectContaining({
850
+ text: 'Memory guidance',
851
+ resume: false,
852
+ }));
853
+ expect(sessionSyntheticMock.mock.invocationCallOrder[0])
854
+ .toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
855
+ expect(recordDelivered).toHaveBeenCalledWith('ses_source', '/repo/app', 'sig_1');
856
+ });
857
+
858
+ it('reuses the previous session selection when send omits model, agent, and variant', async () => {
859
+ // v2 keeps the selection on the session record, so the history is no longer
860
+ // walked for it.
861
+ sessionGetMock.mockImplementation(async ({ sessionID }) => ({
862
+ id: sessionID,
863
+ agent: 'plan',
864
+ model: { providerID: 'anthropic', id: 'claude-sonnet-5', variant: 'high' },
865
+ location: { directory: '/repo/app' },
866
+ }));
867
+ setSessionMessages([{ id: 'msg_before', type: 'assistant', time: { created: 10, completed: 20 } }]);
868
+
869
+ const { app } = createApp();
870
+ const response = await request(app)
871
+ .post('/api/openchamber/sessions/ses_source/send')
872
+ .send({ directory: '/repo/app', prompt: 'Continue where you left off' })
873
+ .expect(200);
874
+
875
+ expect(response.body).toMatchObject({
876
+ action: 'send',
877
+ sessionId: 'ses_source',
878
+ model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
879
+ agent: 'plan',
880
+ variant: 'high',
881
+ promptDispatched: true,
882
+ });
883
+ // The default-selection catalogue must not be consulted.
884
+ expect(modelListMock).not.toHaveBeenCalled();
885
+ expect(agentListMock).not.toHaveBeenCalled();
782
886
  });
783
887
 
784
888
  it('forks from a message, dispatches the prompt, and emits the new session', async () => {
785
- const originalFetch = globalThis.fetch;
786
889
  const emitSessionCreatedEvent = vi.fn();
787
- globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
788
- try {
789
- const { app } = createApp({ emitSessionCreatedEvent });
790
- const response = await request(app)
791
- .post('/api/openchamber/sessions/ses_source/fork')
792
- .send({
793
- directory: '/repo/app',
794
- messageId: 'msg_branch_point',
795
- prompt: 'Try the alternative implementation',
796
- model: 'openai/gpt-5.5',
797
- agent: 'build',
798
- variant: 'high',
799
- })
800
- .expect(200);
801
-
802
- expect(sessionForkMock).toHaveBeenCalledWith({
803
- sessionID: 'ses_source',
804
- directory: '/repo/app',
805
- messageID: 'msg_branch_point',
806
- });
807
- expect(response.body).toMatchObject({
808
- action: 'fork',
809
- sourceSessionId: 'ses_source',
810
- sessionId: 'ses_fork',
811
- directory: '/repo/app',
812
- promptDispatched: true,
813
- });
814
- expect(sessionMessagesMock).toHaveBeenCalledWith({
815
- sessionID: 'ses_fork',
890
+ const { app } = createApp({ emitSessionCreatedEvent });
891
+ const response = await request(app)
892
+ .post('/api/openchamber/sessions/ses_source/fork')
893
+ .send({
816
894
  directory: '/repo/app',
817
- limit: 100,
818
- });
819
- expect(globalThis.fetch).toHaveBeenCalledWith(
820
- 'http://opencode.test/session/ses_fork/prompt_async?directory=%2Frepo%2Fapp',
821
- expect.objectContaining({ method: 'POST' }),
822
- );
823
- expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
824
- sessionID: 'ses_fork',
825
- sourceSessionID: 'ses_source',
826
- directory: '/repo/app',
827
- promptDispatched: true,
828
- }));
829
- } finally {
830
- globalThis.fetch = originalFetch;
831
- }
895
+ messageId: 'msg_branch_point',
896
+ prompt: 'Try the alternative implementation',
897
+ model: 'openai/gpt-5.5',
898
+ agent: 'build',
899
+ variant: 'high',
900
+ })
901
+ .expect(200);
902
+
903
+ expect(sessionForkMock).toHaveBeenCalledWith({
904
+ sessionID: 'ses_source',
905
+ before: 'msg_branch_point',
906
+ });
907
+ expect(response.body).toMatchObject({
908
+ action: 'fork',
909
+ sourceSessionId: 'ses_source',
910
+ sessionId: 'ses_fork',
911
+ directory: '/repo/app',
912
+ promptDispatched: true,
913
+ });
914
+ expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_fork', text: 'Try the alternative implementation' });
915
+ expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
916
+ sessionID: 'ses_fork',
917
+ sourceSessionID: 'ses_source',
918
+ directory: '/repo/app',
919
+ promptDispatched: true,
920
+ }));
832
921
  });
833
922
 
834
923
  it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
835
- const originalFetch = globalThis.fetch;
836
- const fetchMock = vi.fn();
837
- globalThis.fetch = fetchMock;
838
- try {
839
- const { app } = createApp();
840
- await request(app)
841
- .post('/api/openchamber/sessions/ses_source/send')
842
- .send({ directory: '/repo/app' })
843
- .expect(400, { error: 'prompt is required' });
844
- await request(app)
845
- .post('/api/openchamber/sessions/ses_source/fork')
846
- .send({ directory: '/repo/app' })
847
- .expect(400, { error: 'prompt is required' });
848
- expect(fetchMock).not.toHaveBeenCalled();
849
- expect(sessionForkMock).not.toHaveBeenCalled();
850
- } finally {
851
- globalThis.fetch = originalFetch;
852
- }
924
+ const { app } = createApp();
925
+ await request(app)
926
+ .post('/api/openchamber/sessions/ses_source/send')
927
+ .send({ directory: '/repo/app' })
928
+ .expect(400, { error: 'prompt is required' });
929
+ await request(app)
930
+ .post('/api/openchamber/sessions/ses_source/fork')
931
+ .send({ directory: '/repo/app' })
932
+ .expect(400, { error: 'prompt is required' });
933
+
934
+ expect(sessionForkMock).not.toHaveBeenCalled();
935
+ expect(sessionPromptMock).not.toHaveBeenCalled();
853
936
  });
854
937
 
855
938
  it('reports the forked session when prompt dispatch fails', async () => {
856
- const originalFetch = globalThis.fetch;
857
- globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: false, status: 500, text: async () => 'dispatch failed' });
858
- try {
859
- const { app } = createApp();
860
- const response = await request(app)
861
- .post('/api/openchamber/sessions/ses_source/fork')
862
- .send({
863
- directory: '/repo/app',
864
- prompt: 'Try another approach',
865
- model: 'openai/gpt-5.5',
866
- agent: 'build',
867
- variant: 'high',
868
- })
869
- .expect(500);
870
-
871
- expect(response.body).toMatchObject({
872
- partial: true,
873
- partialAction: 'fork-created',
874
- sessionId: 'ses_fork',
939
+ sessionPromptMock.mockRejectedValue(new Error('dispatch failed'));
940
+
941
+ const { app } = createApp();
942
+ const response = await request(app)
943
+ .post('/api/openchamber/sessions/ses_source/fork')
944
+ .send({
875
945
  directory: '/repo/app',
876
- });
877
- } finally {
878
- globalThis.fetch = originalFetch;
879
- }
946
+ prompt: 'Try another approach',
947
+ model: 'openai/gpt-5.5',
948
+ agent: 'build',
949
+ variant: 'high',
950
+ })
951
+ .expect(500);
952
+
953
+ expect(response.body).toMatchObject({
954
+ partial: true,
955
+ partialAction: 'fork-created',
956
+ sessionId: 'ses_fork',
957
+ directory: '/repo/app',
958
+ });
880
959
  });
881
960
 
882
961
  it('does not apply a default variant to an explicitly requested model', async () => {
883
- const originalFetch = globalThis.fetch;
884
- const fetchMock = vi.fn(async (url) => {
885
- const text = String(url);
886
- if (text.includes('/prompt_async')) return { ok: true, text: async () => '' };
887
- if (text.includes('/config/providers')) {
888
- return {
889
- ok: true,
890
- json: async () => ({
891
- providers: [
892
- { id: 'openai', models: { requested: { id: 'requested' }, default: { id: 'default', variants: { high: {} } } } },
893
- ],
894
- }),
895
- };
896
- }
897
- if (text.includes('/agent')) return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
898
- if (text.includes('/config')) return { ok: true, json: async () => ({}) };
899
- return { ok: true, json: async () => ({ id: 'ses_123' }) };
962
+ useCatalog({
963
+ models: [
964
+ { id: 'requested', modelID: 'requested', providerID: 'openai', variants: [] },
965
+ { id: 'default', modelID: 'default', providerID: 'openai', variants: [{ id: 'high' }] },
966
+ ],
967
+ });
968
+ const { app } = createApp({
969
+ readSettingsFromDiskMigrated: async () => ({
970
+ defaultModel: 'openai/default',
971
+ defaultVariant: 'high',
972
+ projects: [{ id: 'proj_1', path: '/repo/app' }],
973
+ }),
974
+ });
975
+ await request(app)
976
+ .post('/api/openchamber/sessions/ses_source/send')
977
+ .send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
978
+ .expect(200);
979
+
980
+ expect(sessionSwitchModelMock).toHaveBeenCalledWith({
981
+ sessionID: 'ses_source',
982
+ model: { id: 'requested', providerID: 'openai' },
900
983
  });
901
- globalThis.fetch = fetchMock;
902
- try {
903
- const { app } = createApp({
904
- readSettingsFromDiskMigrated: async () => ({
905
- defaultModel: 'openai/default',
906
- defaultVariant: 'high',
907
- projects: [{ id: 'proj_1', path: '/repo/app' }],
908
- }),
909
- });
910
- await request(app)
911
- .post('/api/openchamber/sessions/ses_source/send')
912
- .send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
913
- .expect(200);
914
-
915
- const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
916
- expect(JSON.parse(promptCall[1].body)).not.toHaveProperty('variant');
917
- } finally {
918
- globalThis.fetch = originalFetch;
919
- }
920
984
  });
921
985
 
922
986
  it('rejects an unknown agent before creating a session or worktree', async () => {
923
- const originalFetch = globalThis.fetch;
924
- const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
925
- globalThis.fetch = fetchMock;
926
- try {
927
- const { app } = createApp();
928
- await request(app)
929
- .post('/api/openchamber/sessions')
930
- .send({
931
- directory: '/repo/app',
932
- prompt: 'Run this',
933
- agent: 'not-an-agent',
934
- worktree: { name: 'side-task' },
935
- })
936
- .expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
937
-
938
- expect(createWorktreeMock).not.toHaveBeenCalled();
939
- expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false);
940
- expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
941
- } finally {
942
- globalThis.fetch = originalFetch;
943
- }
987
+ const { app } = createApp();
988
+ await request(app)
989
+ .post('/api/openchamber/sessions')
990
+ .send({
991
+ directory: '/repo/app',
992
+ prompt: 'Run this',
993
+ agent: 'not-an-agent',
994
+ worktree: { name: 'side-task' },
995
+ })
996
+ .expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
997
+
998
+ expect(createWorktreeMock).not.toHaveBeenCalled();
999
+ expect(sessionCreateMock).not.toHaveBeenCalled();
1000
+ expect(sessionPromptMock).not.toHaveBeenCalled();
944
1001
  });
945
1002
 
946
1003
  it('rejects an unknown model and an unknown variant before dispatching', async () => {
947
- const originalFetch = globalThis.fetch;
948
- const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
949
- globalThis.fetch = fetchMock;
950
- try {
951
- const { app } = createApp();
952
- await request(app)
953
- .post('/api/openchamber/sessions')
954
- .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
955
- .expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
956
- await request(app)
957
- .post('/api/openchamber/sessions')
958
- .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
959
- .expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
960
-
961
- expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
962
- } finally {
963
- globalThis.fetch = originalFetch;
964
- }
1004
+ const { app } = createApp();
1005
+ await request(app)
1006
+ .post('/api/openchamber/sessions')
1007
+ .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
1008
+ .expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
1009
+ await request(app)
1010
+ .post('/api/openchamber/sessions')
1011
+ .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
1012
+ .expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
1013
+
1014
+ expect(sessionPromptMock).not.toHaveBeenCalled();
965
1015
  });
966
1016
 
967
- it('reports promptDispatched false when the accepted prompt never reaches the session', async () => {
968
- const originalFetch = globalThis.fetch;
969
- const fetchMock = vi.fn(async (url) => {
970
- if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
971
- return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) };
972
- });
973
- globalThis.fetch = fetchMock;
974
- sessionMessagesMock.mockResolvedValue({ data: [] });
975
- try {
976
- const { app } = createApp();
977
- const response = await request(app)
978
- .post('/api/openchamber/sessions')
979
- .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
980
- .expect(200);
1017
+ it('reports promptDispatched false when the accepted prompt returns no queued message', async () => {
1018
+ // v2 answers a prompt with the inbox item it recorded; no id means nothing
1019
+ // is queued, so the dispatch must not be reported as done.
1020
+ sessionPromptMock.mockResolvedValue({});
981
1021
 
982
- expect(response.body.sessionId).toBe('ses_123');
983
- expect(response.body.promptDispatched).toBe(false);
984
- expect(response.body.promptError).toBeTruthy();
985
- } finally {
986
- globalThis.fetch = originalFetch;
987
- }
1022
+ const { app } = createApp();
1023
+ const response = await request(app)
1024
+ .post('/api/openchamber/sessions')
1025
+ .send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
1026
+ .expect(200);
1027
+
1028
+ expect(response.body.sessionId).toBe('ses_123');
1029
+ expect(response.body.promptDispatched).toBe(false);
1030
+ expect(response.body.promptError).toBeTruthy();
988
1031
  }, 20_000);
989
1032
 
990
1033
  it('does not retry a failed slash command as a normal prompt', async () => {
991
- const originalFetch = globalThis.fetch;
992
- const fetchMock = vi.fn(async (url) => selectionInputResponse(url));
993
1034
  commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
994
1035
  sessionCommandMock.mockRejectedValue(new Error('command response failed'));
995
- globalThis.fetch = fetchMock;
996
- try {
997
- const { app } = createApp();
998
- await request(app)
999
- .post('/api/openchamber/sessions/ses_source/send')
1000
- .send({
1001
- directory: '/repo/app',
1002
- prompt: '/review fix this',
1003
- model: 'openai/gpt-5.5',
1004
- agent: 'build',
1005
- variant: 'high',
1006
- })
1007
- .expect(500);
1008
-
1009
- expect(sessionCommandMock).toHaveBeenCalledTimes(1);
1010
- expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
1011
- } finally {
1012
- globalThis.fetch = originalFetch;
1013
- }
1036
+
1037
+ const { app } = createApp();
1038
+ await request(app)
1039
+ .post('/api/openchamber/sessions/ses_source/send')
1040
+ .send({
1041
+ directory: '/repo/app',
1042
+ prompt: '/review fix this',
1043
+ model: 'openai/gpt-5.5',
1044
+ agent: 'build',
1045
+ variant: 'high',
1046
+ })
1047
+ .expect(500);
1048
+
1049
+ expect(sessionCommandMock).toHaveBeenCalledTimes(1);
1050
+ expect(sessionPromptMock).not.toHaveBeenCalled();
1014
1051
  });
1015
1052
  });