@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
@@ -0,0 +1,167 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import { createSessionTitleRuntime } from './runtime.js';
3
+ import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
4
+ import { createTitleSessionAccess } from './session-access.js';
5
+ import { configureServerOpenCodeFetchGate } from '../opencode/server-opencode-fetch.js';
6
+
7
+ afterEach(() => {
8
+ vi.useRealTimers();
9
+ vi.unstubAllGlobals();
10
+ configureServerOpenCodeFetchGate(null);
11
+ });
12
+
13
+ it.each([false, true])('runs a Host smart-title request and clears loading (model failure: %s)', async (fail) => {
14
+ vi.useFakeTimers();
15
+ const metadata = { openchamber: { titleRefresh: { lastAutoTitle: 'Original', requestedAt: 1000 } } };
16
+ const session = { id: 'ses_title', title: 'Original', directory: '/repo', metadata };
17
+ const updates = [];
18
+ const sessionAccess = {
19
+ get: vi.fn(async () => structuredClone(session)),
20
+ update: vi.fn(async (_id, _directory, patch) => {
21
+ if (patch.title) session.title = patch.title;
22
+ if (patch.metadata) session.metadata = mergeMetadataPatch(session.metadata, patch.metadata);
23
+ updates.push(structuredClone(session));
24
+ }),
25
+ messages: vi.fn(async () => [
26
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Implement a title refresh' }] },
27
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Done' }] },
28
+ ]),
29
+ };
30
+ const generate = vi.fn(async () => {
31
+ expect(session.metadata.openchamber.titleRefresh.isGenerating).toBe(true);
32
+ if (fail) throw new Error('title model unavailable');
33
+ return { text: 'Title refresh', providerID: 'test', modelID: 'test' };
34
+ });
35
+ const runtime = createSessionTitleRuntime({
36
+ sessionAccess,
37
+ buildOpenCodeUrl: (path) => `http://opencode${path}`,
38
+ getOpenCodeAuthHeaders: () => ({}),
39
+ getSmallModelService: async () => ({ generateSmallModelText: generate }),
40
+ now: () => 1000,
41
+ });
42
+ try {
43
+ runtime.processPayload({ type: 'openchamber:session-metadata', properties: {
44
+ sessionID: session.id, directory: '/repo', metadata,
45
+ } });
46
+ await vi.runAllTimersAsync();
47
+ expect(generate).toHaveBeenCalledTimes(1);
48
+ expect(session.title).toBe(fail ? 'Original' : 'Title refresh');
49
+ if (fail) expect(session.metadata.openchamber.titleRefresh.lastError).toBe('title model unavailable');
50
+ expect(session.metadata.openchamber.titleRefresh.isGenerating).not.toBe(true);
51
+ expect(session.metadata.openchamber.titleRefresh.requestedAt).toBeUndefined();
52
+ expect(updates.some((row) => row.metadata.openchamber.titleRefresh.isGenerating)).toBe(true);
53
+ } finally {
54
+ runtime.stop();
55
+ }
56
+ });
57
+
58
+ it('uses the real v2 client and publishes the generated title without another session click', async () => {
59
+ vi.useFakeTimers();
60
+ configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
61
+ let metadata = { openchamber: { titleRefresh: { lastAutoTitle: 'Original', requestedAt: 1000 } } };
62
+ const session = { id: 'ses_title', title: 'Original', location: { directory: '/repo' }, time: { created: 1, updated: 2 } };
63
+ const requests = [];
64
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
65
+ const request = input instanceof Request ? input : new Request(input, init);
66
+ const url = new URL(request.url);
67
+ requests.push(`${request.method} ${url.pathname}`);
68
+ if (url.pathname.endsWith('/message')) {
69
+ expect(url.searchParams.get('order')).toBe('desc');
70
+ return Response.json({ data: [
71
+ { id: 'a1', type: 'assistant', content: [{ type: 'text', text: 'Done' }] },
72
+ { id: 'u1', type: 'user', text: 'Implement title refresh' },
73
+ ] });
74
+ }
75
+ if (request.method === 'PATCH') {
76
+ const body = await request.json();
77
+ expect(Object.keys(body)).toEqual(['title']);
78
+ session.title = body.title;
79
+ return new Response(null, { status: 204 });
80
+ }
81
+ return Response.json({ data: session });
82
+ }));
83
+ const events = [];
84
+ const access = createTitleSessionAccess({
85
+ buildOpenCodeUrl: (path) => `http://opencode${path}`,
86
+ getOpenCodeAuthHeaders: () => ({}),
87
+ readSessionMetadata: async () => metadata,
88
+ persistSessionMetadata: async (_id, patch) => {
89
+ metadata = mergeMetadataPatch(metadata, patch);
90
+ events.push({ type: 'metadata', metadata: structuredClone(metadata) });
91
+ },
92
+ publishSession: (row) => events.push({ type: 'session', row }),
93
+ });
94
+ const runtime = createSessionTitleRuntime({
95
+ sessionAccess: access,
96
+ getSmallModelService: async () => ({ generateSmallModelText: async () => ({ text: 'Title refresh', providerID: 'test', modelID: 'test' }) }),
97
+ now: () => 1000,
98
+ });
99
+ try {
100
+ runtime.processPayload({ type: 'openchamber:session-metadata', properties: {
101
+ sessionID: session.id, metadata, directory: '/repo',
102
+ } });
103
+ await vi.runAllTimersAsync();
104
+ expect(events.some((event) => event.metadata?.openchamber?.titleRefresh?.isGenerating === true)).toBe(true);
105
+ expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh', directory: '/repo' } });
106
+ expect(metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
107
+ expect(requests).toContain('PATCH /api/session/ses_title');
108
+ } finally {
109
+ runtime.stop();
110
+ }
111
+ });
112
+
113
+ it('starts the first title from admitted user text while the assistant is still running', async () => {
114
+ vi.useFakeTimers();
115
+ const session = {
116
+ id: 'ses_first', title: '', time: { created: 1, updated: 1 },
117
+ metadata: {},
118
+ };
119
+ let finishTitle;
120
+ const generation = new Promise((resolve) => { finishTitle = resolve; });
121
+ const generate = vi.fn(() => generation);
122
+ const access = {
123
+ get: vi.fn(async () => structuredClone(session)),
124
+ update: vi.fn(async (_id, _directory, patch) => {
125
+ if (patch.metadata) session.metadata = mergeMetadataPatch(session.metadata, patch.metadata);
126
+ if (patch.title) session.title = patch.title;
127
+ }),
128
+ messages: vi.fn(async () => []),
129
+ };
130
+ const runtime = createSessionTitleRuntime({
131
+ sessionAccess: access,
132
+ getSmallModelService: async () => ({ generateSmallModelText: generate }),
133
+ isTitleRefreshEnabled: () => true,
134
+ now: () => 1000,
135
+ });
136
+ const emit = (type, data) => runtime.processPayload({ type, data, created: 1000, location: { directory: '/repo' } });
137
+ try {
138
+ emit('session.created', { sessionID: session.id, location: { directory: '/repo' } });
139
+ emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_synthetic', item: {
140
+ type: 'synthetic', delivery: 'steer', payload: { text: 'Internal instructions' },
141
+ } });
142
+ await vi.runAllTimersAsync();
143
+ expect(generate).not.toHaveBeenCalled();
144
+ emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_first', item: {
145
+ type: 'user', delivery: 'steer', payload: { text: 'Implement a session title generator' },
146
+ } });
147
+ emit('session.execution.started', { sessionID: session.id });
148
+ emit('session.status', { sessionID: session.id, status: { type: 'busy' } });
149
+ await vi.runAllTimersAsync();
150
+ expect(generate).toHaveBeenCalledTimes(1);
151
+ expect(generate.mock.calls[0][0].prompt).toContain('Implement a session title generator');
152
+ expect(session.metadata.openchamber?.titleRefresh?.isGenerating).toBe(true);
153
+ expect(access.messages).not.toHaveBeenCalled();
154
+ finishTitle({ text: 'Session titles', providerID: 'test', modelID: 'test' });
155
+ await vi.runAllTimersAsync();
156
+ expect(session.title).toBe('Session titles');
157
+ expect(session.metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
158
+ emit('session.execution.succeeded', { sessionID: session.id });
159
+ emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_second', item: {
160
+ type: 'user', delivery: 'steer', payload: { text: 'Follow up on the implementation' },
161
+ } });
162
+ await vi.runAllTimersAsync();
163
+ expect(generate).toHaveBeenCalledTimes(1);
164
+ } finally {
165
+ runtime.stop();
166
+ }
167
+ });
@@ -131,14 +131,18 @@ const buildTitleSystemPrompt = () => [
131
131
  ].join('\n');
132
132
 
133
133
  const extractSessionStatus = (payload) => {
134
- if (!payload || payload.type !== 'session.status') return null;
135
- const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
134
+ if (!payload) return null;
135
+ const executionType = payload.type === 'session.execution.started' ? 'busy'
136
+ : payload.type === 'session.execution.succeeded' || payload.type === 'session.execution.failed' ? 'idle'
137
+ : null;
138
+ if (payload.type !== 'session.status' && !executionType) return null;
139
+ const properties = payload.properties ?? payload.data ?? {};
136
140
  const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
137
141
  const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
138
142
  const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
139
- const type = typeof status.type === 'string'
143
+ const type = executionType ?? (typeof status.type === 'string'
140
144
  ? status.type.trim()
141
- : (typeof info.type === 'string' ? info.type.trim() : '');
145
+ : (typeof info.type === 'string' ? info.type.trim() : ''));
142
146
  if (!sessionId || !type) return null;
143
147
  const directory = typeof properties.directory === 'string' && properties.directory
144
148
  ? properties.directory
@@ -159,9 +163,11 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
159
163
  };
160
164
 
161
165
  const extractTitleRefreshRequest = (payload) => {
162
- if (!payload || payload.type !== 'session.updated') return null;
166
+ if (!payload || (payload.type !== 'session.updated' && payload.type !== 'openchamber:session-metadata')) return null;
163
167
  const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
164
- const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
168
+ const info = payload.type === 'openchamber:session-metadata'
169
+ ? { id: properties.sessionID, metadata: properties.metadata }
170
+ : (properties.info && typeof properties.info === 'object' ? properties.info : {});
165
171
  if (isSystemOwnedSession(info)) return null;
166
172
  const sessionId = typeof info.id === 'string' ? info.id.trim() : '';
167
173
  const titleRefresh = info.metadata?.openchamber?.titleRefresh;
@@ -176,20 +182,35 @@ const extractTitleRefreshRequest = (payload) => {
176
182
 
177
183
  const extractCreatedSession = (payload) => {
178
184
  if (!payload || payload.type !== 'session.created') return null;
179
- const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
180
- const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
185
+ const properties = payload.properties ?? payload.data ?? {};
186
+ const info = properties.info ?? { ...properties, id: properties.sessionID };
181
187
  const sessionId = typeof info.id === 'string' ? info.id.trim() : '';
182
188
  if (!sessionId || (typeof info.parentID === 'string' && info.parentID)) return null;
183
189
  if (isSystemOwnedSession(info)) return null;
184
190
  const directory = typeof properties.directory === 'string' && properties.directory
185
191
  ? properties.directory
186
- : (typeof info.directory === 'string' ? info.directory : '');
187
- const createdAt = typeof info.time?.created === 'number' ? info.time.created : Date.now();
192
+ : (typeof info.location?.directory === 'string' ? info.location.directory : (typeof info.directory === 'string' ? info.directory : ''));
193
+ const createdAt = typeof info.time?.created === 'number' ? info.time.created : (payload.created ?? Date.now());
188
194
  const title = typeof info.title === 'string' ? info.title : '';
189
195
  return { sessionId, directory, createdAt, title };
190
196
  };
191
197
 
192
198
  const extractUserMessage = (payload) => {
199
+ if (payload?.type === 'session.inbox.enqueued') {
200
+ const properties = payload.properties ?? payload.data ?? {};
201
+ const item = properties.item;
202
+ if (typeof properties.sessionID !== 'string' || item?.type !== 'user') return null;
203
+ const text = typeof item.payload?.text === 'string' ? item.payload.text.slice(0, TRANSCRIPT_USER_CHAR_LIMIT) : '';
204
+ return {
205
+ sessionId: properties.sessionID,
206
+ createdAt: payload.created ?? 0,
207
+ directory: payload.location?.directory ?? '',
208
+ initialRecord: text.trim() ? {
209
+ info: { id: properties.inboxID, role: 'user' },
210
+ parts: [{ type: 'text', text }],
211
+ } : null,
212
+ };
213
+ }
193
214
  if (!payload || payload.type !== 'message.updated') return null;
194
215
  const info = payload.properties?.info;
195
216
  if (!info || typeof info !== 'object' || info.role !== 'user') return null;
@@ -351,6 +372,9 @@ export const buildLatestTitleTranscript = (
351
372
  export const createSessionTitleRuntime = ({
352
373
  buildOpenCodeUrl,
353
374
  getOpenCodeAuthHeaders,
375
+ /** Ticket 11: Host write admission shared with proxy (optional). */
376
+ serverOpenCodeFetch = null,
377
+ sessionAccess = null,
354
378
  getSmallModelService,
355
379
  quietMs = TITLE_QUIET_MS,
356
380
  throttleMs = TITLE_THROTTLE_MS,
@@ -362,6 +386,7 @@ export const createSessionTitleRuntime = ({
362
386
  const forcedRefreshes = new Set();
363
387
  const initialRefreshes = new Set();
364
388
  const newSessions = new Set();
389
+ const initialUserMessages = new Map();
365
390
  const forkFirstSendPending = new Map();
366
391
  const forkFirstRefreshes = new Set();
367
392
  /** In-memory throttle timestamps (metadata also persists across restarts). */
@@ -377,6 +402,20 @@ export const createSessionTitleRuntime = ({
377
402
  };
378
403
 
379
404
  const openCodeFetch = async (pathname, { directory, method = 'GET', body } = {}) => {
405
+ if (sessionAccess) {
406
+ const sessionId = decodeURIComponent(pathname.slice('/session/'.length));
407
+ return method === 'PATCH'
408
+ ? sessionAccess.update(sessionId, directory, body)
409
+ : sessionAccess.get(sessionId, directory);
410
+ }
411
+ if (typeof serverOpenCodeFetch === 'function') {
412
+ return serverOpenCodeFetch(pathname, {
413
+ directory,
414
+ method,
415
+ body,
416
+ timeoutMs: FETCH_TIMEOUT_MS,
417
+ });
418
+ }
380
419
  const base = buildOpenCodeUrl(pathname, '');
381
420
  const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
382
421
  const response = await fetch(url, {
@@ -396,6 +435,7 @@ export const createSessionTitleRuntime = ({
396
435
  };
397
436
 
398
437
  const fetchRecentMessages = async (sessionId, directory) => {
438
+ if (sessionAccess) return sessionAccess.messages(sessionId, directory, TRANSCRIPT_MESSAGE_LIMIT);
399
439
  const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
400
440
  const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
401
441
  if (directory) params.set('directory', directory);
@@ -421,20 +461,13 @@ export const createSessionTitleRuntime = ({
421
461
 
422
462
  const meta = readTitleRefreshMeta(session);
423
463
  if (meta.titleRefresh.isGenerating !== true) return;
424
- const {
425
- isGenerating: _isGenerating,
426
- requestedAt: _requestedAt,
427
- ...titleRefresh
428
- } = meta.titleRefresh;
429
464
  await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
430
465
  directory,
431
466
  method: 'PATCH',
432
467
  body: {
433
468
  metadata: {
434
- ...meta.metadata,
435
469
  openchamber: {
436
- ...meta.openchamber,
437
- titleRefresh,
470
+ titleRefresh: { isGenerating: null, requestedAt: null },
438
471
  },
439
472
  },
440
473
  },
@@ -446,17 +479,13 @@ export const createSessionTitleRuntime = ({
446
479
  .catch(() => null);
447
480
  if (!session || typeof session !== 'object') return;
448
481
 
449
- const meta = readTitleRefreshMeta(session);
450
482
  await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
451
483
  directory,
452
484
  method: 'PATCH',
453
485
  body: {
454
486
  metadata: {
455
- ...meta.metadata,
456
487
  openchamber: {
457
- ...meta.openchamber,
458
488
  titleRefresh: {
459
- ...meta.titleRefresh,
460
489
  lastError: error instanceof Error ? error.message : String(error),
461
490
  failedAt: now(),
462
491
  },
@@ -505,11 +534,8 @@ export const createSessionTitleRuntime = ({
505
534
  method: 'PATCH',
506
535
  body: {
507
536
  metadata: {
508
- ...meta.metadata,
509
537
  openchamber: {
510
- ...meta.openchamber,
511
538
  titleRefresh: {
512
- ...meta.titleRefresh,
513
539
  activityUpdatedAt,
514
540
  },
515
541
  },
@@ -524,6 +550,8 @@ export const createSessionTitleRuntime = ({
524
550
  const generateTitle = async (sessionId, directory) => {
525
551
  const forceRefresh = forcedRefreshes.delete(sessionId);
526
552
  const initialRefresh = initialRefreshes.delete(sessionId);
553
+ const initialUserMessage = initialUserMessages.get(sessionId);
554
+ initialUserMessages.delete(sessionId);
527
555
  const forkFirstRefresh = forkFirstRefreshes.delete(sessionId);
528
556
  if (!forceRefresh && !isTitleRefreshEnabled()) return;
529
557
 
@@ -558,15 +586,14 @@ export const createSessionTitleRuntime = ({
558
586
  return;
559
587
  }
560
588
 
561
- const messages = await fetchRecentMessages(sessionId, directory);
589
+ const messages = initialUserMessage ? [initialUserMessage] : await fetchRecentMessages(sessionId, directory);
562
590
  if (!messages || messages.length === 0) return;
563
591
 
564
592
  const { transcript, lastAssistantId, realUserCount, languageSample } = buildLatestTitleTranscript(messages);
565
593
  if (!transcript) return;
566
594
 
567
- // Let OpenCode's first-message title land first. We only refresh once the
568
- // conversation has moved on (2+ real user turns), unless the title is
569
- // still the default placeholder.
595
+ // Initial titles use the admitted first user message immediately; ordinary
596
+ // later refreshes still require a moved-on conversation or placeholder.
570
597
  if (!forceRefresh && !initialRefresh && !forkFirstRefresh && realUserCount < 2 && !isDefaultSessionTitle(currentTitle)) {
571
598
  return;
572
599
  }
@@ -597,14 +624,11 @@ export const createSessionTitleRuntime = ({
597
624
  method: 'PATCH',
598
625
  body: {
599
626
  metadata: {
600
- ...meta.metadata,
601
627
  openchamber: {
602
- ...meta.openchamber,
603
628
  titleRefresh: {
604
- ...meta.titleRefresh,
605
629
  isGenerating: true,
606
- lastError: undefined,
607
- failedAt: undefined,
630
+ lastError: null,
631
+ failedAt: null,
608
632
  },
609
633
  },
610
634
  },
@@ -677,9 +701,6 @@ export const createSessionTitleRuntime = ({
677
701
  const generatedAt = now();
678
702
  lastGeneratedAtBySession.set(sessionId, generatedAt);
679
703
 
680
- const currentMetadata = freshMeta.metadata;
681
- const currentNamespace = freshMeta.openchamber;
682
-
683
704
  console.log(`[session-title] refreshed ${sessionId} → "${nextTitle}" via ${generated.providerID}/${generated.modelID}`);
684
705
  await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
685
706
  directory,
@@ -687,11 +708,8 @@ export const createSessionTitleRuntime = ({
687
708
  body: {
688
709
  title: nextTitle,
689
710
  metadata: {
690
- ...currentMetadata,
691
711
  openchamber: {
692
- ...currentNamespace,
693
712
  titleRefresh: {
694
- ...freshMeta.titleRefresh,
695
713
  lastAutoTitle: nextTitle,
696
714
  forMessageID: lastAssistantId || latestAssistantId || '',
697
715
  generatedAt,
@@ -723,6 +741,7 @@ export const createSessionTitleRuntime = ({
723
741
 
724
742
  const processPayload = (payload, directoryHint = '') => {
725
743
  if (stopped) return;
744
+ directoryHint = directoryHint || payload?.location?.directory || '';
726
745
  const createdSession = extractCreatedSession(payload);
727
746
  if (createdSession) {
728
747
  if (isForkedSessionTitle(createdSession.title)) {
@@ -758,13 +777,18 @@ export const createSessionTitleRuntime = ({
758
777
  initialRefreshes.add(status.sessionId);
759
778
  armTimer(status.sessionId, status.directory || directoryHint, 0);
760
779
  }
761
- } else {
780
+ } else if (!initialRefreshes.has(status.sessionId)) {
762
781
  clearTimer(status.sessionId);
763
782
  }
764
783
  return;
765
784
  }
766
785
  const userMessage = extractUserMessage(payload);
767
786
  if (userMessage) {
787
+ if (userMessage.initialRecord && newSessions.delete(userMessage.sessionId)) {
788
+ initialRefreshes.add(userMessage.sessionId);
789
+ initialUserMessages.set(userMessage.sessionId, userMessage.initialRecord);
790
+ armTimer(userMessage.sessionId, userMessage.directory || directoryHint, 0);
791
+ }
768
792
  const pendingFork = forkFirstSendPending.get(userMessage.sessionId);
769
793
  if (pendingFork && userMessage.createdAt > pendingFork.createdAt) {
770
794
  pendingFork.hasNewUserMessage = true;
@@ -780,7 +804,7 @@ export const createSessionTitleRuntime = ({
780
804
  // session settles. Only a message created after the timer was armed
781
805
  // means the user actually moved on.
782
806
  const armed = timers.get(userMessage.sessionId);
783
- if (armed && userMessage.createdAt >= armed.armedAt) {
807
+ if (armed && !initialRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
784
808
  clearTimer(userMessage.sessionId);
785
809
  }
786
810
  }
@@ -792,6 +816,7 @@ export const createSessionTitleRuntime = ({
792
816
  clearTimeout(timer);
793
817
  }
794
818
  timers.clear();
819
+ initialUserMessages.clear();
795
820
  };
796
821
 
797
822
  return { processPayload, stop, armTimer };
@@ -0,0 +1,46 @@
1
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
2
+ import { projectSessionWithHostMetadata } from '../session-metadata/session-projection.js';
3
+ import { projectSessionMessageRecords } from '../session-turn-pages/session-message-projection.js';
4
+
5
+ /** Title reads use OpenCode; title-refresh metadata and its notifications belong to the Host. */
6
+ export function createTitleSessionAccess({
7
+ buildOpenCodeUrl,
8
+ getOpenCodeAuthHeaders,
9
+ readSessionMetadata,
10
+ persistSessionMetadata,
11
+ publishSession,
12
+ }) {
13
+ const client = () => makeOpenCodeV2Client({
14
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
15
+ authHeaders: getOpenCodeAuthHeaders(),
16
+ });
17
+ const options = () => ({ signal: AbortSignal.timeout(30_000) });
18
+ const get = async (sessionID) => {
19
+ const session = await client().session.get({ sessionID }, options());
20
+ return projectSessionWithHostMetadata(session, await readSessionMetadata(sessionID));
21
+ };
22
+ return {
23
+ get,
24
+ async messages(sessionID, _directory, limit) {
25
+ const page = await client().message.list({ sessionID, limit, order: 'desc' }, options());
26
+ if (!Array.isArray(page?.data)) throw new Error('Session title: invalid message page');
27
+ return projectSessionMessageRecords([...page.data].reverse());
28
+ },
29
+ async update(sessionID, directory, patch) {
30
+ if (typeof patch.title === 'string') {
31
+ await client().session.update({ sessionID, title: patch.title }, options());
32
+ }
33
+ // Never write the complete metadata snapshot back: goal/archive/ownership
34
+ // may have changed while the title model was running.
35
+ const refresh = patch.metadata?.openchamber?.titleRefresh;
36
+ if (refresh) {
37
+ await persistSessionMetadata(sessionID, { openchamber: { titleRefresh: refresh } });
38
+ }
39
+ if (typeof patch.title === 'string' || (refresh && Object.hasOwn(refresh, 'isGenerating'))) {
40
+ const session = await get(sessionID);
41
+ await publishSession(session, directory);
42
+ return session;
43
+ }
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,91 @@
1
+ import { beforeEach, expect, it, vi } from 'vitest';
2
+ import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
3
+
4
+ const { api } = vi.hoisted(() => ({ api: {
5
+ session: { get: vi.fn(), update: vi.fn() },
6
+ message: { list: vi.fn() },
7
+ } }));
8
+ vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: () => api }));
9
+ import { createTitleSessionAccess } from './session-access.js';
10
+
11
+ beforeEach(() => vi.resetAllMocks());
12
+
13
+ function fixture() {
14
+ let metadata = { openchamber: {
15
+ goal: { status: 'active' },
16
+ titleRefresh: { isGenerating: true, requestedAt: 1000, lastError: 'old failure', failedAt: 1 },
17
+ } };
18
+ api.session.get.mockResolvedValue({
19
+ id: 'ses_title', title: 'Updated title', location: { directory: '/repo' },
20
+ time: { created: 10, updated: 20 },
21
+ });
22
+ const persist = vi.fn(async (_id, patch) => { metadata = mergeMetadataPatch(metadata, patch); });
23
+ const publish = vi.fn();
24
+ const access = createTitleSessionAccess({
25
+ buildOpenCodeUrl: (path) => `http://opencode${path}`,
26
+ getOpenCodeAuthHeaders: () => ({}),
27
+ readSessionMetadata: async () => metadata,
28
+ persistSessionMetadata: persist,
29
+ publishSession: publish,
30
+ });
31
+ return { access, persist, publish, metadata: () => metadata };
32
+ }
33
+
34
+ it('publishes the full authoritative title row and clears Host loading without overwriting unrelated state', async () => {
35
+ const f = fixture();
36
+ await f.access.update('ses_title', '/repo', {
37
+ title: 'Updated title',
38
+ metadata: { openchamber: { goal: { status: 'stale' }, titleRefresh: {
39
+ lastAutoTitle: 'Updated title', isGenerating: null, requestedAt: null, lastError: null, failedAt: null,
40
+ } } },
41
+ });
42
+ expect(api.session.update.mock.calls[0][0]).toEqual({ sessionID: 'ses_title', title: 'Updated title' });
43
+ expect(f.metadata()).toEqual({ openchamber: {
44
+ goal: { status: 'active' }, titleRefresh: { lastAutoTitle: 'Updated title' },
45
+ } });
46
+ expect(f.publish).toHaveBeenCalledWith(expect.objectContaining({
47
+ id: 'ses_title', title: 'Updated title', directory: '/repo', metadata: f.metadata(),
48
+ }), '/repo');
49
+ });
50
+
51
+ it('projects only the bounded newest message page in conversation order', async () => {
52
+ const f = fixture();
53
+ api.message.list.mockResolvedValue({ data: [
54
+ { id: 'a1', type: 'assistant', content: [{ type: 'text', text: 'Finished' }] },
55
+ { id: 'u1', type: 'user', text: 'Implement title refresh' },
56
+ ] });
57
+ const messages = await f.access.messages('ses_title', '/repo', 16);
58
+ expect(api.message.list.mock.calls[0][0]).toEqual({ sessionID: 'ses_title', limit: 16, order: 'desc' });
59
+ expect(messages.map((row) => row.info.id)).toEqual(['u1', 'a1']);
60
+ expect(messages[0].parts[0].text).toBe('Implement title refresh');
61
+ expect(messages[1].parts[0].text).toBe('Finished');
62
+ });
63
+
64
+ it('publishes a full loading row before the new session exists in client catalogs', async () => {
65
+ const f = fixture();
66
+ await f.access.update('ses_title', '/repo', { metadata: { openchamber: { titleRefresh: { isGenerating: true } } } });
67
+ expect(f.publish).toHaveBeenCalledWith(expect.objectContaining({
68
+ id: 'ses_title', directory: '/repo', metadata: expect.objectContaining({
69
+ openchamber: expect.objectContaining({ titleRefresh: expect.objectContaining({ isGenerating: true }) }),
70
+ }),
71
+ }), '/repo');
72
+ expect(api.session.update).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('does not publish success on a failed title write', async () => {
76
+ const f = fixture();
77
+ api.session.update.mockRejectedValue(new Error('offline'));
78
+ await expect(f.access.update('ses_title', '/repo', {
79
+ title: 'Failed', metadata: { openchamber: { titleRefresh: { lastAutoTitle: 'Failed' } } },
80
+ })).rejects.toThrow('offline');
81
+ expect(f.persist).not.toHaveBeenCalled();
82
+ expect(f.publish).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('does not turn a failed or malformed message read into empty success', async () => {
86
+ const f = fixture();
87
+ api.message.list.mockRejectedValueOnce(new Error('offline'));
88
+ await expect(f.access.messages('ses_title', '/repo', 16)).rejects.toThrow('offline');
89
+ api.message.list.mockResolvedValueOnce({});
90
+ await expect(f.access.messages('ses_title', '/repo', 16)).rejects.toThrow('invalid message page');
91
+ });
@@ -4,7 +4,7 @@
4
4
 
5
5
  OpenChamber-owned turn-window pagination and anchor reconcile for session messages.
6
6
 
7
- 1. **Turn pages** — clients request the last N **authored user turns** (default 3) rather than a raw message count. The host loops official OpenCode `session.messages` pages (`limit` / `before` / `x-next-cursor`) until the turn budget is met or history is exhausted.
7
+ 1. **Turn pages** — clients request the last N **authored user turns** (default 3) rather than a raw message count. The host loops OpenCode v2 `message.list` pages (first page `limit` + `order=desc`; continuation `limit` + `cursor` from the previous body `cursor.next` — upstream rejects `order` together with `cursor` with 400) until the turn budget is met or history is exhausted.
8
8
  2. **Anchor reconcile** — after SSE reconnect, clients request the gap from a stable turn-boundary anchor to the current head. The host scans newest→older, returns chronological gap records (including the anchor's overlap turn), and paginates via host-owned continuation tokens with page/byte/total budgets.
9
9
 
10
10
  Exposed as:
@@ -16,7 +16,7 @@ Exposed as:
16
16
 
17
17
  ## Why this exists
18
18
 
19
- Official OpenCode pagination is message-count based and uses **opaque** cursors (not message ids). Each upstream page is chronological **old→new** (including the latest slice). UI prepend/load-more needs a stable turn boundary so synthetic loop messages, subtasks, and compaction rows do not consume the turn budget. This module aggregates upstream pages on the host, returns chronological records, and exposes a continuous **host-owned opaque cursor** that points just before the earliest returned authored user.
19
+ Official OpenCode pagination is message-count based and uses **opaque** cursors (not message ids). v2 pages arrive **newest-first**; `fetchPage` reverses each page to chronological **old→new** before aggregation. UI prepend/load-more needs a stable turn boundary so synthetic loop messages, subtasks, and compaction rows do not consume the turn budget. This module aggregates upstream pages on the host, returns chronological records, and exposes a continuous **host-owned opaque cursor** that points just before the earliest returned authored user.
20
20
 
21
21
  ## Scope
22
22
 
@@ -66,7 +66,7 @@ Success `200`:
66
66
  }
67
67
  ```
68
68
 
69
- - `records`: chronological oldest → newest, including intermediate non-boundary rows (synthetic, tools, subtask, compaction) inside the window.
69
+ - `records`: chronological oldest → newest, including intermediate non-boundary rows (synthetic, tools, subtask, compaction) inside the window. Host projection drops v2 **control** SessionMessage rows (`idle`, `model-switched`, `agent-selected`, `agent-switched`, `location-switched`) so they never reach ChatMessage as empty Assistant headers. Native assistants project `content[]` once (never also top-level `text` when `content` is present). Wire `model.id` becomes domain `modelID`; `finish`/`error` on failed assistants (e.g. `provider.auth` 401) are preserved.
70
70
  - `turnCount`: count of authored user boundaries in `records`.
71
71
  - `cursor`: host-owned opaque token for the next `before=` request; `null` when `complete` is true. The token represents the position **just before** the earliest returned authored user (so the client can load older history without overlap).
72
72
  - `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, or when upstream stopped on a full page with a missing `nextCursor` (suspicious truncation), `complete` stays `false` with a `cursor` so the client can fetch / retry.
@@ -126,7 +126,7 @@ On resume:
126
126
  1. Strict decode / shape / length validation of the host token.
127
127
  2. First `fetchPage` uses the token’s raw upstream `before`.
128
128
  3. Locate `boundaryID` in that page; keep only records strictly older than it (`slice(0, index)` in old→new order). Boundary and newer rows are excluded.
129
- 4. If fewer than N turns remain, continue with that raw page’s `x-next-cursor` for older pages.
129
+ 4. If fewer than N turns remain, continue with that raw page’s `cursor.next` for older pages.
130
130
  5. Each accumulated record records its upstream page’s request-before origin; when `complete=false`, the next host cursor encodes the earliest selected authored user’s origin + id.
131
131
 
132
132
  A raw OpenCode SDK cursor (no `oc1.` prefix) is still accepted as the first `before` and is passed through unchanged to `fetchPage`.
@@ -146,14 +146,15 @@ A message is an authored user turn boundary when:
146
146
 
147
147
  ## Aggregation rules
148
148
 
149
- - Upstream pages are chronological **old→new** within each page; **do not reverse**. Older pages are **prepended** while deduping by `info.id` (global order remains old→new).
150
- - Continues paging with raw upstream `before` until `turns` authored boundaries are collected or upstream reports no next cursor.
151
- - **Suspicious truncation:** an upstream page whose `records.length >= scanLimit` (full page) but `nextCursor == null` is treated as a non-authoritative cut (observed OpenCode `x-next-cursor` race), not as end-of-history. The host stops scanning that round with `upstreamComplete=false`, so the response stays `complete=false` and still emits a boundary host cursor the client can retry. A short page (`records.length < scanLimit`) with no cursor, an empty page with no cursor, or an explicit `page.complete === true` remain authoritative exhaustion. Client no-growth handling still bounds retries if the upstream view stays truncated.
149
+ - `fetchPage` returns each page chronological **old→new** (v2 `data` is newest-first and is reversed there). Older pages are **prepended** while deduping by `info.id` (global order remains old→new).
150
+ - Continues paging with the raw upstream `cursor.next` (sent as `cursor`, never with `order`) until `turns` authored boundaries are collected or upstream reports no next cursor.
151
+ - **Suspicious truncation:** an upstream page whose `records.length >= scanLimit` (full page) but `nextCursor == null` is treated as a non-authoritative cut (observed OpenCode next-cursor race), not as end-of-history. The host stops scanning that round with `upstreamComplete=false`, so the response stays `complete=false` and still emits a boundary host cursor the client can retry. A short page (`records.length < scanLimit`) with no cursor, an empty page with no cursor, or an explicit `page.complete === true` remain authoritative exhaustion. Client no-growth handling still bounds retries if the upstream view stays truncated.
152
152
  - Every upstream record must have a non-empty `info.id`; otherwise structured error, no partial `records`, HTTP 502.
153
153
  - Hard scan caps (no partial success): **50 pages** / **5000 messages** → structured error, HTTP 413.
154
154
  - Repeated / stalled cursor, or empty page that still carries a next cursor → structured error, no partial `records`, HTTP 502.
155
155
  - Malformed upstream payloads and SDK/transport failures → HTTP 502 (`error: "upstream"`).
156
- - `AbortSignal` is forwarded to each `session.messages` call; route also applies a 45s linked timeout.
156
+ - Native v2 `SessionMessageInfo` rows are projected to the external `{ info, parts }` record contract by `session-message-projection.js` (control rows such as `idle` / `model-switched` are dropped). The VS Code turn-page bridge imports the same module so both runtimes emit identical records.
157
+ - `AbortSignal` is forwarded to each `message.list` call; route also applies a 45s linked timeout.
157
158
  - Request abort only when the client disconnects mid-flight: `req` aborted, or `res` `close` while `!res.writableEnded`. A normal GET request `close` after the response has ended must not abort.
158
159
 
159
160
  ## Anchor reconcile API