@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4

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 (287) hide show
  1. package/README.md +15 -11
  2. package/bin/cli-opencode.test.js +4 -4
  3. package/bin/cli.js +1 -1
  4. package/bin/lib/cli-startup.js +1 -1
  5. package/bin/lib/cli-startup.test.js +18 -4
  6. package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
  7. package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
  10. package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
  11. package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
  16. package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
  20. package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
  22. package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
  24. package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
  25. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  26. package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
  27. package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
  28. package/dist/assets/context-menu-B2LXJgXL.js +1 -0
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
  33. package/dist/assets/es-CN3E_zXj.js +15 -0
  34. package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
  36. package/dist/assets/fr-9zZvQZD2.js +13 -0
  37. package/dist/assets/gitApi-yuWg9BN4.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
  41. package/dist/assets/ja-Dnjk_82N.js +15 -0
  42. package/dist/assets/ko-BxCEE99C.js +15 -0
  43. package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
  44. package/dist/assets/main-DiL6w6KF.js +169 -0
  45. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  46. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
  47. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  48. package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
  49. package/dist/assets/miniChat-CwtUaElU.js +2 -0
  50. package/dist/assets/mobile-DB-6--ws.js +2 -0
  51. package/dist/assets/multirun-TAsxFI8e.js +4 -0
  52. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
  53. package/dist/assets/pl-CNNsWnPM.js +15 -0
  54. package/dist/assets/platform-C13fMVEs.js +6 -0
  55. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  56. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  57. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
  58. package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
  59. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  60. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
  61. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
  62. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
  63. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
  64. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
  65. package/dist/assets/src-ubCWXZhR.css +2 -0
  66. package/dist/assets/store-DLCiZaA6.js +15 -0
  67. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
  68. package/dist/assets/textarea-BBOlRI9O.js +1 -0
  69. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
  70. package/dist/assets/uk-rwQrxeYM.js +15 -0
  71. package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
  72. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
  73. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  74. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
  75. package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
  76. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
  77. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
  78. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  79. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
  80. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  81. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
  83. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  84. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  87. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  88. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  89. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  90. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  91. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  92. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  99. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  100. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  101. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  102. package/dist/index.html +13 -13
  103. package/dist/mini-chat.html +13 -13
  104. package/dist/mobile.html +13 -13
  105. package/package.json +1 -1
  106. package/server/index.js +327 -62
  107. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  108. package/server/lib/assistants/contracts.js +11 -1
  109. package/server/lib/assistants/projection-history.test.js +265 -18
  110. package/server/lib/assistants/routes.js +6 -0
  111. package/server/lib/assistants/service.js +417 -231
  112. package/server/lib/assistants/service.test.js +392 -148
  113. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  114. package/server/lib/conversations/service.js +1 -0
  115. package/server/lib/conversations/service.test.js +16 -0
  116. package/server/lib/conversations/validation.js +11 -1
  117. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +49 -4
  120. package/server/lib/event-stream/reasoning-projection.js +16 -1
  121. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  122. package/server/lib/event-stream/runtime.js +57 -20
  123. package/server/lib/event-stream/runtime.test.js +112 -0
  124. package/server/lib/llm/DOCUMENTATION.md +33 -9
  125. package/server/lib/llm/catalog.js +1 -1
  126. package/server/lib/llm/completions.js +5 -1
  127. package/server/lib/llm/completions.test.js +3 -3
  128. package/server/lib/llm/generate.js +237 -145
  129. package/server/lib/llm/generate.test.js +201 -46
  130. package/server/lib/llm/routes.js +2 -0
  131. package/server/lib/message-queue/runtime.js +15 -2
  132. package/server/lib/notifications/runtime.js +23 -5
  133. package/server/lib/notifications/runtime.test.js +11 -0
  134. package/server/lib/opencode/DOCUMENTATION.md +85 -17
  135. package/server/lib/opencode/agent-document.d.ts +28 -0
  136. package/server/lib/opencode/agent-document.js +280 -0
  137. package/server/lib/opencode/agent-document.test.js +137 -0
  138. package/server/lib/opencode/agents.js +96 -0
  139. package/server/lib/opencode/auth-state-runtime.js +5 -0
  140. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  141. package/server/lib/opencode/config-entity-routes.js +42 -99
  142. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  143. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  144. package/server/lib/opencode/ensure-cli.js +1 -2
  145. package/server/lib/opencode/env-runtime.js +40 -26
  146. package/server/lib/opencode/env-runtime.test.js +10 -17
  147. package/server/lib/opencode/feature-routes-runtime.js +43 -4
  148. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  149. package/server/lib/opencode/http-server-connections.js +98 -0
  150. package/server/lib/opencode/http-server-connections.test.js +321 -0
  151. package/server/lib/opencode/lifecycle.js +349 -39
  152. package/server/lib/opencode/lifecycle.test.js +322 -0
  153. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  154. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  155. package/server/lib/opencode/openchamber-routes.js +7 -1
  156. package/server/lib/opencode/opencode2-pin.js +4 -4
  157. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  158. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  159. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  160. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  161. package/server/lib/opencode/plugin-routes.js +3 -18
  162. package/server/lib/opencode/plugin-routes.test.js +9 -9
  163. package/server/lib/opencode/provider-catalog.js +58 -12
  164. package/server/lib/opencode/provider-catalog.test.js +48 -0
  165. package/server/lib/opencode/proxy.js +252 -46
  166. package/server/lib/opencode/proxy.test.js +235 -2
  167. package/server/lib/opencode/routes.js +296 -62
  168. package/server/lib/opencode/routes.test.js +278 -12
  169. package/server/lib/opencode/runtime-contract.js +401 -0
  170. package/server/lib/opencode/runtime-contract.test.js +214 -0
  171. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  172. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  173. package/server/lib/opencode/server-utils-runtime.js +33 -0
  174. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  175. package/server/lib/opencode/shared-service.js +147 -0
  176. package/server/lib/opencode/shared-service.test.js +93 -0
  177. package/server/lib/opencode/shared.js +3 -3
  178. package/server/lib/opencode/shutdown-runtime.js +15 -6
  179. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  180. package/server/lib/opencode/skill-routes.js +20 -33
  181. package/server/lib/opencode/skill-routes.test.js +35 -2
  182. package/server/lib/opencode/skills.test.js +15 -0
  183. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  184. package/server/lib/opencode/static-routes-runtime.js +3 -1
  185. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  186. package/server/lib/opencode/v2-client.js +14 -3
  187. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  188. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  189. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  190. package/server/lib/permission-auto-accept/runtime.js +14 -7
  191. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  192. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  193. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
  194. package/server/lib/scheduled-tasks/runtime.js +52 -4
  195. package/server/lib/scheduled-tasks/runtime.test.js +80 -1
  196. package/server/lib/session-goal/DOCUMENTATION.md +113 -13
  197. package/server/lib/session-goal/routes.js +9 -0
  198. package/server/lib/session-goal/runtime.js +1579 -117
  199. package/server/lib/session-goal/runtime.test.js +1453 -84
  200. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  201. package/server/lib/session-index/event-ingest.js +89 -16
  202. package/server/lib/session-index/event-ingest.test.js +73 -0
  203. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  204. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  205. package/server/lib/session-index/routes.js +63 -10
  206. package/server/lib/session-index/routes.test.js +8 -8
  207. package/server/lib/session-index/service.js +7 -1
  208. package/server/lib/session-index/sync-runtime.js +161 -34
  209. package/server/lib/session-index/sync-runtime.test.js +155 -1
  210. package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
  211. package/server/lib/session-metadata/routes.js +108 -9
  212. package/server/lib/session-metadata/routes.test.js +47 -2
  213. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  214. package/server/lib/session-metadata/session-archive.js +654 -0
  215. package/server/lib/session-metadata/session-archive.test.js +359 -0
  216. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  217. package/server/lib/session-metadata/session-metadata-store.js +212 -72
  218. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  219. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  220. package/server/lib/session-metadata/session-projection.js +255 -0
  221. package/server/lib/session-metadata/session-projection.test.js +64 -0
  222. package/server/lib/session-metadata/system-session.js +55 -0
  223. package/server/lib/session-metadata/system-session.test.js +55 -0
  224. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  225. package/server/lib/session-title/native-runtime.test.js +167 -0
  226. package/server/lib/session-title/runtime.js +67 -42
  227. package/server/lib/session-title/session-access.js +46 -0
  228. package/server/lib/session-title/session-access.test.js +91 -0
  229. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  230. package/server/lib/session-turn-pages/routes.js +17 -102
  231. package/server/lib/session-turn-pages/routes.test.js +281 -0
  232. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  233. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  234. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  235. package/server/lib/small-model/catalog.js +31 -51
  236. package/server/lib/small-model/catalog.test.js +65 -10
  237. package/server/lib/small-model/custom-api.js +68 -0
  238. package/server/lib/small-model/custom-api.test.js +64 -0
  239. package/server/lib/small-model/index.js +124 -240
  240. package/server/lib/small-model/resolve.js +35 -120
  241. package/server/lib/small-model/resolve.test.js +20 -110
  242. package/server/lib/small-model/routes.js +1 -3
  243. package/server/lib/small-model/summary-settings.test.js +157 -253
  244. package/server/opencode-proxy.test.js +2 -0
  245. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  246. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  247. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  248. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  249. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  250. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  251. package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
  252. package/dist/assets/context-menu-jGtH6fR9.js +0 -1
  253. package/dist/assets/es-c52LTHHe.js +0 -15
  254. package/dist/assets/fr-PisFbUE8.js +0 -13
  255. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  256. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  257. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  258. package/dist/assets/ko-ClFys5o2.js +0 -15
  259. package/dist/assets/main-Dn9rpsgl.js +0 -169
  260. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  261. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  262. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  263. package/dist/assets/multirun-BvSG94mW.js +0 -4
  264. package/dist/assets/pl-CnaKWN5F.js +0 -15
  265. package/dist/assets/platform-CWTIp02q.js +0 -4
  266. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  267. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  268. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  269. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  270. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  271. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  272. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  273. package/dist/assets/src-BzX5UI-c.css +0 -2
  274. package/dist/assets/store-DJczmczv.js +0 -15
  275. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  276. package/dist/assets/uk-CaX0iqkB.js +0 -15
  277. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  278. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  279. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  280. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  281. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  282. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  283. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  284. package/server/lib/small-model/call.js +0 -427
  285. package/server/lib/small-model/call.test.js +0 -382
  286. package/server/lib/small-model/opencode-session.js +0 -183
  287. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -1,5 +1,70 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { accountGoalTokenSpend, createSessionGoalRuntime, messageTokenSpend } from './runtime.js';
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { createSessionMetadataStore } from '../session-metadata/session-metadata-store.js';
3
+ import {
4
+ configureServerOpenCodeFetchGate,
5
+ } from '../opencode/server-opencode-fetch.js';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import {
11
+ accountGoalTokenSpend,
12
+ buildContinuationMessageID,
13
+ buildPendingContinuation,
14
+ createSessionGoalRuntime,
15
+ extractExecutionModel,
16
+ messageTokenSpend,
17
+ parsePendingContinuation,
18
+ projectGoalMessages,
19
+ readGoalExecutionGeneration,
20
+ shouldAdvanceGoalExecutionGeneration,
21
+ unwrapSessionMessages,
22
+ withGoalExecutionGeneration,
23
+ } from './runtime.js';
24
+
25
+ // Host SDK writes share the runtime-contract gate. Unit tests have no lifecycle
26
+ // contract — allow missing so real switchAgent/switchModel/prompt reach fetch fixtures.
27
+ beforeAll(() => {
28
+ configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
29
+ });
30
+ afterAll(() => {
31
+ configureServerOpenCodeFetchGate(null);
32
+ });
33
+
34
+ /** Resolve installed @opencode/schema SessionMessage next to @opencode/client (no extra dep). */
35
+ const loadInstalledSessionMessageSchema = async () => {
36
+ const candidates = [];
37
+ const tryClientLink = (clientLink) => {
38
+ try {
39
+ if (!fs.existsSync(clientLink)) return;
40
+ const clientDir = fs.realpathSync(clientLink);
41
+ candidates.push(path.join(path.dirname(clientDir), 'schema', 'dist', 'session-message.js'));
42
+ } catch {
43
+ // ignore missing link
44
+ }
45
+ };
46
+ // packages/web dependency
47
+ tryClientLink(path.join(process.cwd(), 'node_modules/@opencode/client'));
48
+ tryClientLink(path.join(process.cwd(), 'packages/web/node_modules/@opencode/client'));
49
+ // bun nested install layout
50
+ const bunRoot = path.join(process.cwd(), 'node_modules/.bun');
51
+ if (fs.existsSync(bunRoot)) {
52
+ for (const name of fs.readdirSync(bunRoot)) {
53
+ if (!name.startsWith('@opencode+client@')) continue;
54
+ candidates.push(path.join(
55
+ bunRoot,
56
+ name,
57
+ 'node_modules/@opencode/schema/dist/session-message.js',
58
+ ));
59
+ }
60
+ }
61
+ for (const schemaPath of candidates) {
62
+ if (fs.existsSync(schemaPath)) {
63
+ return import(pathToFileURL(schemaPath).href);
64
+ }
65
+ }
66
+ throw new Error('installed @opencode/schema session-message not found beside @opencode/client');
67
+ };
3
68
 
4
69
  // A session whose last assistant message is an orphaned incomplete turn: the
5
70
  // app was force-killed mid-generation, so opencode left a message with no
@@ -36,35 +101,63 @@ const buildSession = (status = 'active') => ({
36
101
  },
37
102
  });
38
103
 
104
+ /** Match both legacy `/session/...` and official v2 `/api/session/...` paths. */
105
+ const pathEndsWith = (urlPath, suffix) => {
106
+ const normalized = String(urlPath || '').split('?')[0];
107
+ return normalized.endsWith(suffix) || normalized.endsWith(`/api${suffix}`);
108
+ };
109
+
110
+ const jsonResponse = (body, status = 200) => new Response(
111
+ body === null || body === undefined ? null : JSON.stringify(body),
112
+ { status, headers: { 'Content-Type': 'application/json' } },
113
+ );
114
+
115
+ const emptyResponse = (status = 204) => new Response(null, { status });
116
+
39
117
  // Build a mock fetch that answers the endpoints the goal tick needs. `messages`
40
118
  // is the message list returned for the session; `liveStatus` is the type
41
- // returned by /session/status for the session.
119
+ // returned by /session/status (or mapped from /session/active) for the session.
42
120
  const makeFetch = ({ messages, liveStatus }) => {
43
- const calls = { promptAsync: 0, patchSession: 0 };
121
+ const calls = { promptAsync: 0, prompt: 0, patchSession: 0 };
44
122
  const session = buildSession();
45
123
  return {
46
124
  calls,
47
125
  fetch: async (input, init = {}) => {
48
126
  const url = String(input);
49
- const path = url.split('?')[0];
50
- if (path.endsWith('/session/status')) {
51
- return new Response(JSON.stringify({ 'ses-goal': { type: liveStatus } }), { status: 200 });
127
+ const reqPath = url.split('?')[0];
128
+ if (pathEndsWith(reqPath, '/session/status')) {
129
+ return jsonResponse({ 'ses-goal': { type: liveStatus } });
52
130
  }
53
- if (path.endsWith('/session/ses-goal/message')) {
54
- return new Response(JSON.stringify(messages), { status: 200 });
131
+ if (pathEndsWith(reqPath, '/session/active')) {
132
+ const data = liveStatus === 'busy' || liveStatus === 'retry'
133
+ ? { 'ses-goal': true }
134
+ : {};
135
+ return jsonResponse({ data });
55
136
  }
56
- if (path.endsWith('/session/ses-goal')) {
137
+ if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
138
+ // Official v2 SessionMessagesResponse shape.
139
+ return jsonResponse({ data: messages, cursor: {} });
140
+ }
141
+ if (pathEndsWith(reqPath, '/session/ses-goal') && !reqPath.includes('/message')) {
57
142
  if (init.method === 'PATCH') {
58
143
  calls.patchSession += 1;
59
- return new Response(JSON.stringify(session), { status: 200 });
144
+ // openCodeFetch (no /api) expects bare session; SDK wraps { data }.
145
+ return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
60
146
  }
61
- return new Response(JSON.stringify(session), { status: 200 });
147
+ return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
62
148
  }
63
- if (path.endsWith('/prompt_async')) {
64
- calls.promptAsync += 1;
65
- return new Response(JSON.stringify({}), { status: 200 });
149
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
150
+ if (pathEndsWith(reqPath, '/prompt_async')) calls.promptAsync += 1;
151
+ else calls.prompt += 1;
152
+ // v2 session.prompt successStatus 200 with SessionInboxUser body.
153
+ return jsonResponse({
154
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
155
+ });
66
156
  }
67
- return new Response(JSON.stringify(session), { status: 200 });
157
+ if (pathEndsWith(reqPath, '/model') || pathEndsWith(reqPath, '/agent')) {
158
+ return emptyResponse(204);
159
+ }
160
+ return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
68
161
  },
69
162
  };
70
163
  };
@@ -92,8 +185,85 @@ const withFetch = async (fetchImpl, fn) => {
92
185
  }
93
186
  };
94
187
 
188
+ describe('session-goal pending continuation helpers', () => {
189
+ it('parses and builds durable pendingContinuation admission records', () => {
190
+ expect(parsePendingContinuation(null)).toBeNull();
191
+ expect(parsePendingContinuation({ messageID: 'msg_x' })).toBeNull();
192
+ const built = buildPendingContinuation({
193
+ messageID: 'msg_goalc_g_0_1',
194
+ goalId: 'g',
195
+ generation: 0,
196
+ turnsUsed: 1,
197
+ phase: 'reserved',
198
+ text: 'Continue…',
199
+ providerID: 'p',
200
+ modelID: 'm',
201
+ agent: 'build',
202
+ });
203
+ expect(parsePendingContinuation(built)).toMatchObject({
204
+ messageID: 'msg_goalc_g_0_1',
205
+ phase: 'reserved',
206
+ turnsUsed: 1,
207
+ providerID: 'p',
208
+ });
209
+ });
210
+ });
211
+
212
+ describe('session-goal v2 message projection helpers', () => {
213
+ it('unwraps SessionMessagesResponse, bare arrays, and projects raw SessionMessageInfo', () => {
214
+ expect(unwrapSessionMessages(null)).toBeNull();
215
+ expect(unwrapSessionMessages({ data: [{ id: 'a', type: 'user', text: 'hi', time: { created: 1 } }], cursor: {} }))
216
+ .toEqual([{ id: 'a', type: 'user', text: 'hi', time: { created: 1 } }]);
217
+ const projected = projectGoalMessages({
218
+ data: [
219
+ { id: 'u1', type: 'user', text: 'go', time: { created: 1 } },
220
+ {
221
+ id: 'a1',
222
+ type: 'assistant',
223
+ agent: 'build',
224
+ model: { id: 'm', providerID: 'p', variant: 'fast' },
225
+ time: { created: 2, completed: 3 },
226
+ content: [{ type: 'text', text: 'done' }],
227
+ },
228
+ {
229
+ id: 'c1',
230
+ type: 'compaction',
231
+ status: 'completed',
232
+ reason: 'auto',
233
+ summary: 'summary text',
234
+ recent: '',
235
+ time: { created: 4 },
236
+ },
237
+ ],
238
+ cursor: {},
239
+ }, 'ses-1');
240
+ expect(projected).toHaveLength(3);
241
+ expect(projected[0].info.role).toBe('user');
242
+ expect(projected[1].info.role).toBe('assistant');
243
+ expect(projected[1].parts[0].text).toBe('done');
244
+ expect(projected[2].info.summary).toBe(true);
245
+ expect(extractExecutionModel(projected[1].info)).toEqual({
246
+ providerID: 'p',
247
+ modelID: 'm',
248
+ variant: 'fast',
249
+ agent: 'build',
250
+ });
251
+ expect(buildContinuationMessageID({ goalId: 'goal-1', generation: 2, turnsUsed: 5 }))
252
+ .toBe('msg_goalc_goal-1_2_5');
253
+ });
254
+
255
+ it('continuation id satisfies installed SessionMessage.ID (msg_ prefix)', async () => {
256
+ const SessionMessage = await loadInstalledSessionMessageSchema();
257
+ const legal = buildContinuationMessageID({ goalId: 'goal-1', generation: 2, turnsUsed: 5 });
258
+ expect(legal.startsWith('msg_')).toBe(true);
259
+ expect(SessionMessage.ID.make(legal)).toBe(legal);
260
+ // Legacy goalc_ prefix is rejected by the official schema contract.
261
+ expect(() => SessionMessage.ID.make('goalc_goal-1_2_5')).toThrow();
262
+ });
263
+ });
264
+
95
265
  describe('session-goal continuation delivery', () => {
96
- it('marks auto-continuation parts as synthetic so the UI can hide them', async () => {
266
+ it('dispatches v2 session.prompt with stable continuation id and metadata', async () => {
97
267
  const promptBodies = [];
98
268
  const messages = [
99
269
  { info: { id: 'msg-user', role: 'user' }, parts: [] },
@@ -113,25 +283,33 @@ describe('session-goal continuation delivery', () => {
113
283
  session.metadata.openchamber.goal.createdAt = 1;
114
284
  const fetchImpl = async (input, init = {}) => {
115
285
  const url = String(input);
116
- const path = url.split('?')[0];
117
- if (path.endsWith('/session/status')) {
118
- return new Response(JSON.stringify({ 'ses-goal': { type: 'idle' } }), { status: 200 });
286
+ const reqPath = url.split('?')[0];
287
+ if (pathEndsWith(reqPath, '/session/status')) {
288
+ return jsonResponse({ 'ses-goal': { type: 'idle' } });
119
289
  }
120
- if (path.endsWith('/session/ses-goal/message')) {
121
- return new Response(JSON.stringify(messages), { status: 200 });
290
+ if (pathEndsWith(reqPath, '/session/active')) {
291
+ return jsonResponse({ data: {} });
122
292
  }
123
- if (path.endsWith('/session/ses-goal')) {
293
+ if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
294
+ return jsonResponse({ data: messages, cursor: {} });
295
+ }
296
+ if (pathEndsWith(reqPath, '/session/ses-goal') && !reqPath.includes('/message')) {
124
297
  if (init.method === 'PATCH' && init.body) {
125
298
  const body = JSON.parse(init.body);
126
299
  session.metadata = body.metadata;
127
300
  }
128
- return new Response(JSON.stringify(session), { status: 200 });
301
+ return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
129
302
  }
130
- if (path.endsWith('/prompt_async')) {
303
+ if (pathEndsWith(reqPath, '/model') || pathEndsWith(reqPath, '/agent')) {
304
+ return emptyResponse(204);
305
+ }
306
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
131
307
  promptBodies.push(JSON.parse(init.body || '{}'));
132
- return new Response(JSON.stringify({}), { status: 200 });
308
+ return jsonResponse({
309
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
310
+ });
133
311
  }
134
- return new Response(JSON.stringify(session), { status: 200 });
312
+ return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
135
313
  };
136
314
 
137
315
  await withFetch(fetchImpl, async () => {
@@ -145,18 +323,25 @@ describe('session-goal continuation delivery', () => {
145
323
  kickoffQuietMs: 1,
146
324
  maxAutoTurns: 20,
147
325
  });
148
- runtime.processPayload({
149
- type: 'session.updated',
150
- properties: {
151
- sessionID: 'ses-goal',
152
- directory: '/repo',
153
- info: session,
154
- },
155
- });
156
- await new Promise((resolve) => setTimeout(resolve, 400));
326
+ await runtime.runTick('ses-goal', '/repo');
157
327
  expect(promptBodies.length).toBeGreaterThan(0);
158
- expect(promptBodies[0].parts[0].synthetic).toBe(true);
159
- expect(String(promptBodies[0].parts[0].text)).toContain('Continue working toward the active session goal.');
328
+ const body = promptBodies[0];
329
+ expect(body.id).toBe(buildContinuationMessageID({ goalId: 'goal-1', generation: 0, turnsUsed: 1 }));
330
+ expect(body.id.startsWith('msg_')).toBe(true);
331
+ const SessionMessage = await loadInstalledSessionMessageSchema();
332
+ expect(SessionMessage.ID.make(body.id)).toBe(body.id);
333
+ expect(String(body.text)).toContain('Continue working toward the active session goal.');
334
+ expect(body.metadata?.openchamber?.goalContinuation).toBe(true);
335
+ // User-boundary prompt: synthetic lives in metadata only (not type: synthetic).
336
+ expect(body.metadata?.openchamber?.synthetic).toBe(true);
337
+ expect(body.type).not.toBe('synthetic');
338
+ expect(runtime.getDispatchedContinuation('ses-goal')).toMatchObject({
339
+ goalId: 'goal-1',
340
+ generation: 0,
341
+ turnsUsed: 1,
342
+ messageID: body.id,
343
+ phase: 'accepted',
344
+ });
160
345
  runtime.stop();
161
346
  });
162
347
  });
@@ -259,18 +444,8 @@ describe('session-goal runtime — restart-orphan quiescence', () => {
259
444
 
260
445
  await withFetch(fetch, async () => {
261
446
  const runtime = makeRuntime();
262
- runtime.processPayload({
263
- type: 'session.updated',
264
- properties: {
265
- sessionID: 'ses-goal',
266
- directory: '/repo',
267
- info: buildSession(),
268
- },
269
- });
270
- // The resumed kickoff timer (RESUME_KICKOFF_MS = 250ms) must fire before
271
- // the tick runs — wait past it.
272
- await new Promise((resolve) => setTimeout(resolve, 400));
273
- expect(calls.promptAsync).toBeGreaterThan(0);
447
+ await runtime.runTick('ses-goal', '/repo');
448
+ expect(calls.prompt + calls.promptAsync).toBeGreaterThan(0);
274
449
  runtime.stop();
275
450
  });
276
451
  });
@@ -286,16 +461,8 @@ describe('session-goal runtime — restart-orphan quiescence', () => {
286
461
 
287
462
  await withFetch(fetch, async () => {
288
463
  const runtime = makeRuntime();
289
- runtime.processPayload({
290
- type: 'session.updated',
291
- properties: {
292
- sessionID: 'ses-goal',
293
- directory: '/repo',
294
- info: buildSession(),
295
- },
296
- });
297
- await new Promise((resolve) => setTimeout(resolve, 400));
298
- expect(calls.promptAsync).toBe(0);
464
+ await runtime.runTick('ses-goal', '/repo');
465
+ expect(calls.prompt + calls.promptAsync).toBe(0);
299
466
  runtime.stop();
300
467
  });
301
468
  });
@@ -311,16 +478,8 @@ describe('session-goal runtime — restart-orphan quiescence', () => {
311
478
 
312
479
  await withFetch(fetch, async () => {
313
480
  const runtime = makeRuntime();
314
- runtime.processPayload({
315
- type: 'session.updated',
316
- properties: {
317
- sessionID: 'ses-goal',
318
- directory: '/repo',
319
- info: buildSession(),
320
- },
321
- });
322
- await new Promise((resolve) => setTimeout(resolve, 400));
323
- expect(calls.promptAsync).toBeGreaterThan(0);
481
+ await runtime.runTick('ses-goal', '/repo');
482
+ expect(calls.prompt + calls.promptAsync).toBeGreaterThan(0);
324
483
  runtime.stop();
325
484
  });
326
485
  });
@@ -642,28 +801,60 @@ describe('session-goal runtime — pause for question', () => {
642
801
  });
643
802
  });
644
803
 
804
+ describe('session-goal execution generation helpers', () => {
805
+ it('advances generation on pause, resume, and condition changes — not on settle', () => {
806
+ const base = {
807
+ id: 'g1',
808
+ status: 'active',
809
+ executionGeneration: 2,
810
+ tokenBudget: 100,
811
+ objective: 'x',
812
+ objectiveFile: false,
813
+ };
814
+ expect(shouldAdvanceGoalExecutionGeneration(base, { ...base, status: 'paused' })).toBe(true);
815
+ expect(shouldAdvanceGoalExecutionGeneration(
816
+ { ...base, status: 'paused' },
817
+ { ...base, status: 'active' },
818
+ )).toBe(true);
819
+ expect(shouldAdvanceGoalExecutionGeneration(base, { ...base, tokenBudget: 200 })).toBe(true);
820
+ expect(shouldAdvanceGoalExecutionGeneration(base, { ...base, status: 'complete' })).toBe(false);
821
+ expect(readGoalExecutionGeneration({})).toBe(0);
822
+ expect(withGoalExecutionGeneration(base, { ...base, status: 'paused' }).executionGeneration).toBe(3);
823
+ });
824
+ });
825
+
645
826
  describe('session-goal runtime — Host metadata store seams', () => {
646
- it('writes through persistSessionGoal and never PATCHes OpenCode metadata when seams are injected', async () => {
827
+ it('writes through mutateSessionMetadata and never PATCHes OpenCode metadata when seams are injected', async () => {
647
828
  const active = buildSession('active');
648
829
  const goal = active.metadata.openchamber.goal;
649
830
  const metadata = {
650
831
  openchamber: {
651
- goal: { ...goal },
832
+ goal: { ...goal, executionGeneration: 0 },
652
833
  assist: { recap: 'keep-me' },
653
834
  },
654
835
  };
655
836
  const readSessionMetadata = vi.fn(async () => metadata);
656
- const persistSessionGoal = vi.fn(async (_sessionId, _directory, nextGoal) => {
657
- metadata.openchamber = { ...metadata.openchamber, goal: nextGoal };
837
+ const mutateSessionMetadata = vi.fn(async (_sessionId, decide) => {
838
+ const decision = decide(metadata);
839
+ if (!decision?.ok) return { committed: false, reason: decision?.reason, metadata };
840
+ metadata.openchamber = {
841
+ ...metadata.openchamber,
842
+ ...(decision.patch.openchamber || {}),
843
+ goal: {
844
+ ...metadata.openchamber.goal,
845
+ ...(decision.patch.openchamber?.goal || {}),
846
+ },
847
+ };
848
+ return { committed: true, metadata };
658
849
  });
659
850
  const fetchMock = vi.fn(async (input, init = {}) => {
660
851
  const url = String(input);
661
- const path = url.split('?')[0];
852
+ const reqPath = url.split('?')[0];
662
853
  if (init.method === 'PATCH') {
663
- throw new Error(`OpenCode metadata PATCH must not run when store seams are wired: ${path}`);
854
+ throw new Error(`OpenCode metadata PATCH must not run when store seams are wired: ${reqPath}`);
664
855
  }
665
856
  // resolveGoalOwner walks parentID via GET /session/:id
666
- if (path.endsWith('/session/ses-goal')) {
857
+ if (reqPath.endsWith('/session/ses-goal')) {
667
858
  return new Response(JSON.stringify({ id: 'ses-goal', directory: '/repo' }), { status: 200 });
668
859
  }
669
860
  return new Response(JSON.stringify({}), { status: 200 });
@@ -680,25 +871,1203 @@ describe('session-goal runtime — Host metadata store seams', () => {
680
871
  kickoffQuietMs: 1,
681
872
  maxAutoTurns: 20,
682
873
  readSessionMetadata,
683
- persistSessionGoal,
874
+ persistSessionGoal: async () => {
875
+ throw new Error('persistSessionGoal must not run when mutate is wired');
876
+ },
877
+ mutateSessionMetadata,
684
878
  });
685
879
 
686
880
  await runtime.pauseForQuestion('ses-goal', '/repo');
687
881
 
688
882
  expect(readSessionMetadata).toHaveBeenCalledWith('ses-goal');
689
- expect(persistSessionGoal).toHaveBeenCalledTimes(1);
690
- expect(persistSessionGoal.mock.calls[0][0]).toBe('ses-goal');
691
- expect(persistSessionGoal.mock.calls[0][1]).toBe('/repo');
692
- expect(persistSessionGoal.mock.calls[0][2]).toMatchObject({
883
+ expect(mutateSessionMetadata).toHaveBeenCalledTimes(1);
884
+ expect(metadata.openchamber.goal).toMatchObject({
693
885
  id: goal.id,
694
886
  status: 'paused',
695
887
  statusReason: 'paused for question',
888
+ executionGeneration: 1,
696
889
  });
697
- // Neighbouring Host namespaces must survive the goal write (caller owns
698
- // merge; this seam only receives the goal object).
890
+ // Neighbouring Host namespaces must survive the goal write.
699
891
  expect(metadata.openchamber.assist).toEqual({ recap: 'keep-me' });
700
892
  expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
701
893
  runtime.stop();
702
894
  });
703
895
  });
704
896
  });
897
+
898
+ describe('session-goal runtime — pause / audit / dispatch boundary', () => {
899
+ const makeTempStore = async () => {
900
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-goal-race-'));
901
+ const store = createSessionMetadataStore({ dataDir });
902
+ await store.load();
903
+ return {
904
+ store,
905
+ dataDir,
906
+ cleanup: () => fs.rmSync(dataDir, { recursive: true, force: true }),
907
+ };
908
+ };
909
+
910
+ const idleMessages = [
911
+ { info: { id: 'msg-user', role: 'user' }, parts: [] },
912
+ {
913
+ info: {
914
+ id: 'msg-done',
915
+ role: 'assistant',
916
+ agent: 'build',
917
+ time: { completed: 200 },
918
+ providerID: 'p',
919
+ modelID: 'm',
920
+ tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
921
+ },
922
+ parts: [{ type: 'text', text: 'working' }],
923
+ },
924
+ ];
925
+
926
+ /**
927
+ * @param {object} [options]
928
+ * @param {Array|object} [options.messages]
929
+ * @param {Set<string>|string[]} [options.knownMessageIDs] exact ids found via message GET / inbox
930
+ * @param {boolean} [options.messageLookupUnavailable] fail != empty for reconcile
931
+ * @param {(body: object) => Response|Promise<Response>} [options.onPrompt]
932
+ */
933
+ const makeOpenCodeFetch = (messagesOrOptions = idleMessages) => {
934
+ const options = Array.isArray(messagesOrOptions) || (messagesOrOptions && messagesOrOptions.data)
935
+ ? { messages: messagesOrOptions }
936
+ : (messagesOrOptions || {});
937
+ const messages = options.messages ?? idleMessages;
938
+ const knownMessageIDs = options.knownMessageIDs instanceof Set
939
+ ? options.knownMessageIDs
940
+ : new Set(options.knownMessageIDs || []);
941
+ const messageLookupUnavailable = options.messageLookupUnavailable === true;
942
+ const onPrompt = typeof options.onPrompt === 'function' ? options.onPrompt : null;
943
+
944
+ return async (input, init = {}) => {
945
+ const url = String(input);
946
+ const reqPath = url.split('?')[0];
947
+ if (pathEndsWith(reqPath, '/session/status')) {
948
+ return jsonResponse({ 'ses-goal': { type: 'idle' } });
949
+ }
950
+ if (pathEndsWith(reqPath, '/session/active')) {
951
+ return jsonResponse({ data: {} });
952
+ }
953
+ // Exact message GET before list suffix match.
954
+ const exactMatch = reqPath.match(/\/session\/ses-goal\/message\/([^/?]+)$/);
955
+ if (exactMatch) {
956
+ if (messageLookupUnavailable) {
957
+ return new Response(JSON.stringify({ error: 'upstream' }), {
958
+ status: 500,
959
+ headers: { 'Content-Type': 'application/json' },
960
+ });
961
+ }
962
+ const messageID = decodeURIComponent(exactMatch[1]);
963
+ if (knownMessageIDs.has(messageID)) {
964
+ return jsonResponse({ id: messageID, type: 'user', text: 'continuation' });
965
+ }
966
+ return new Response(JSON.stringify({ error: 'not found' }), {
967
+ status: 404,
968
+ headers: { 'Content-Type': 'application/json' },
969
+ });
970
+ }
971
+ if (pathEndsWith(reqPath, '/session/ses-goal/inbox') || pathEndsWith(reqPath, '/inbox')) {
972
+ if (messageLookupUnavailable) {
973
+ return new Response(JSON.stringify({ error: 'upstream' }), {
974
+ status: 500,
975
+ headers: { 'Content-Type': 'application/json' },
976
+ });
977
+ }
978
+ return jsonResponse({
979
+ data: [...knownMessageIDs].map((id) => ({ id, type: 'user', sessionID: 'ses-goal' })),
980
+ });
981
+ }
982
+ if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
983
+ // Support both bare array fixtures and official `{ data, cursor }` projection.
984
+ const body = Array.isArray(messages)
985
+ ? { data: messages, cursor: {} }
986
+ : messages;
987
+ return jsonResponse(body);
988
+ }
989
+ if (pathEndsWith(reqPath, '/session/ses-goal')) {
990
+ if (init.method === 'PATCH') {
991
+ throw new Error('OpenCode PATCH must not run with Host store');
992
+ }
993
+ return jsonResponse({ data: { id: 'ses-goal', directory: '/repo' } });
994
+ }
995
+ if (pathEndsWith(reqPath, '/model') || pathEndsWith(reqPath, '/agent')) {
996
+ return emptyResponse(204);
997
+ }
998
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
999
+ const body = JSON.parse(init.body || '{}');
1000
+ if (onPrompt) return onPrompt(body);
1001
+ if (body.id) knownMessageIDs.add(body.id);
1002
+ return jsonResponse({
1003
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1004
+ });
1005
+ }
1006
+ return jsonResponse({});
1007
+ };
1008
+ };
1009
+
1010
+ it('audit start → user pause → late continue does not dispatch or raise turnsUsed', async () => {
1011
+ const { store, cleanup } = await makeTempStore();
1012
+ const goal = {
1013
+ ...buildSession('active').metadata.openchamber.goal,
1014
+ executionGeneration: 0,
1015
+ createdAt: 1,
1016
+ turnsUsed: 3,
1017
+ };
1018
+ await store.setSessionMetadata('ses-goal', {
1019
+ openchamber: { goal, assist: { recap: 'keep' }, archive: { archivedAt: 9 } },
1020
+ }, { allowArchive: true });
1021
+
1022
+ let releaseAudit;
1023
+ const auditGate = new Promise((resolve) => { releaseAudit = resolve; });
1024
+ const promptBodies = [];
1025
+
1026
+ await withFetch(makeOpenCodeFetch(), async () => {
1027
+ const runtime = createSessionGoalRuntime({
1028
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1029
+ getOpenCodeAuthHeaders: () => ({}),
1030
+ getSmallModelService: async () => ({
1031
+ generateSmallModelText: async () => {
1032
+ await auditGate;
1033
+ return { text: '{"verdict":"continue","note":"late"}' };
1034
+ },
1035
+ }),
1036
+ idleQuietMs: 1_000_000,
1037
+ kickoffQuietMs: 1,
1038
+ maxAutoTurns: 20,
1039
+ readSessionMetadata: (id) => store.get(id),
1040
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1041
+ persistSessionGoal: async () => {
1042
+ throw new Error('unexpected persistSessionGoal');
1043
+ },
1044
+ });
1045
+
1046
+ // Intercept prompt via fetch override that already returns ok — count via monkeypatch.
1047
+ const originalFetch = globalThis.fetch;
1048
+ globalThis.fetch = async (input, init = {}) => {
1049
+ const reqPath = String(input).split('?')[0];
1050
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1051
+ promptBodies.push(JSON.parse(init.body || '{}'));
1052
+ return jsonResponse({
1053
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1054
+ });
1055
+ }
1056
+ return originalFetch(input, init);
1057
+ };
1058
+
1059
+ try {
1060
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1061
+ // Let the tick reach the audit gate.
1062
+ await new Promise((r) => setTimeout(r, 30));
1063
+ expect(runtime.getDispatchEpoch('ses-goal')).toBe(0);
1064
+
1065
+ const paused = await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
1066
+ expect(paused.status).toBe('paused');
1067
+ expect(paused.executionGeneration).toBe(1);
1068
+ expect(runtime.getDispatchEpoch('ses-goal')).toBeGreaterThan(0);
1069
+
1070
+ // Readers only see committed pause (store already published).
1071
+ const mid = await store.get('ses-goal');
1072
+ expect(mid.openchamber.goal.status).toBe('paused');
1073
+ expect(mid.openchamber.assist).toEqual({ recap: 'keep' });
1074
+ expect(mid.openchamber.archive).toEqual({ archivedAt: 9 });
1075
+
1076
+ releaseAudit();
1077
+ await tickPromise;
1078
+
1079
+ expect(promptBodies).toEqual([]);
1080
+ const finalMeta = await store.get('ses-goal');
1081
+ expect(finalMeta.openchamber.goal.status).toBe('paused');
1082
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(3);
1083
+ expect(finalMeta.openchamber.goal.executionGeneration).toBe(1);
1084
+ expect(finalMeta.openchamber.assist).toEqual({ recap: 'keep' });
1085
+ expect(finalMeta.openchamber.archive).toEqual({ archivedAt: 9 });
1086
+ runtime.stop();
1087
+ } finally {
1088
+ globalThis.fetch = originalFetch;
1089
+ cleanup();
1090
+ }
1091
+ });
1092
+ });
1093
+
1094
+ it('late audit complete after pause cannot settle the goal', async () => {
1095
+ const { store, cleanup } = await makeTempStore();
1096
+ const goal = {
1097
+ ...buildSession('active').metadata.openchamber.goal,
1098
+ executionGeneration: 4,
1099
+ createdAt: 1,
1100
+ turnsUsed: 1,
1101
+ };
1102
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1103
+
1104
+ let releaseAudit;
1105
+ const auditGate = new Promise((resolve) => { releaseAudit = resolve; });
1106
+
1107
+ await withFetch(makeOpenCodeFetch(), async () => {
1108
+ const runtime = createSessionGoalRuntime({
1109
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1110
+ getOpenCodeAuthHeaders: () => ({}),
1111
+ getSmallModelService: async () => ({
1112
+ generateSmallModelText: async () => {
1113
+ await auditGate;
1114
+ return { text: '{"verdict":"complete","note":"done"}' };
1115
+ },
1116
+ }),
1117
+ idleQuietMs: 1_000_000,
1118
+ kickoffQuietMs: 1,
1119
+ readSessionMetadata: (id) => store.get(id),
1120
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1121
+ });
1122
+
1123
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1124
+ await new Promise((r) => setTimeout(r, 30));
1125
+ await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
1126
+ releaseAudit();
1127
+ await tickPromise;
1128
+
1129
+ const finalMeta = await store.get('ses-goal');
1130
+ expect(finalMeta.openchamber.goal.status).toBe('paused');
1131
+ expect(finalMeta.openchamber.goal.executionGeneration).toBe(5);
1132
+ runtime.stop();
1133
+ cleanup();
1134
+ });
1135
+ });
1136
+
1137
+ it('resume advances generation so a prior-period continue cannot commit', async () => {
1138
+ const { store, cleanup } = await makeTempStore();
1139
+ const goal = {
1140
+ ...buildSession('active').metadata.openchamber.goal,
1141
+ executionGeneration: 1,
1142
+ createdAt: 1,
1143
+ turnsUsed: 2,
1144
+ };
1145
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1146
+
1147
+ let releaseAudit;
1148
+ const auditGate = new Promise((resolve) => { releaseAudit = resolve; });
1149
+ const promptBodies = [];
1150
+
1151
+ await withFetch(makeOpenCodeFetch(), async () => {
1152
+ const originalFetch = globalThis.fetch;
1153
+ globalThis.fetch = async (input, init = {}) => {
1154
+ const reqPath = String(input).split('?')[0];
1155
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1156
+ promptBodies.push(1);
1157
+ return jsonResponse({
1158
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1159
+ });
1160
+ }
1161
+ return originalFetch(input, init);
1162
+ };
1163
+
1164
+ try {
1165
+ const runtime = createSessionGoalRuntime({
1166
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1167
+ getOpenCodeAuthHeaders: () => ({}),
1168
+ getSmallModelService: async () => ({
1169
+ generateSmallModelText: async () => {
1170
+ await auditGate;
1171
+ return { text: '{"verdict":"continue","note":"old"}' };
1172
+ },
1173
+ }),
1174
+ idleQuietMs: 1_000_000,
1175
+ kickoffQuietMs: 1,
1176
+ readSessionMetadata: (id) => store.get(id),
1177
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1178
+ });
1179
+
1180
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1181
+ await new Promise((r) => setTimeout(r, 30));
1182
+ // Simulate pause then resume while audit is in flight (new generation).
1183
+ await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
1184
+ await store.mutateSessionMetadata('ses-goal', (current) => {
1185
+ const g = current.openchamber.goal;
1186
+ return {
1187
+ ok: true,
1188
+ patch: {
1189
+ openchamber: {
1190
+ goal: {
1191
+ ...g,
1192
+ status: 'active',
1193
+ statusReason: 'resumed',
1194
+ executionGeneration: g.executionGeneration + 1,
1195
+ },
1196
+ },
1197
+ },
1198
+ };
1199
+ });
1200
+ // Local epoch was bumped on pause; resume does not lower it.
1201
+ releaseAudit();
1202
+ await tickPromise;
1203
+ expect(promptBodies).toEqual([]);
1204
+ const finalMeta = await store.get('ses-goal');
1205
+ expect(finalMeta.openchamber.goal.status).toBe('active');
1206
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(2);
1207
+ expect(finalMeta.openchamber.goal.executionGeneration).toBe(3);
1208
+ runtime.stop();
1209
+ } finally {
1210
+ globalThis.fetch = originalFetch;
1211
+ cleanup();
1212
+ }
1213
+ });
1214
+ });
1215
+
1216
+ it('successful continue commits turnsUsed and records dispatch identity', async () => {
1217
+ const { store, cleanup } = await makeTempStore();
1218
+ const goal = {
1219
+ ...buildSession('active').metadata.openchamber.goal,
1220
+ executionGeneration: 0,
1221
+ createdAt: 1,
1222
+ turnsUsed: 0,
1223
+ statusReason: 'resumed',
1224
+ };
1225
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1226
+ const promptBodies = [];
1227
+
1228
+ await withFetch(makeOpenCodeFetch(), async () => {
1229
+ const originalFetch = globalThis.fetch;
1230
+ globalThis.fetch = async (input, init = {}) => {
1231
+ const reqPath = String(input).split('?')[0];
1232
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1233
+ promptBodies.push(JSON.parse(init.body || '{}'));
1234
+ return jsonResponse({
1235
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1236
+ });
1237
+ }
1238
+ return originalFetch(input, init);
1239
+ };
1240
+ try {
1241
+ const runtime = createSessionGoalRuntime({
1242
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1243
+ getOpenCodeAuthHeaders: () => ({}),
1244
+ getSmallModelService: async () => ({
1245
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1246
+ }),
1247
+ idleQuietMs: 1_000_000,
1248
+ kickoffQuietMs: 1,
1249
+ readSessionMetadata: (id) => store.get(id),
1250
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1251
+ });
1252
+ await runtime.runTick('ses-goal', '/repo');
1253
+ expect(promptBodies.length).toBe(1);
1254
+ expect(promptBodies[0].id).toBe(buildContinuationMessageID({
1255
+ goalId: goal.id,
1256
+ generation: 0,
1257
+ turnsUsed: 1,
1258
+ }));
1259
+ const finalMeta = await store.get('ses-goal');
1260
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(1);
1261
+ expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
1262
+ expect(runtime.getDispatchedContinuation('ses-goal')).toMatchObject({
1263
+ goalId: goal.id,
1264
+ generation: 0,
1265
+ turnsUsed: 1,
1266
+ messageID: promptBodies[0].id,
1267
+ phase: 'accepted',
1268
+ });
1269
+ runtime.stop();
1270
+ } finally {
1271
+ globalThis.fetch = originalFetch;
1272
+ cleanup();
1273
+ }
1274
+ });
1275
+ });
1276
+
1277
+ it('raw v2 projection payload + duplicate continuation id skips second dispatch', async () => {
1278
+ const { store, cleanup } = await makeTempStore();
1279
+ const goal = {
1280
+ ...buildSession('active').metadata.openchamber.goal,
1281
+ executionGeneration: 0,
1282
+ createdAt: 1,
1283
+ turnsUsed: 0,
1284
+ statusReason: 'resumed',
1285
+ };
1286
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1287
+
1288
+ // Official raw SessionMessageInfo (no info/parts wrapper).
1289
+ const rawProjection = {
1290
+ data: [
1291
+ { id: 'msg-user', type: 'user', text: 'hi', time: { created: 1 } },
1292
+ {
1293
+ id: 'msg-done',
1294
+ type: 'assistant',
1295
+ agent: 'build',
1296
+ model: { id: 'm', providerID: 'p' },
1297
+ time: { created: 2, completed: 200 },
1298
+ content: [{ type: 'text', text: 'working' }],
1299
+ tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
1300
+ },
1301
+ ],
1302
+ cursor: {},
1303
+ };
1304
+ const promptBodies = [];
1305
+ const knownMessageIDs = new Set();
1306
+
1307
+ await withFetch(makeOpenCodeFetch({ messages: rawProjection, knownMessageIDs }), async () => {
1308
+ const originalFetch = globalThis.fetch;
1309
+ globalThis.fetch = async (input, init = {}) => {
1310
+ const reqPath = String(input).split('?')[0];
1311
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1312
+ const body = JSON.parse(init.body || '{}');
1313
+ promptBodies.push(body);
1314
+ if (body.id) knownMessageIDs.add(body.id);
1315
+ return jsonResponse({
1316
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1317
+ });
1318
+ }
1319
+ return originalFetch(input, init);
1320
+ };
1321
+ try {
1322
+ const runtime = createSessionGoalRuntime({
1323
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1324
+ getOpenCodeAuthHeaders: () => ({}),
1325
+ getSmallModelService: async () => ({
1326
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1327
+ }),
1328
+ idleQuietMs: 1_000_000,
1329
+ kickoffQuietMs: 1,
1330
+ readSessionMetadata: (id) => store.get(id),
1331
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1332
+ });
1333
+ await runtime.runTick('ses-goal', '/repo');
1334
+ expect(promptBodies.length).toBe(1);
1335
+ const firstId = promptBodies[0].id;
1336
+ expect(firstId).toBe(buildContinuationMessageID({ goalId: goal.id, generation: 0, turnsUsed: 1 }));
1337
+ // Simulate crash after accept: turnsUsed rolled back but upstream still
1338
+ // holds the fixed message id — reconcile counts once, no second prompt.
1339
+ await store.mutateSessionMetadata('ses-goal', (current) => ({
1340
+ ok: true,
1341
+ patch: {
1342
+ openchamber: {
1343
+ goal: { ...current.openchamber.goal, turnsUsed: 0 },
1344
+ },
1345
+ },
1346
+ }));
1347
+ await runtime.runTick('ses-goal', '/repo');
1348
+ expect(promptBodies.length).toBe(1);
1349
+ const finalMeta = await store.get('ses-goal');
1350
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(1);
1351
+ expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
1352
+ runtime.stop();
1353
+ } finally {
1354
+ globalThis.fetch = originalFetch;
1355
+ cleanup();
1356
+ }
1357
+ });
1358
+ });
1359
+
1360
+ it('shutdown interrupt blocks auto-continue until authoritative idle', async () => {
1361
+ const { store, cleanup } = await makeTempStore();
1362
+ const goal = {
1363
+ ...buildSession('active').metadata.openchamber.goal,
1364
+ executionGeneration: 0,
1365
+ createdAt: 1,
1366
+ turnsUsed: 0,
1367
+ statusReason: 'resumed',
1368
+ };
1369
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1370
+ const promptBodies = [];
1371
+
1372
+ await withFetch(makeOpenCodeFetch(), async () => {
1373
+ const originalFetch = globalThis.fetch;
1374
+ globalThis.fetch = async (input, init = {}) => {
1375
+ const reqPath = String(input).split('?')[0];
1376
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1377
+ promptBodies.push(JSON.parse(init.body || '{}'));
1378
+ return jsonResponse({
1379
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1380
+ });
1381
+ }
1382
+ return originalFetch(input, init);
1383
+ };
1384
+ try {
1385
+ const runtime = createSessionGoalRuntime({
1386
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1387
+ getOpenCodeAuthHeaders: () => ({}),
1388
+ getSmallModelService: async () => ({
1389
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1390
+ }),
1391
+ idleQuietMs: 1_000_000,
1392
+ kickoffQuietMs: 1,
1393
+ readSessionMetadata: (id) => store.get(id),
1394
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1395
+ });
1396
+ runtime.processPayload({
1397
+ type: 'session.execution.interrupted',
1398
+ properties: { sessionID: 'ses-goal', reason: 'shutdown' },
1399
+ });
1400
+ await runtime.runTick('ses-goal', '/repo');
1401
+ expect(promptBodies).toEqual([]);
1402
+ // Authoritative idle clears recovery and allows continue.
1403
+ runtime.processPayload({
1404
+ type: 'session.status',
1405
+ properties: { sessionID: 'ses-goal', status: { type: 'idle' }, directory: '/repo' },
1406
+ });
1407
+ await runtime.runTick('ses-goal', '/repo');
1408
+ expect(promptBodies.length).toBe(1);
1409
+ runtime.stop();
1410
+ } finally {
1411
+ globalThis.fetch = originalFetch;
1412
+ cleanup();
1413
+ }
1414
+ });
1415
+ });
1416
+
1417
+ it('pause during switchAgent drops before prompt with zero transport identity', async () => {
1418
+ const { store, cleanup } = await makeTempStore();
1419
+ const goal = {
1420
+ ...buildSession('active').metadata.openchamber.goal,
1421
+ executionGeneration: 0,
1422
+ createdAt: 1,
1423
+ turnsUsed: 0,
1424
+ statusReason: 'resumed',
1425
+ };
1426
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1427
+
1428
+ let releaseAgent;
1429
+ const agentGate = new Promise((resolve) => { releaseAgent = resolve; });
1430
+ let agentStarted = false;
1431
+ const promptBodies = [];
1432
+
1433
+ await withFetch(makeOpenCodeFetch(), async () => {
1434
+ const originalFetch = globalThis.fetch;
1435
+ globalThis.fetch = async (input, init = {}) => {
1436
+ const reqPath = String(input).split('?')[0];
1437
+ if (pathEndsWith(reqPath, '/agent')) {
1438
+ agentStarted = true;
1439
+ await agentGate;
1440
+ return emptyResponse(204);
1441
+ }
1442
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1443
+ promptBodies.push(JSON.parse(init.body || '{}'));
1444
+ return jsonResponse({
1445
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1446
+ });
1447
+ }
1448
+ return originalFetch(input, init);
1449
+ };
1450
+
1451
+ try {
1452
+ const runtime = createSessionGoalRuntime({
1453
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1454
+ getOpenCodeAuthHeaders: () => ({}),
1455
+ getSmallModelService: async () => ({
1456
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1457
+ }),
1458
+ idleQuietMs: 1_000_000,
1459
+ kickoffQuietMs: 1,
1460
+ readSessionMetadata: (id) => store.get(id),
1461
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1462
+ });
1463
+
1464
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1465
+ const started = Date.now();
1466
+ while (!agentStarted) {
1467
+ if (Date.now() - started > 2000) throw new Error('switchAgent never started');
1468
+ await new Promise((r) => setTimeout(r, 5));
1469
+ }
1470
+ // Selection in flight: transport must not be claimed yet.
1471
+ expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
1472
+
1473
+ const paused = await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
1474
+ expect(paused.status).toBe('paused');
1475
+ expect(runtime.getDispatchEpoch('ses-goal')).toBeGreaterThan(0);
1476
+
1477
+ releaseAgent();
1478
+ await tickPromise;
1479
+
1480
+ expect(promptBodies).toEqual([]);
1481
+ expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
1482
+ const finalMeta = await store.get('ses-goal');
1483
+ expect(finalMeta.openchamber.goal.status).toBe('paused');
1484
+ // Before selection/prompt: turnsUsed must stay 0 and reserved pending cleared.
1485
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(0);
1486
+ expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
1487
+ runtime.stop();
1488
+ } finally {
1489
+ globalThis.fetch = originalFetch;
1490
+ cleanup();
1491
+ }
1492
+ });
1493
+ });
1494
+
1495
+ it('pause during switchModel drops before prompt with zero transport identity', async () => {
1496
+ const { store, cleanup } = await makeTempStore();
1497
+ const goal = {
1498
+ ...buildSession('active').metadata.openchamber.goal,
1499
+ executionGeneration: 0,
1500
+ createdAt: 1,
1501
+ turnsUsed: 0,
1502
+ statusReason: 'resumed',
1503
+ };
1504
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1505
+
1506
+ let releaseModel;
1507
+ const modelGate = new Promise((resolve) => { releaseModel = resolve; });
1508
+ let modelStarted = false;
1509
+ const promptBodies = [];
1510
+
1511
+ await withFetch(makeOpenCodeFetch(), async () => {
1512
+ const originalFetch = globalThis.fetch;
1513
+ globalThis.fetch = async (input, init = {}) => {
1514
+ const reqPath = String(input).split('?')[0];
1515
+ if (pathEndsWith(reqPath, '/model')) {
1516
+ modelStarted = true;
1517
+ await modelGate;
1518
+ return emptyResponse(204);
1519
+ }
1520
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1521
+ promptBodies.push(JSON.parse(init.body || '{}'));
1522
+ return jsonResponse({
1523
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1524
+ });
1525
+ }
1526
+ return originalFetch(input, init);
1527
+ };
1528
+
1529
+ try {
1530
+ const runtime = createSessionGoalRuntime({
1531
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1532
+ getOpenCodeAuthHeaders: () => ({}),
1533
+ getSmallModelService: async () => ({
1534
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1535
+ }),
1536
+ idleQuietMs: 1_000_000,
1537
+ kickoffQuietMs: 1,
1538
+ readSessionMetadata: (id) => store.get(id),
1539
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1540
+ });
1541
+
1542
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1543
+ const started = Date.now();
1544
+ while (!modelStarted) {
1545
+ if (Date.now() - started > 2000) throw new Error('switchModel never started');
1546
+ await new Promise((r) => setTimeout(r, 5));
1547
+ }
1548
+ expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
1549
+
1550
+ await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
1551
+ releaseModel();
1552
+ await tickPromise;
1553
+
1554
+ expect(promptBodies).toEqual([]);
1555
+ expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
1556
+ runtime.stop();
1557
+ } finally {
1558
+ globalThis.fetch = originalFetch;
1559
+ cleanup();
1560
+ }
1561
+ });
1562
+ });
1563
+
1564
+ it('shutdown during switchAgent drops before prompt without uncertain phase', async () => {
1565
+ const { store, cleanup } = await makeTempStore();
1566
+ const goal = {
1567
+ ...buildSession('active').metadata.openchamber.goal,
1568
+ executionGeneration: 0,
1569
+ createdAt: 1,
1570
+ turnsUsed: 0,
1571
+ statusReason: 'resumed',
1572
+ };
1573
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1574
+
1575
+ let releaseAgent;
1576
+ const agentGate = new Promise((resolve) => { releaseAgent = resolve; });
1577
+ let agentStarted = false;
1578
+ const promptBodies = [];
1579
+
1580
+ await withFetch(makeOpenCodeFetch(), async () => {
1581
+ const originalFetch = globalThis.fetch;
1582
+ globalThis.fetch = async (input, init = {}) => {
1583
+ const reqPath = String(input).split('?')[0];
1584
+ if (pathEndsWith(reqPath, '/agent')) {
1585
+ agentStarted = true;
1586
+ await agentGate;
1587
+ return emptyResponse(204);
1588
+ }
1589
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1590
+ promptBodies.push(JSON.parse(init.body || '{}'));
1591
+ return jsonResponse({
1592
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1593
+ });
1594
+ }
1595
+ return originalFetch(input, init);
1596
+ };
1597
+
1598
+ try {
1599
+ const runtime = createSessionGoalRuntime({
1600
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1601
+ getOpenCodeAuthHeaders: () => ({}),
1602
+ getSmallModelService: async () => ({
1603
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1604
+ }),
1605
+ idleQuietMs: 1_000_000,
1606
+ kickoffQuietMs: 1,
1607
+ readSessionMetadata: (id) => store.get(id),
1608
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1609
+ });
1610
+
1611
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1612
+ const started = Date.now();
1613
+ while (!agentStarted) {
1614
+ if (Date.now() - started > 2000) throw new Error('switchAgent never started');
1615
+ await new Promise((r) => setTimeout(r, 5));
1616
+ }
1617
+
1618
+ runtime.processPayload({
1619
+ type: 'session.execution.interrupted',
1620
+ properties: { sessionID: 'ses-goal', reason: 'shutdown' },
1621
+ });
1622
+ releaseAgent();
1623
+ await tickPromise;
1624
+
1625
+ expect(promptBodies).toEqual([]);
1626
+ const dispatch = runtime.getDispatchedContinuation('ses-goal');
1627
+ expect(dispatch === null || dispatch.phase !== 'uncertain').toBe(true);
1628
+ runtime.stop();
1629
+ } finally {
1630
+ globalThis.fetch = originalFetch;
1631
+ cleanup();
1632
+ }
1633
+ });
1634
+ });
1635
+
1636
+ it('switchAgent selection failure is not reported as prompt-uncertain', async () => {
1637
+ const { store, cleanup } = await makeTempStore();
1638
+ const goal = {
1639
+ ...buildSession('active').metadata.openchamber.goal,
1640
+ executionGeneration: 0,
1641
+ createdAt: 1,
1642
+ turnsUsed: 0,
1643
+ statusReason: 'resumed',
1644
+ };
1645
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1646
+ const promptBodies = [];
1647
+
1648
+ await withFetch(makeOpenCodeFetch(), async () => {
1649
+ const originalFetch = globalThis.fetch;
1650
+ globalThis.fetch = async (input, init = {}) => {
1651
+ const reqPath = String(input).split('?')[0];
1652
+ if (pathEndsWith(reqPath, '/agent')) {
1653
+ return new Response(JSON.stringify({ error: 'agent unavailable' }), {
1654
+ status: 500,
1655
+ headers: { 'Content-Type': 'application/json' },
1656
+ });
1657
+ }
1658
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1659
+ promptBodies.push(JSON.parse(init.body || '{}'));
1660
+ return jsonResponse({
1661
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1662
+ });
1663
+ }
1664
+ return originalFetch(input, init);
1665
+ };
1666
+
1667
+ try {
1668
+ const runtime = createSessionGoalRuntime({
1669
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1670
+ getOpenCodeAuthHeaders: () => ({}),
1671
+ getSmallModelService: async () => ({
1672
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1673
+ }),
1674
+ idleQuietMs: 1_000_000,
1675
+ kickoffQuietMs: 1,
1676
+ readSessionMetadata: (id) => store.get(id),
1677
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1678
+ });
1679
+
1680
+ await runtime.runTick('ses-goal', '/repo').catch(() => {});
1681
+ expect(promptBodies).toEqual([]);
1682
+ const dispatch = runtime.getDispatchedContinuation('ses-goal');
1683
+ expect(dispatch === null || dispatch.phase !== 'uncertain').toBe(true);
1684
+ runtime.stop();
1685
+ } finally {
1686
+ globalThis.fetch = originalFetch;
1687
+ cleanup();
1688
+ }
1689
+ });
1690
+ });
1691
+
1692
+ it('persist failure during pause keeps prior committed active state', async () => {
1693
+ const { store, cleanup } = await makeTempStore();
1694
+ const goal = {
1695
+ ...buildSession('active').metadata.openchamber.goal,
1696
+ executionGeneration: 2,
1697
+ };
1698
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1699
+
1700
+ let failNext = false;
1701
+ const gated = {
1702
+ mutateSessionMetadata: async (id, decide) => {
1703
+ if (failNext) {
1704
+ failNext = false;
1705
+ throw new Error('disk full');
1706
+ }
1707
+ return store.mutateSessionMetadata(id, decide);
1708
+ },
1709
+ get: (id) => store.get(id),
1710
+ };
1711
+
1712
+ await withFetch(makeOpenCodeFetch(), async () => {
1713
+ const runtime = createSessionGoalRuntime({
1714
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1715
+ getOpenCodeAuthHeaders: () => ({}),
1716
+ getSmallModelService: async () => ({
1717
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"x"}' }),
1718
+ }),
1719
+ idleQuietMs: 1_000_000,
1720
+ kickoffQuietMs: 1,
1721
+ readSessionMetadata: (id) => gated.get(id),
1722
+ mutateSessionMetadata: (id, decide) => gated.mutateSessionMetadata(id, decide),
1723
+ });
1724
+
1725
+ failNext = true;
1726
+ await expect(runtime.pauseGoal('ses-goal', '/repo', 'paused by user')).rejects.toThrow('disk full');
1727
+ // Local epoch still bumped (pause requested) but durable state unchanged.
1728
+ const mid = await store.get('ses-goal');
1729
+ expect(mid.openchamber.goal.status).toBe('active');
1730
+ expect(mid.openchamber.goal.executionGeneration).toBe(2);
1731
+ runtime.stop();
1732
+ cleanup();
1733
+ });
1734
+ });
1735
+
1736
+ it('before-selection pause keeps turnsUsed at 0 and clears reserved pending', async () => {
1737
+ const { store, cleanup } = await makeTempStore();
1738
+ const goal = {
1739
+ ...buildSession('active').metadata.openchamber.goal,
1740
+ executionGeneration: 0,
1741
+ createdAt: 1,
1742
+ turnsUsed: 0,
1743
+ statusReason: 'resumed',
1744
+ };
1745
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1746
+
1747
+ let releaseAgent;
1748
+ const agentGate = new Promise((resolve) => { releaseAgent = resolve; });
1749
+ let agentStarted = false;
1750
+ const promptBodies = [];
1751
+
1752
+ await withFetch(makeOpenCodeFetch(), async () => {
1753
+ const originalFetch = globalThis.fetch;
1754
+ globalThis.fetch = async (input, init = {}) => {
1755
+ const reqPath = String(input).split('?')[0];
1756
+ if (pathEndsWith(reqPath, '/agent')) {
1757
+ agentStarted = true;
1758
+ await agentGate;
1759
+ return emptyResponse(204);
1760
+ }
1761
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1762
+ promptBodies.push(JSON.parse(init.body || '{}'));
1763
+ return jsonResponse({ data: { id: 'inbox_1' } });
1764
+ }
1765
+ return originalFetch(input, init);
1766
+ };
1767
+ try {
1768
+ const runtime = createSessionGoalRuntime({
1769
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1770
+ getOpenCodeAuthHeaders: () => ({}),
1771
+ getSmallModelService: async () => ({
1772
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1773
+ }),
1774
+ idleQuietMs: 1_000_000,
1775
+ kickoffQuietMs: 1,
1776
+ readSessionMetadata: (id) => store.get(id),
1777
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1778
+ });
1779
+ const tickPromise = runtime.runTick('ses-goal', '/repo');
1780
+ const started = Date.now();
1781
+ while (!agentStarted) {
1782
+ if (Date.now() - started > 2000) throw new Error('switchAgent never started');
1783
+ await new Promise((r) => setTimeout(r, 5));
1784
+ }
1785
+ // Reserved pending should exist before selection completes.
1786
+ const mid = await store.get('ses-goal');
1787
+ expect(mid.openchamber.goal.turnsUsed).toBe(0);
1788
+ expect(parsePendingContinuation(mid.openchamber.goal.pendingContinuation)?.phase).toBe('reserved');
1789
+
1790
+ await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
1791
+ releaseAgent();
1792
+ await tickPromise;
1793
+
1794
+ expect(promptBodies).toEqual([]);
1795
+ const finalMeta = await store.get('ses-goal');
1796
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(0);
1797
+ expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
1798
+ expect(finalMeta.openchamber.goal.status).toBe('paused');
1799
+ runtime.stop();
1800
+ } finally {
1801
+ globalThis.fetch = originalFetch;
1802
+ cleanup();
1803
+ }
1804
+ });
1805
+ });
1806
+
1807
+ it('persist failure after accept keeps accepted pending and reboot counts once', async () => {
1808
+ const { store, cleanup } = await makeTempStore();
1809
+ const messageID = buildContinuationMessageID({ goalId: 'goal-1', generation: 0, turnsUsed: 1 });
1810
+ const knownMessageIDs = new Set([messageID]);
1811
+ // Durable state after accept + crash before turnsUsed commit.
1812
+ await store.setSessionMetadata('ses-goal', {
1813
+ openchamber: {
1814
+ goal: {
1815
+ ...buildSession('active').metadata.openchamber.goal,
1816
+ executionGeneration: 0,
1817
+ createdAt: 1,
1818
+ turnsUsed: 0,
1819
+ statusReason: 'resumed',
1820
+ pendingContinuation: buildPendingContinuation({
1821
+ messageID,
1822
+ goalId: 'goal-1',
1823
+ generation: 0,
1824
+ turnsUsed: 1,
1825
+ phase: 'accepted',
1826
+ text: 'Continue working toward the active session goal.',
1827
+ providerID: 'p',
1828
+ modelID: 'm',
1829
+ agent: 'build',
1830
+ }),
1831
+ },
1832
+ },
1833
+ });
1834
+ let promptCount = 0;
1835
+
1836
+ await withFetch(makeOpenCodeFetch({ knownMessageIDs }), async () => {
1837
+ const originalFetch = globalThis.fetch;
1838
+ globalThis.fetch = async (input, init = {}) => {
1839
+ const reqPath = String(input).split('?')[0];
1840
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1841
+ promptCount += 1;
1842
+ return jsonResponse({
1843
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
1844
+ });
1845
+ }
1846
+ return originalFetch(input, init);
1847
+ };
1848
+ try {
1849
+ const meta = await store.get('ses-goal');
1850
+ expect(parsePendingContinuation(meta.openchamber.goal.pendingContinuation)?.phase).toBe('accepted');
1851
+ expect(meta.openchamber.goal.turnsUsed).toBe(0);
1852
+
1853
+ // Fresh runtime = reboot: reconcile finds message, counts once, no blind new id.
1854
+ const runtime = createSessionGoalRuntime({
1855
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1856
+ getOpenCodeAuthHeaders: () => ({}),
1857
+ getSmallModelService: async () => ({
1858
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1859
+ }),
1860
+ idleQuietMs: 1_000_000,
1861
+ kickoffQuietMs: 1,
1862
+ readSessionMetadata: (id) => store.get(id),
1863
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1864
+ });
1865
+ await runtime.runTick('ses-goal', '/repo');
1866
+ const after = await store.get('ses-goal');
1867
+ expect(after.openchamber.goal.turnsUsed).toBe(1);
1868
+ expect(after.openchamber.goal.pendingContinuation == null).toBe(true);
1869
+ expect(promptCount).toBe(0);
1870
+ runtime.stop();
1871
+ } finally {
1872
+ globalThis.fetch = originalFetch;
1873
+ cleanup();
1874
+ }
1875
+ });
1876
+ });
1877
+
1878
+ it('transport drop after accept pauses with stable identity and reboot reconciles', async () => {
1879
+ const { store, cleanup } = await makeTempStore();
1880
+ const goal = {
1881
+ ...buildSession('active').metadata.openchamber.goal,
1882
+ executionGeneration: 0,
1883
+ createdAt: 1,
1884
+ turnsUsed: 0,
1885
+ statusReason: 'resumed',
1886
+ };
1887
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1888
+ const knownMessageIDs = new Set();
1889
+ let promptAttempts = 0;
1890
+
1891
+ await withFetch(makeOpenCodeFetch({ knownMessageIDs }), async () => {
1892
+ const originalFetch = globalThis.fetch;
1893
+ globalThis.fetch = async (input, init = {}) => {
1894
+ const reqPath = String(input).split('?')[0];
1895
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1896
+ promptAttempts += 1;
1897
+ const body = JSON.parse(init.body || '{}');
1898
+ // Simulate: upstream accepted the id, then transport dropped the response.
1899
+ if (body.id) knownMessageIDs.add(body.id);
1900
+ throw new Error('socket hang up after accept');
1901
+ }
1902
+ return originalFetch(input, init);
1903
+ };
1904
+ try {
1905
+ const runtime = createSessionGoalRuntime({
1906
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1907
+ getOpenCodeAuthHeaders: () => ({}),
1908
+ getSmallModelService: async () => ({
1909
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1910
+ }),
1911
+ idleQuietMs: 1_000_000,
1912
+ kickoffQuietMs: 1,
1913
+ readSessionMetadata: (id) => store.get(id),
1914
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1915
+ });
1916
+ await runtime.runTick('ses-goal', '/repo');
1917
+ const mid = await store.get('ses-goal');
1918
+ expect(mid.openchamber.goal.status).toBe('paused');
1919
+ expect(String(mid.openchamber.goal.statusReason)).toMatch(/uncertain|continuation/i);
1920
+ const pending = parsePendingContinuation(mid.openchamber.goal.pendingContinuation);
1921
+ expect(pending).toBeTruthy();
1922
+ expect(pending.messageID).toMatch(/^msg_goalc_/);
1923
+ expect(mid.openchamber.goal.turnsUsed).toBe(0);
1924
+ expect(promptAttempts).toBe(1);
1925
+
1926
+ // Reboot + resume: reconcile finds the message, counts once, no second prompt.
1927
+ runtime.stop();
1928
+ await store.mutateSessionMetadata('ses-goal', (current) => ({
1929
+ ok: true,
1930
+ patch: {
1931
+ openchamber: {
1932
+ goal: {
1933
+ ...current.openchamber.goal,
1934
+ status: 'active',
1935
+ statusReason: 'resumed',
1936
+ executionGeneration: current.openchamber.goal.executionGeneration + 1,
1937
+ },
1938
+ },
1939
+ },
1940
+ }));
1941
+ // Keep pending with original generation for stale-gen found path.
1942
+ const runtime2 = createSessionGoalRuntime({
1943
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
1944
+ getOpenCodeAuthHeaders: () => ({}),
1945
+ getSmallModelService: async () => ({
1946
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
1947
+ }),
1948
+ idleQuietMs: 1_000_000,
1949
+ kickoffQuietMs: 1,
1950
+ readSessionMetadata: (id) => store.get(id),
1951
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
1952
+ });
1953
+ await runtime2.runTick('ses-goal', '/repo');
1954
+ const after = await store.get('ses-goal');
1955
+ // Found under stale gen → counted once; may continue a new turn after.
1956
+ expect(after.openchamber.goal.turnsUsed).toBeGreaterThanOrEqual(1);
1957
+ expect(after.openchamber.goal.pendingContinuation == null
1958
+ || after.openchamber.goal.turnsUsed >= 1).toBe(true);
1959
+ runtime2.stop();
1960
+ } finally {
1961
+ globalThis.fetch = originalFetch;
1962
+ cleanup();
1963
+ }
1964
+ });
1965
+ });
1966
+
1967
+ it('reboot with reserved pending re-dispatches same message id and fixed payload', async () => {
1968
+ const { store, cleanup } = await makeTempStore();
1969
+ const messageID = buildContinuationMessageID({ goalId: 'goal-1', generation: 0, turnsUsed: 1 });
1970
+ const fixedText = 'Continue working toward the active session goal.\nFIXED-PAYLOAD-MARKER';
1971
+ const pending = buildPendingContinuation({
1972
+ messageID,
1973
+ goalId: 'goal-1',
1974
+ generation: 0,
1975
+ turnsUsed: 1,
1976
+ phase: 'reserved',
1977
+ text: fixedText,
1978
+ providerID: 'p',
1979
+ modelID: 'm',
1980
+ agent: 'build',
1981
+ });
1982
+ const goal = {
1983
+ ...buildSession('active').metadata.openchamber.goal,
1984
+ executionGeneration: 0,
1985
+ createdAt: 1,
1986
+ turnsUsed: 0,
1987
+ statusReason: 'resumed',
1988
+ pendingContinuation: pending,
1989
+ };
1990
+ await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
1991
+ const promptBodies = [];
1992
+ const knownMessageIDs = new Set();
1993
+
1994
+ await withFetch(makeOpenCodeFetch({ knownMessageIDs }), async () => {
1995
+ const originalFetch = globalThis.fetch;
1996
+ globalThis.fetch = async (input, init = {}) => {
1997
+ const reqPath = String(input).split('?')[0];
1998
+ if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
1999
+ const body = JSON.parse(init.body || '{}');
2000
+ promptBodies.push(body);
2001
+ if (body.id) knownMessageIDs.add(body.id);
2002
+ return jsonResponse({
2003
+ data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
2004
+ });
2005
+ }
2006
+ return originalFetch(input, init);
2007
+ };
2008
+ try {
2009
+ const runtime = createSessionGoalRuntime({
2010
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
2011
+ getOpenCodeAuthHeaders: () => ({}),
2012
+ getSmallModelService: async () => ({
2013
+ generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
2014
+ }),
2015
+ idleQuietMs: 1_000_000,
2016
+ kickoffQuietMs: 1,
2017
+ readSessionMetadata: (id) => store.get(id),
2018
+ mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
2019
+ });
2020
+ await runtime.runTick('ses-goal', '/repo');
2021
+ expect(promptBodies.length).toBe(1);
2022
+ expect(promptBodies[0].id).toBe(messageID);
2023
+ expect(promptBodies[0].text).toBe(fixedText);
2024
+ const finalMeta = await store.get('ses-goal');
2025
+ expect(finalMeta.openchamber.goal.turnsUsed).toBe(1);
2026
+ expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
2027
+ runtime.stop();
2028
+ } finally {
2029
+ globalThis.fetch = originalFetch;
2030
+ cleanup();
2031
+ }
2032
+ });
2033
+ });
2034
+
2035
+ it('pending mutate is invisible to readers until persist succeeds', async () => {
2036
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-goal-pending-'));
2037
+ let unlock;
2038
+ const gate = new Promise((resolve) => { unlock = resolve; });
2039
+ const fsPromises = {
2040
+ ...fs.promises,
2041
+ writeFile: async (target, payload, encoding) => {
2042
+ await gate;
2043
+ return fs.promises.writeFile(target, payload, encoding);
2044
+ },
2045
+ };
2046
+ const store = createSessionMetadataStore({ dataDir, fsPromises });
2047
+ // First write without gate
2048
+ const warm = createSessionMetadataStore({ dataDir });
2049
+ await warm.setSessionMetadata('ses-goal', {
2050
+ openchamber: {
2051
+ goal: {
2052
+ ...buildSession('active').metadata.openchamber.goal,
2053
+ executionGeneration: 0,
2054
+ },
2055
+ },
2056
+ });
2057
+ await store.load();
2058
+
2059
+ const pending = store.mutateSessionMetadata('ses-goal', () => ({
2060
+ ok: true,
2061
+ patch: { openchamber: { goal: { status: 'paused', executionGeneration: 1 } } },
2062
+ }));
2063
+ await expect(store.get('ses-goal')).resolves.toMatchObject({
2064
+ openchamber: { goal: { status: 'active', executionGeneration: 0 } },
2065
+ });
2066
+ unlock();
2067
+ await pending;
2068
+ await expect(store.get('ses-goal')).resolves.toMatchObject({
2069
+ openchamber: { goal: { status: 'paused', executionGeneration: 1 } },
2070
+ });
2071
+ fs.rmSync(dataDir, { recursive: true, force: true });
2072
+ });
2073
+ });