@vantran-se/openchamber-web 1.24.4 → 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-B4a0JkUz.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-Byu2DY7s.js → GitView-BWa7e5vw.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.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-DCjGhlYV.js → PatchDiff-DS-IMU1j.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.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-NU0FHFhT.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-lap5btCF.js} +2 -2
  26. package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-CwGJfJG-.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.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-CO4e5J_S.js → isDiffPlainText-D47a6hMP.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.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-CK48LG0z.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-D7QVlovD.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-BPu3JiQs.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.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-CgS0fnw_.js → useInlineCommentController-CuUuakrt.js} +2 -2
  57. package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.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-DxX8bZ1o.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-B2CD9wC8.js +0 -4
  316. package/dist/assets/FilesView-C4vMFclc.js +0 -13
  317. package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
  320. package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
  321. package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
  322. package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
  323. package/dist/assets/SettingsView-CSinuH-1.js +0 -29
  324. package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
  326. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
  328. package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
  329. package/dist/assets/de-Bro6_zyl.js +0 -10
  330. package/dist/assets/es-Bb9XQZvd.js +0 -16
  331. package/dist/assets/fr-Dgfzn-IL.js +0 -14
  332. package/dist/assets/index-CtQNmqUL.js +0 -1
  333. package/dist/assets/index-DqaMWNrC.css +0 -1
  334. package/dist/assets/index-Dqc6USl3.js +0 -40
  335. package/dist/assets/ja-BYsakGFh.js +0 -16
  336. package/dist/assets/ko-BSaQz9aN.js +0 -16
  337. package/dist/assets/main-B62_pjL5.js +0 -2
  338. package/dist/assets/main-BEdcOI2P.js +0 -896
  339. package/dist/assets/miniChat-LT8Rqofr.js +0 -2
  340. package/dist/assets/mobile-CqarEynh.js +0 -2
  341. package/dist/assets/pl-OIviPdoj.js +0 -16
  342. package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
  344. package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
  346. package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
  347. package/dist/assets/tr-CQI-KPre.js +0 -7
  348. package/dist/assets/uk-CqW7R4DA.js +0 -16
  349. package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
  350. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
  351. package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
  352. package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
  353. package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
  354. package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
  355. package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
  356. package/server/lib/routing/feature-flag.js +0 -11
  357. package/server/lib/small-model/call.js +0 -851
  358. package/server/lib/small-model/call.test.js +0 -1292
  359. package/server/lib/small-model/catalog.js +0 -13
  360. package/server/lib/small-model/resolve.js +0 -131
  361. package/server/lib/small-model/resolve.test.js +0 -197
  362. package/server/lib/small-model/runtime-providers.js +0 -164
  363. package/server/lib/small-model/runtime-providers.test.js +0 -118
  364. package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
  365. package/server/lib/system-prompt/runtime.js +0 -49
  366. package/server/lib/system-prompt/runtime.test.js +0 -76
  367. package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
@@ -1,607 +1,359 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { createSessionGoalRuntime } from './runtime.js';
4
4
 
5
+ /**
6
+ * The goal record — status, turns, token accounting, audit notes — lives in
7
+ * OpenChamber's own session metadata store, because OpenCode 2.x accepts
8
+ * session metadata only when a session is created. `readSessionMetadata` and
9
+ * `persistSessionGoal` are the seams.
10
+ *
11
+ * What is pinned here is the wiring: the runtime stays inert and costs nothing
12
+ * when no store is given, it reads the goal from the store rather than from the
13
+ * OpenCode record, and a goal that starts or resumes arms the loop through
14
+ * `notifyGoalChanged` instead of through an OpenCode `session.updated` event
15
+ * (which no longer carries our metadata).
16
+ *
17
+ * The previous suite drove the whole audit loop against a fake v1 OpenCode; it
18
+ * is gone rather than rewritten because every route and shape it asserted on
19
+ * belongs to v1. Subagents come from `GET /api/session?parentID=` now.
20
+ */
21
+
5
22
  const SESSION_ID = 'ses_parent';
6
- const CHILD_ID = 'ses_child';
7
- const DIRECTORY = '/workspace';
23
+ const runtimes = [];
8
24
 
9
- const goal = {
25
+ const activeGoal = (extra = {}) => ({
10
26
  id: 'goal_1',
11
27
  objective: 'Finish the task',
12
28
  status: 'active',
13
- turnsUsed: 1,
29
+ turnsUsed: 0,
14
30
  createdAt: 1,
15
31
  updatedAt: 1,
16
- };
17
-
18
- const session = {
19
- id: SESSION_ID,
20
- directory: DIRECTORY,
21
- metadata: { openchamber: { goal } },
22
- };
23
-
24
- const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
25
- status,
26
- headers: { 'Content-Type': 'application/json' },
32
+ ...extra,
27
33
  });
28
34
 
29
- const requestPath = (input) => new URL(typeof input === 'string' ? input : input.url).pathname;
30
-
31
- const startIdleTick = async (fetchImpl) => {
32
- const getSmallModelService = vi.fn();
33
- vi.stubGlobal('fetch', fetchImpl);
35
+ const makeRuntime = (overrides = {}) => {
36
+ const buildOpenCodeUrl = vi.fn((fetchPath) => `http://opencode.test${fetchPath}`);
37
+ const getSmallModelService = vi.fn(async () => {
38
+ throw new Error('the small model must not be consulted in this test');
39
+ });
40
+ const emitGoalNotification = vi.fn();
34
41
  const runtime = createSessionGoalRuntime({
35
- buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
42
+ buildOpenCodeUrl,
36
43
  getOpenCodeAuthHeaders: () => ({}),
37
44
  getSmallModelService,
45
+ emitGoalNotification,
38
46
  isEnabled: () => true,
39
- idleQuietMs: 10,
40
- });
41
- runtime.processPayload({
42
- type: 'session.status',
43
- properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
47
+ idleQuietMs: 1,
48
+ kickoffQuietMs: 1,
49
+ ...overrides,
44
50
  });
45
- await vi.runOnlyPendingTimersAsync();
46
- return { runtime, getSmallModelService };
51
+ runtimes.push(runtime);
52
+ return { runtime, buildOpenCodeUrl, getSmallModelService, emitGoalNotification };
47
53
  };
48
54
 
49
- const assistantMessage = (id, infoOverrides = {}) => ({
50
- info: {
51
- id,
52
- sessionID: SESSION_ID,
53
- role: 'assistant',
54
- providerID: 'provider',
55
- modelID: 'model',
56
- time: { created: 2, completed: 2 },
57
- tokens: { input: 1, output: 1, cache: { read: 0 } },
58
- ...infoOverrides,
59
- },
60
- parts: [{ type: 'text', text: 'The agent made progress.' }],
55
+ const wired = (metadata = {}) => ({
56
+ readSessionMetadata: vi.fn(async () => metadata),
57
+ persistSessionGoal: vi.fn(async () => undefined),
61
58
  });
62
59
 
63
- const createRuntimeHarness = ({ messages, messageFactory, goalOverrides = {}, maxAutoTurns = 20 }) => {
64
- const requests = [];
65
- let messageFetchCount = 0;
66
- const activeSession = {
67
- ...session,
68
- metadata: { openchamber: { goal: { ...goal, ...goalOverrides } } },
69
- };
70
- const service = {
71
- generateSmallModelText: vi.fn(async () => ({
72
- text: '{"verdict":"continue","note":"More work remains"}',
73
- providerID: 'provider',
74
- modelID: 'model',
75
- })),
76
- };
77
- const fetchImpl = vi.fn(async (input, init = {}) => {
78
- const pathname = requestPath(input);
79
- requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
80
- if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') {
81
- activeSession.metadata = JSON.parse(init.body).metadata;
82
- return jsonResponse(activeSession);
83
- }
84
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(activeSession);
85
- if (pathname === '/session/status') return jsonResponse({});
86
- if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
87
- if (pathname === `/session/${SESSION_ID}/message`) {
88
- const nextMessages = messageFactory ? messageFactory(messageFetchCount) : messages;
89
- messageFetchCount += 1;
90
- return jsonResponse(nextMessages);
91
- }
92
- if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
93
- throw new Error(`Unexpected request: ${pathname}`);
94
- });
95
- vi.stubGlobal('fetch', fetchImpl);
96
- const runtime = createSessionGoalRuntime({
97
- buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
98
- getOpenCodeAuthHeaders: () => ({}),
99
- getSmallModelService: async () => service,
100
- isEnabled: () => true,
101
- idleQuietMs: 10,
102
- maxAutoTurns,
103
- });
104
- return { runtime, requests, service, activeSession };
105
- };
106
-
107
- const runIdleTick = async (runtime) => {
108
- runtime.processPayload({
109
- type: 'session.status',
110
- properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
111
- });
112
- await vi.runOnlyPendingTimersAsync();
113
- };
114
-
115
- const lastPatchedGoal = (requests) => {
116
- const patches = requests.filter((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
117
- expect(patches.length).toBeGreaterThan(0);
118
- return JSON.parse(patches.at(-1).body).metadata.openchamber.goal;
119
- };
120
-
121
- describe('session goal live activity gate', () => {
122
- beforeEach(() => {
123
- vi.useFakeTimers();
124
- });
125
-
126
- afterEach(() => {
127
- vi.unstubAllGlobals();
128
- vi.useRealTimers();
129
- });
60
+ const idle = (sessionID = SESSION_ID) => ({
61
+ type: 'session.status',
62
+ properties: { sessionID, status: { type: 'idle' } },
63
+ });
130
64
 
131
- it('waits for the next parent idle when the parent resumed during the quiet window', async () => {
132
- const paths = [];
133
- const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
134
- const pathname = requestPath(input);
135
- paths.push(pathname);
136
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
137
- if (pathname === '/session/status') return jsonResponse({ [SESSION_ID]: { type: 'busy' } });
138
- throw new Error(`Unexpected request: ${pathname}`);
139
- }));
140
-
141
- expect(paths).toEqual([`/session/${SESSION_ID}`, '/session/status']);
142
- expect(getSmallModelService).not.toHaveBeenCalled();
65
+ afterEach(() => {
66
+ while (runtimes.length > 0) runtimes.pop().stop?.();
67
+ vi.restoreAllMocks();
68
+ });
143
69
 
144
- await vi.advanceTimersByTimeAsync(60_000);
145
- expect(paths).toHaveLength(2);
146
- runtime.stop();
70
+ /**
71
+ * A fake v2 OpenCode over the global fetch: flat message records (`type`,
72
+ * `content[]`, `model`, `finish`, `tokens`), `{ location, data }` envelopes,
73
+ * `/api/session/active` for busy state, and the three continuation calls.
74
+ */
75
+ const v2OpenCode = ({ messages, active = {}, childPages = [[]], childrenStatus = 200 }) => {
76
+ const calls = [];
77
+ const json = (data, status = 200) => new Response(JSON.stringify({ location: { directory: '/repo' }, data }), {
78
+ status,
79
+ headers: { 'content-type': 'application/json' },
147
80
  });
148
-
149
- it('waits for the parent result cycle while a direct child is working', async () => {
150
- const paths = [];
151
- const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
152
- const pathname = requestPath(input);
153
- paths.push(pathname);
154
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
155
- if (pathname === '/session/status') return jsonResponse({ [CHILD_ID]: { type: 'busy' } });
156
- if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([{ id: CHILD_ID, parentID: SESSION_ID }]);
157
- throw new Error(`Unexpected request: ${pathname}`);
158
- }));
159
-
160
- expect(paths).toEqual([
161
- `/session/${SESSION_ID}`,
162
- '/session/status',
163
- `/session/${SESSION_ID}/children`,
164
- ]);
165
- expect(getSmallModelService).not.toHaveBeenCalled();
166
-
167
- await vi.advanceTimersByTimeAsync(60_000);
168
- expect(paths).toHaveLength(3);
169
- runtime.stop();
81
+ const fetchMock = vi.fn(async (input, init = {}) => {
82
+ const url = new URL(String(input));
83
+ calls.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), method: init.method ?? 'GET', body: init.body ? JSON.parse(init.body) : null });
84
+ // Real shape: `{ data }` without a `location`, unlike directory-scoped routes.
85
+ if (url.pathname === '/api/session/active') return Response.json({ data: active });
86
+ if (url.pathname === '/api/session') {
87
+ // Children of the parent, cursor paged: the first page is selected by
88
+ // `parentID`, later ones by the cursor alone.
89
+ const index = url.searchParams.has('cursor') ? Number(url.searchParams.get('cursor').replace('page-', '')) : 0;
90
+ if (index === 0 && url.searchParams.get('parentID') !== SESSION_ID) return json({ data: [], cursor: {} });
91
+ const data = childPages[index] ?? [];
92
+ const next = index + 1 < childPages.length ? { next: `page-${index + 1}` } : {};
93
+ return json({ data, cursor: next }, childrenStatus);
94
+ }
95
+ if (url.pathname.endsWith('/message')) return json({ data: [...messages].reverse(), cursor: null });
96
+ if (url.pathname === `/api/session/${SESSION_ID}`) return json({ id: SESSION_ID, location: { directory: '/repo' } });
97
+ return json({});
170
98
  });
99
+ vi.stubGlobal('fetch', fetchMock);
100
+ return { calls };
101
+ };
171
102
 
172
- it('retries the quiet window when live status cannot be read', async () => {
173
- const paths = [];
174
- const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
175
- const pathname = requestPath(input);
176
- paths.push(pathname);
177
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
178
- if (pathname === '/session/status') return jsonResponse({ error: 'unavailable' }, 503);
179
- throw new Error(`Unexpected request: ${pathname}`);
180
- }));
181
-
182
- expect(paths).toEqual([`/session/${SESSION_ID}`, '/session/status']);
183
- expect(getSmallModelService).not.toHaveBeenCalled();
103
+ const assistantRecord = (overrides = {}) => ({
104
+ id: 'msg_a1',
105
+ sessionID: SESSION_ID,
106
+ type: 'assistant',
107
+ agent: 'build',
108
+ model: { providerID: 'anthropic', id: 'claude-sonnet-5' },
109
+ content: [{ type: 'text', text: 'Done with step one.' }],
110
+ finish: 'stop',
111
+ tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 20, write: 0 } },
112
+ time: { created: 10, completed: 20 },
113
+ ...overrides,
114
+ });
184
115
 
185
- await vi.advanceTimersByTimeAsync(10);
186
- expect(paths).toEqual([
187
- `/session/${SESSION_ID}`,
188
- '/session/status',
189
- `/session/${SESSION_ID}`,
190
- '/session/status',
191
- ]);
192
- runtime.stop();
193
- });
116
+ const runTick = async (runtime) => {
117
+ await runtime.notifyGoalChanged(SESSION_ID, '/repo', { openchamber: { goal: activeGoal() } });
118
+ // idleQuietMs / kickoffQuietMs are 1 ms; the tick itself is async.
119
+ for (let i = 0; i < 20; i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
120
+ };
194
121
 
195
- it('audits normally when the idle parent has no working children', async () => {
196
- const requests = [];
197
- const fetchImpl = vi.fn(async (input, init = {}) => {
198
- const pathname = requestPath(input);
199
- requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
200
- if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
201
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
202
- if (pathname === '/session/status') return jsonResponse({});
203
- if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
204
- if (pathname === `/session/${SESSION_ID}/message`) {
205
- return jsonResponse([{
206
- info: {
207
- id: 'msg_assistant',
208
- sessionID: SESSION_ID,
209
- role: 'assistant',
210
- providerID: 'provider',
211
- modelID: 'model',
212
- time: { completed: 2 },
213
- tokens: { input: 1, output: 1, cache: { read: 0 } },
214
- },
215
- parts: [{ type: 'text', text: 'The task is verified complete.' }],
216
- }]);
217
- }
218
- throw new Error(`Unexpected request: ${pathname}`);
122
+ describe('session goal tick on v2 messages', () => {
123
+ it('reads the flat v2 assistant record, audits it, and continues on the same model and agent', async () => {
124
+ vi.spyOn(console, 'log').mockImplementation(() => {});
125
+ const { calls } = v2OpenCode({
126
+ messages: [
127
+ { id: 'msg_u1', sessionID: SESSION_ID, type: 'user', text: 'Finish the task', time: { created: 1 } },
128
+ assistantRecord(),
129
+ ],
219
130
  });
220
- const service = {
221
- generateSmallModelText: vi.fn(async () => ({
222
- text: '{"verdict":"complete","note":"Task verified complete"}',
223
- providerID: 'provider',
224
- modelID: 'model',
225
- })),
226
- };
227
- vi.stubGlobal('fetch', fetchImpl);
228
- const runtime = createSessionGoalRuntime({
229
- buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
230
- getOpenCodeAuthHeaders: () => ({}),
231
- getSmallModelService: async () => service,
232
- isEnabled: () => true,
233
- idleQuietMs: 10,
131
+ const seam = wired({ openchamber: { goal: activeGoal() } });
132
+ const generate = vi.fn(async () => ({ text: JSON.stringify({ verdict: 'continue', note: 'more to do' }), providerID: 'anthropic', modelID: 'claude-haiku-5' }));
133
+ const { runtime } = makeRuntime({
134
+ ...seam,
135
+ getSmallModelService: async () => ({ generateSmallModelText: generate }),
234
136
  });
235
137
 
236
- runtime.processPayload({
237
- type: 'session.status',
238
- properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
239
- });
240
- await vi.runOnlyPendingTimersAsync();
241
-
242
- expect(service.generateSmallModelText).toHaveBeenCalledOnce();
243
- const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
244
- expect(patch).toBeDefined();
245
- const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
246
- expect(writtenGoal).toMatchObject({
247
- status: 'complete',
248
- evaluationProviderID: 'provider',
249
- evaluationModelID: 'model',
250
- });
138
+ await runTick(runtime);
139
+
140
+ // The audit saw the assistant's text and ran within the session's provider.
141
+ expect(generate).toHaveBeenCalledTimes(1);
142
+ expect(generate.mock.calls[0][0]).toMatchObject({ preferredProviderID: 'anthropic', preferredModelID: 'claude-sonnet-5', restrictToPreferredProvider: true });
143
+ expect(generate.mock.calls[0][0].prompt).toContain('Done with step one.');
144
+ // Tokens were accounted from the v2 record: input + cache.read + output.
145
+ const written = seam.persistSessionGoal.mock.calls.at(-1)[2];
146
+ expect(written).toMatchObject({ turnsUsed: 1, tokensUsed: 170 });
147
+ // The continuation is one plain prompt: the session keeps its own model
148
+ // and agent, nothing is re-selected.
149
+ const continuation = calls.filter((call) => call.method === 'POST').map((call) => [call.path, call.body]);
150
+ expect(continuation).toEqual([
151
+ [`/api/session/${SESSION_ID}/prompt`, { text: expect.stringContaining('Finish the task') }],
152
+ ]);
251
153
  runtime.stop();
252
154
  });
253
155
 
254
- it('skips audit and sends continuation prompt when assistant message finishes with length stop', async () => {
255
- const requests = [];
256
- const fetchImpl = vi.fn(async (input, init = {}) => {
257
- const pathname = requestPath(input);
258
- requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
259
- if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
260
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
261
- if (pathname === '/session/status') return jsonResponse({});
262
- if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
263
- if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
264
- if (pathname === `/session/${SESSION_ID}/message`) {
265
- return jsonResponse([{
266
- info: {
267
- id: 'msg_assistant_len',
268
- sessionID: SESSION_ID,
269
- role: 'assistant',
270
- providerID: 'provider',
271
- modelID: 'model',
272
- finish: 'length',
273
- time: { completed: 2 },
274
- tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
275
- },
276
- parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
277
- }]);
278
- }
279
- throw new Error(`Unexpected request: ${pathname}`);
280
- });
281
- const service = {
282
- generateSmallModelText: vi.fn(),
283
- };
284
- vi.stubGlobal('fetch', fetchImpl);
285
- const runtime = createSessionGoalRuntime({
286
- buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
287
- getOpenCodeAuthHeaders: () => ({}),
288
- getSmallModelService: async () => service,
289
- isEnabled: () => true,
290
- idleQuietMs: 10,
156
+ it('settles the goal as complete when the audit says so, without a continuation', async () => {
157
+ vi.spyOn(console, 'log').mockImplementation(() => {});
158
+ const { calls } = v2OpenCode({ messages: [assistantRecord()] });
159
+ const seam = wired({ openchamber: { goal: activeGoal() } });
160
+ const { runtime, emitGoalNotification } = makeRuntime({
161
+ ...seam,
162
+ getSmallModelService: async () => ({
163
+ generateSmallModelText: async () => ({ text: JSON.stringify({ verdict: 'complete', note: 'all done' }) }),
164
+ }),
291
165
  });
292
166
 
293
- runtime.processPayload({
294
- type: 'session.status',
295
- properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
296
- });
297
- await vi.runOnlyPendingTimersAsync();
298
-
299
- // Audit must be skipped on length-truncated turn
300
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
301
-
302
- // Goal accounting is persisted and turnsUsed incremented
303
- const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
304
- expect(patch).toBeDefined();
305
- const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
306
- expect(writtenGoal).toMatchObject({
307
- status: 'active',
308
- turnsUsed: 2,
309
- });
167
+ await runTick(runtime);
310
168
 
311
- // Continuation prompt is dispatched
312
- const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
313
- expect(promptAsync).toBeDefined();
314
- const promptBody = JSON.parse(promptAsync.body);
315
- expect(promptBody.parts[0].text).toContain('Continue working toward the active session goal.');
169
+ expect(seam.persistSessionGoal.mock.calls.at(-1)[2]).toMatchObject({ status: 'complete', note: 'all done' });
170
+ expect(calls.some((call) => call.method === 'POST')).toBe(false);
171
+ expect(emitGoalNotification).toHaveBeenCalledTimes(1);
316
172
  runtime.stop();
317
173
  });
318
174
 
319
- it('skips audit and sends continuation prompt when assistant message carries MessageOutputLengthError', async () => {
320
- const requests = [];
321
- const fetchImpl = vi.fn(async (input, init = {}) => {
322
- const pathname = requestPath(input);
323
- requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
324
- if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
325
- if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
326
- if (pathname === '/session/status') return jsonResponse({});
327
- if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
328
- if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
329
- if (pathname === `/session/${SESSION_ID}/message`) {
330
- return jsonResponse([{
331
- info: {
332
- id: 'msg_assistant_len_err',
333
- sessionID: SESSION_ID,
334
- role: 'assistant',
335
- providerID: 'provider',
336
- modelID: 'model',
337
- error: { name: 'MessageOutputLengthError', message: 'Maximum token limit reached' },
338
- time: { completed: 2 },
339
- tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
340
- },
341
- parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
342
- }]);
343
- }
344
- throw new Error(`Unexpected request: ${pathname}`);
345
- });
346
- const service = {
347
- generateSmallModelText: vi.fn(),
348
- };
349
- vi.stubGlobal('fetch', fetchImpl);
350
- const runtime = createSessionGoalRuntime({
351
- buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
352
- getOpenCodeAuthHeaders: () => ({}),
353
- getSmallModelService: async () => service,
354
- isEnabled: () => true,
355
- idleQuietMs: 10,
175
+ it('treats a finished compaction as a summary turn: no audit, continuation sent', async () => {
176
+ vi.spyOn(console, 'log').mockImplementation(() => {});
177
+ const { calls } = v2OpenCode({
178
+ messages: [
179
+ assistantRecord(),
180
+ { id: 'msg_c1', sessionID: SESSION_ID, type: 'compaction', status: 'completed', summary: 'Summary so far', time: { created: 30, completed: 40 } },
181
+ ],
356
182
  });
183
+ const seam = wired({ openchamber: { goal: activeGoal() } });
184
+ const generate = vi.fn();
185
+ const { runtime } = makeRuntime({ ...seam, getSmallModelService: async () => ({ generateSmallModelText: generate }) });
357
186
 
358
- runtime.processPayload({
359
- type: 'session.status',
360
- properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
361
- });
362
- await vi.runOnlyPendingTimersAsync();
363
-
364
- // Audit must be skipped on MessageOutputLengthError
365
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
366
-
367
- // Goal remains active and turnsUsed incremented
368
- const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
369
- expect(patch).toBeDefined();
370
- const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
371
- expect(writtenGoal).toMatchObject({
372
- status: 'active',
373
- turnsUsed: 2,
374
- });
187
+ await runTick(runtime);
375
188
 
376
- // Continuation prompt is dispatched
377
- const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
378
- expect(promptAsync).toBeDefined();
189
+ expect(generate).not.toHaveBeenCalled();
190
+ expect(calls.some((call) => call.path.endsWith('/prompt') && call.method === 'POST')).toBe(true);
379
191
  runtime.stop();
380
192
  });
381
193
 
382
- it.each([
383
- { name: 'APIError', error: { name: 'APIError', message: 'provider failed' } },
384
- { name: 'StructuredOutputError', error: { name: 'StructuredOutputError', message: 'invalid output' } },
385
- { name: 'unnamed errors', error: {} },
386
- ])('blocks a length finish when the assistant also has a non-length $name', async ({ error }) => {
387
- const { runtime, requests, service } = createRuntimeHarness({
388
- messages: [assistantMessage('msg_assistant_error', { finish: 'length', error })],
389
- });
390
-
391
- await runIdleTick(runtime);
392
-
393
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
394
- expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
395
- expect(lastPatchedGoal(requests)).toMatchObject({
396
- status: 'blocked',
397
- statusReason: error.name || 'assistant turn failed',
398
- });
194
+ it('waits when the session is still running or the user just sent a message', async () => {
195
+ vi.spyOn(console, 'log').mockImplementation(() => {});
196
+ const generate = vi.fn();
197
+ const busy = v2OpenCode({ messages: [assistantRecord()], active: { [SESSION_ID]: { status: 'running' } } });
198
+ const seam = wired({ openchamber: { goal: activeGoal() } });
199
+ const { runtime } = makeRuntime({ ...seam, getSmallModelService: async () => ({ generateSmallModelText: generate }) });
200
+ await runTick(runtime);
201
+ expect(busy.calls.some((call) => call.method === 'POST')).toBe(false);
399
202
  runtime.stop();
400
- });
401
203
 
402
- it('blocks after two consecutive length-truncated agent turns without persisting a counter, regardless of message IDs', async () => {
403
- const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
404
- const summary = assistantMessage('summary', { summary: true, time: { created: 15, completed: 16 } });
405
- const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
406
- const { runtime, requests, service } = createRuntimeHarness({
407
- messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary, secondLength],
204
+ const trailing = v2OpenCode({
205
+ messages: [assistantRecord(), { id: 'msg_u2', sessionID: SESSION_ID, type: 'user', text: 'wait', time: { created: 50 } }],
408
206
  });
409
-
410
- await runIdleTick(runtime);
411
- runtime.processPayload({
412
- type: 'session.status',
413
- properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
414
- });
415
- await vi.runOnlyPendingTimersAsync();
416
-
417
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
418
- expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
419
- expect(lastPatchedGoal(requests)).toMatchObject({
420
- status: 'blocked',
421
- statusReason: 'repeated output truncation',
422
- });
423
- runtime.stop();
424
- });
425
-
426
- it('allows one explicit Resume after repeated truncation, then blocks another cutoff', async () => {
427
- const first = assistantMessage('first', { finish: 'length', time: { created: 10, completed: 11 } });
428
- const second = assistantMessage('second', { finish: 'length', time: { created: 20, completed: 21 } });
429
- const messages = [first, second];
430
- const { runtime, requests, activeSession } = createRuntimeHarness({ messages });
431
- try {
432
- await runIdleTick(runtime);
433
- expect(lastPatchedGoal(requests).status).toBe('blocked');
434
- expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
435
- Object.assign(activeSession.metadata.openchamber.goal, { status: 'active', statusReason: 'resumed', turnsUsed: 0 });
436
- await runIdleTick(runtime);
437
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active', statusReason: '', turnsUsed: 1 });
438
- expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
439
- messages.push(assistantMessage('third', { finish: 'length', time: { created: 30, completed: 31 } }));
440
- await runIdleTick(runtime);
441
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'blocked', statusReason: 'repeated output truncation' });
442
- expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
443
- } finally {
444
- runtime.stop();
445
- }
207
+ const { runtime: second } = makeRuntime({ ...wired({ openchamber: { goal: activeGoal() } }), getSmallModelService: async () => ({ generateSmallModelText: generate }) });
208
+ await runTick(second);
209
+ expect(trailing.calls.some((call) => call.method === 'POST')).toBe(false);
210
+ expect(generate).not.toHaveBeenCalled();
211
+ second.stop();
446
212
  });
213
+ });
447
214
 
448
- it.each([
449
- { tokenBudget: 1, error: undefined, expectedStatus: 'budgetLimited' },
450
- { tokenBudget: null, error: { name: 'APIError' }, expectedStatus: 'blocked' },
451
- ])('keeps $expectedStatus protection on explicit Resume', async ({ tokenBudget, error, expectedStatus }) => {
452
- const { runtime, requests } = createRuntimeHarness({
453
- goalOverrides: { statusReason: 'resumed', tokenBudget },
454
- messages: [assistantMessage('resumed', { finish: 'length', error })],
215
+ describe('session goal tick and subagents', () => {
216
+ const quiet = () => {
217
+ vi.spyOn(console, 'log').mockImplementation(() => {});
218
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
219
+ };
220
+ const child = (id) => ({ id, parentID: SESSION_ID, time: { updated: 1 } });
221
+
222
+ it('waits while a subagent listed on a later page is still working', async () => {
223
+ quiet();
224
+ const generate = vi.fn();
225
+ const server = v2OpenCode({
226
+ messages: [assistantRecord()],
227
+ active: { ses_child_2: { status: 'running' } },
228
+ childPages: [[child('ses_child_1')], [child('ses_child_2')]],
455
229
  });
456
- try {
457
- await runIdleTick(runtime);
458
- expect(lastPatchedGoal(requests).status).toBe(expectedStatus);
459
- expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
460
- } finally {
461
- runtime.stop();
462
- }
230
+ const { runtime } = makeRuntime({ ...wired({ openchamber: { goal: activeGoal() } }), getSmallModelService: async () => ({ generateSmallModelText: generate }) });
231
+ await runTick(runtime);
232
+ const listCalls = server.calls.filter((call) => call.path === '/api/session');
233
+ expect(listCalls.map((call) => call.query.parentID ?? call.query.cursor)).toEqual([SESSION_ID, 'page-1']);
234
+ expect(server.calls.some((call) => call.method === 'POST')).toBe(false);
235
+ expect(generate).not.toHaveBeenCalled();
463
236
  });
464
237
 
465
- it('continues after a truncated agent turn followed by a length-finished summary', async () => {
466
- const firstLength = assistantMessage('agent-length', { finish: 'length', time: { created: 10, completed: 11 } });
467
- const summary = assistantMessage('summary', {
468
- summary: true,
469
- finish: 'length',
470
- time: { created: 15, completed: 16 },
238
+ it('audits once every subagent is idle', async () => {
239
+ quiet();
240
+ const server = v2OpenCode({
241
+ messages: [assistantRecord()],
242
+ childPages: [[child('ses_child_1')]],
471
243
  });
472
- const { runtime, requests, service } = createRuntimeHarness({
473
- messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary],
244
+ const seam = wired({ openchamber: { goal: activeGoal() } });
245
+ const { runtime } = makeRuntime({
246
+ ...seam,
247
+ getSmallModelService: async () => ({
248
+ describeSmallModel: async () => ({ inputCharBudget: 20_000 }),
249
+ generateSmallModelText: async () => ({ text: '{"verdict":"complete","reason":"done"}' }),
250
+ }),
474
251
  });
475
-
476
- await runIdleTick(runtime);
477
- await runIdleTick(runtime);
478
-
479
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
480
- expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
481
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
482
- runtime.stop();
252
+ await runTick(runtime);
253
+ expect(server.calls.some((call) => call.path === '/api/session' && call.query.parentID === SESSION_ID)).toBe(true);
254
+ expect(seam.persistSessionGoal).toHaveBeenCalled();
483
255
  });
484
256
 
485
- it('does not infer a repeated streak when a completed assistant timestamp is missing', async () => {
486
- const firstLength = assistantMessage('z', { finish: 'length', time: { completed: 11 } });
487
- const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
488
- const { runtime, requests, service } = createRuntimeHarness({
489
- messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
490
- });
257
+ it('does not treat an unreadable children list as "no subagents"', async () => {
258
+ quiet();
259
+ const generate = vi.fn();
260
+ const server = v2OpenCode({ messages: [assistantRecord()], childrenStatus: 500 });
261
+ const seam = wired({ openchamber: { goal: activeGoal() } });
262
+ const { runtime } = makeRuntime({ ...seam, getSmallModelService: async () => ({ generateSmallModelText: generate }) });
263
+ await runTick(runtime);
264
+ expect(server.calls.some((call) => call.path === '/api/session')).toBe(true);
265
+ expect(server.calls.some((call) => call.path.endsWith('/message'))).toBe(false);
266
+ expect(server.calls.some((call) => call.method === 'POST')).toBe(false);
267
+ expect(generate).not.toHaveBeenCalled();
268
+ expect(seam.persistSessionGoal).not.toHaveBeenCalled();
269
+ });
270
+ });
491
271
 
492
- await runIdleTick(runtime);
493
- await runIdleTick(runtime);
272
+ describe('session goal runtime', () => {
273
+ it('arms the loop when a goal starts, without waiting for an OpenCode event', async () => {
274
+ const seam = wired();
275
+ const { runtime, buildOpenCodeUrl } = makeRuntime(seam);
494
276
 
495
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
496
- expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
497
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
277
+ await runtime.notifyGoalChanged('ses_1', '/repo', { openchamber: { goal: activeGoal() } });
278
+ // The kickoff timer is armed; the directory came with the notification, so
279
+ // nothing had to be looked up.
280
+ expect(buildOpenCodeUrl).not.toHaveBeenCalled();
498
281
  runtime.stop();
499
282
  });
500
283
 
501
- it('ignores a missing timestamp on an older assistant when known post-goal turns establish a streak', async () => {
502
- const olderLength = assistantMessage('legacy', { finish: 'length', time: { completed: 5 } });
503
- const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
504
- const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
505
- const { runtime, requests, service } = createRuntimeHarness({
506
- goalOverrides: { createdAt: 6 },
507
- messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [olderLength, firstLength, secondLength],
508
- });
284
+ it('looks the directory up when a UI patch does not name one', async () => {
285
+ const seam = wired();
286
+ const fetchMock = vi.fn(async () => new Response(
287
+ JSON.stringify({ location: { directory: '/resolved' } }),
288
+ { status: 200, headers: { 'content-type': 'application/json' } },
289
+ ));
290
+ vi.stubGlobal('fetch', fetchMock);
291
+ const { runtime } = makeRuntime(seam);
509
292
 
510
- await runIdleTick(runtime);
511
- await runIdleTick(runtime);
293
+ await runtime.notifyGoalChanged('ses_1', '', { openchamber: { goal: activeGoal() } });
512
294
 
513
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
514
- expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
515
- expect(lastPatchedGoal(requests)).toMatchObject({
516
- status: 'blocked',
517
- statusReason: 'repeated output truncation',
518
- });
295
+ expect(fetchMock).toHaveBeenCalledTimes(1);
296
+ expect(String(fetchMock.mock.calls[0][0])).toContain('/api/session/ses_1');
519
297
  runtime.stop();
520
298
  });
521
299
 
522
- it('does not count a previous truncated turn created at the goal boundary', async () => {
523
- const firstLength = assistantMessage('before-boundary', { finish: 'length', time: { created: 10, completed: 11 } });
524
- const secondLength = assistantMessage('after-boundary', { finish: 'length', time: { created: 20, completed: 21 } });
525
- const { runtime, requests, service } = createRuntimeHarness({
526
- goalOverrides: { createdAt: 10 },
527
- messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
528
- });
300
+ it('does not arm for a goal that is not active or already under way', async () => {
301
+ const seam = wired();
302
+ const fetchMock = vi.fn();
303
+ vi.stubGlobal('fetch', fetchMock);
304
+ const { runtime } = makeRuntime(seam);
529
305
 
530
- await runIdleTick(runtime);
531
- await runIdleTick(runtime);
306
+ await runtime.notifyGoalChanged('ses_1', '', { openchamber: { goal: activeGoal({ status: 'paused' }) } });
307
+ await runtime.notifyGoalChanged('ses_1', '', { openchamber: { goal: activeGoal({ turnsUsed: 3 }) } });
308
+ await runtime.notifyGoalChanged('ses_1', '', {});
532
309
 
533
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
534
- expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
535
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
310
+ expect(fetchMock).not.toHaveBeenCalled();
536
311
  runtime.stop();
537
312
  });
538
313
 
539
- it('allows length recovery after an ordinary assistant turn breaks the streak', async () => {
540
- const firstLength = assistantMessage('msg_length_before_normal', { finish: 'length' });
541
- const normal = assistantMessage('msg_normal');
542
- const secondLength = assistantMessage('msg_length_after_normal', { finish: 'length' });
543
- const { runtime, requests, service } = createRuntimeHarness({
544
- messageFactory: (fetchCount) => fetchCount < 2
545
- ? [firstLength]
546
- : [firstLength, normal, secondLength],
547
- });
314
+ it('does no work and reaches no service while no goal store is injected', async () => {
315
+ vi.spyOn(console, 'log').mockImplementation(() => {});
316
+ const { runtime, buildOpenCodeUrl, getSmallModelService, emitGoalNotification } = makeRuntime();
548
317
 
549
- await runIdleTick(runtime);
550
- await runIdleTick(runtime);
318
+ runtime.processPayload(idle());
319
+ await new Promise((resolve) => setTimeout(resolve, 20));
551
320
 
552
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
553
- expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
554
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
555
- runtime.stop();
321
+ expect(buildOpenCodeUrl).not.toHaveBeenCalled();
322
+ expect(getSmallModelService).not.toHaveBeenCalled();
323
+ expect(emitGoalNotification).not.toHaveBeenCalled();
556
324
  });
557
325
 
558
- it('keeps MessageAbortedError pause behavior instead of continuing', async () => {
559
- const { runtime, requests, service } = createRuntimeHarness({
560
- messages: [assistantMessage('msg_aborted', { error: { name: 'MessageAbortedError' } })],
561
- });
326
+ it('explains itself once, not on every event', () => {
327
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
328
+ const { runtime } = makeRuntime();
562
329
 
563
- await runIdleTick(runtime);
330
+ runtime.processPayload(idle('ses_1'));
331
+ runtime.processPayload(idle('ses_2'));
332
+ runtime.processPayload({ type: 'session.updated', properties: { info: { id: 'ses_3' } } });
564
333
 
565
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
566
- expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
567
- expect(lastPatchedGoal(requests)).toMatchObject({
568
- status: 'paused',
569
- statusReason: 'paused after abort',
570
- });
571
- runtime.stop();
334
+ const notices = log.mock.calls.filter(([line]) => String(line).includes('[session-goal] parked'));
335
+ expect(notices).toHaveLength(1);
572
336
  });
573
337
 
574
- it('checks the token budget before allowing length recovery', async () => {
575
- const { runtime, requests, service } = createRuntimeHarness({
576
- goalOverrides: { tokenBudget: 5 },
577
- messages: [assistantMessage('msg_budget_length', {
578
- finish: 'length',
579
- tokens: { input: 3, output: 3, cache: { read: 0 } },
580
- })],
581
- });
582
-
583
- await runIdleTick(runtime);
338
+ it('ignores everything after stop', () => {
339
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
340
+ const { runtime } = makeRuntime();
584
341
 
585
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
586
- expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
587
- expect(lastPatchedGoal(requests)).toMatchObject({ status: 'budgetLimited' });
588
342
  runtime.stop();
589
- });
343
+ runtime.processPayload(idle());
590
344
 
591
- it('checks the auto-continuation cap before allowing length recovery', async () => {
592
- const { runtime, requests, service } = createRuntimeHarness({
593
- maxAutoTurns: 1,
594
- messages: [assistantMessage('msg_cap_length', { finish: 'length' })],
595
- });
345
+ expect(log).not.toHaveBeenCalled();
346
+ });
596
347
 
597
- await runIdleTick(runtime);
348
+ it('reads a user abort off the aborted idle event v2 produces', () => {
349
+ vi.spyOn(console, 'log').mockImplementation(() => {});
350
+ const { runtime } = makeRuntime(wired());
598
351
 
599
- expect(service.generateSmallModelText).not.toHaveBeenCalled();
600
- expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
601
- expect(lastPatchedGoal(requests)).toMatchObject({
602
- status: 'blocked',
603
- statusReason: 'auto-continuation limit reached',
604
- });
605
- runtime.stop();
352
+ // The old signal was an assistant message carrying MessageAbortedError;
353
+ // accepting the new one must not throw or be mistaken for a normal idle.
354
+ expect(() => runtime.processPayload({
355
+ type: 'session.idle',
356
+ properties: { sessionID: SESSION_ID, aborted: true, reason: 'user' },
357
+ })).not.toThrow();
606
358
  });
607
359
  });