@openchambery/web 1.19.22-beta.1 → 2.0.0-beta.3

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 (311) hide show
  1. package/README.md +13 -7
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +179 -0
  6. package/dist/assets/{AgentAvatar-CRaBJXKs.js → AgentAvatar-B_JQoreg.js} +1 -1
  7. package/dist/assets/AssistantView-ClQAfGOT.js +3 -0
  8. package/dist/assets/{DiagramView-D1LGK_s2.js → DiagramView-BNnda8qO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-B0kJo9Ii.js → FadeInOnReveal-VYQTSAkr.js} +1 -1
  10. package/dist/assets/{Icon-BgdfPhNs.js → Icon-BiEQEcih.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-CJHSsWin.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-DfoCJ1Mq.js → MarkdownRendererImpl-DwrHFGUp.js} +3 -3
  13. package/dist/assets/{MarkstreamRendererImpl-D2dsW5Lg.js → MarkstreamRendererImpl-CSnbQMu3.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-BXr6AGLT.js +1 -0
  15. package/dist/assets/{MobileShareBridge-2Yjn6wXg.js → MobileShareBridge-D7OE9OP_.js} +2 -2
  16. package/dist/assets/MobileSurface-D2jUkX9l.js +2 -0
  17. package/dist/assets/{MultiRunWindow-tTbMFowS.js → MultiRunWindow-DF3iIFCd.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-CD2KtlF3.js → OnboardingScreen-BjHmlwlw.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-DLtWO_Gp.js +2 -0
  20. package/dist/assets/SettingsView-BRxELjow.js +25 -0
  21. package/dist/assets/{SettingsWindow-zIa76s4t.js → SettingsWindow-BI5swspb.js} +1 -1
  22. package/dist/assets/{TerminalView-CDdzF4yi.js → TerminalView-BU3x8-fr.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-DNqA4uBl.js → ToolOutputDialog-BEWhBcRd.js} +2 -2
  24. package/dist/assets/appThemeRegistry-4MR0F7Jz.js +1 -0
  25. package/dist/assets/{button-CI7Q-Zw_.js → button-DKuz3L8l.js} +1 -1
  26. package/dist/assets/collapsible-DXbDynN4.js +3 -0
  27. package/dist/assets/context-menu-CqolsLCm.js +1 -0
  28. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-CoDkOnyS.js} +1 -1
  29. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DDlVKyOE.js} +1 -1
  30. package/dist/assets/{diagram-DdLO8HZ9.js → diagram-D7T8HQgj.js} +1 -1
  31. package/dist/assets/{dialog-B6pnIYmp.js → dialog-DQYl3W_o.js} +1 -1
  32. package/dist/assets/es-CN3E_zXj.js +15 -0
  33. package/dist/assets/{fileMentionTouchSelection-DNeiqns-.js → fileMentionTouchSelection-BZ6ovBYH.js} +1 -1
  34. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-JrpcsOCs.js} +1 -1
  35. package/dist/assets/fr-9zZvQZD2.js +13 -0
  36. package/dist/assets/gitApi-CM42qUed.js +10 -0
  37. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-CIl0FL10.js} +1 -1
  38. package/dist/assets/{input-D-__fwRi.js → input-CzQy8UhV.js} +1 -1
  39. package/dist/assets/insertionBoundaries-CtkZ9720.js +1 -0
  40. package/dist/assets/ja-Dnjk_82N.js +15 -0
  41. package/dist/assets/ko-BxCEE99C.js +15 -0
  42. package/dist/assets/main-BILgBI3B.js +169 -0
  43. package/dist/assets/main-BeveiW6F.js +2 -0
  44. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  45. package/dist/assets/{markdownHeightCache-BhxqJOvf.js → markdownHeightCache-Cj1I5NDw.js} +1 -1
  46. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  47. package/dist/assets/mcpQueries-D5mQymgM.js +1 -0
  48. package/dist/assets/miniChat-BgrzM3u7.js +2 -0
  49. package/dist/assets/mobile-DtYoQE8C.js +2 -0
  50. package/dist/assets/multirun-jy8dEI96.js +4 -0
  51. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-C9LQpiOS.js} +2 -2
  52. package/dist/assets/pl-CNNsWnPM.js +15 -0
  53. package/dist/assets/platform-BQghHRfp.js +6 -0
  54. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  55. package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +2 -0
  56. package/dist/assets/renderMobileApp-u3c4GtFC.js +28 -0
  57. package/dist/assets/{runtimeConfig-CMkwoQ_X.js → runtimeConfig-BIGBRGQI.js} +1 -1
  58. package/dist/assets/{runtimeEndpointReset-ChOxt-J7.js → runtimeEndpointReset-D2savkuw.js} +12 -12
  59. package/dist/assets/{sessionChangeSummary-NnxcqCTI.js → sessionChangeSummary-eIbpSfrO.js} +1 -1
  60. package/dist/assets/{sessionLookup-BrU3rFHJ.js → sessionLookup-myHe5ltu.js} +1 -1
  61. package/dist/assets/{sortable-tabs-strip-Fnfhf3K9.js → sortable-tabs-strip-BQtmHx9r.js} +1 -1
  62. package/dist/assets/src-CLN7Z-zx.css +2 -0
  63. package/dist/assets/store-DLCiZaA6.js +15 -0
  64. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-BxcdiduX.js} +1 -1
  65. package/dist/assets/{textarea-bHYxZtfH.js → textarea-CakKvqFw.js} +1 -1
  66. package/dist/assets/{tooltip-0T5jv0cu.js → tooltip-Cx5gTbdY.js} +1 -1
  67. package/dist/assets/uk-rwQrxeYM.js +15 -0
  68. package/dist/assets/{url-_z7evzdK.js → url-CDHnjlsm.js} +1 -1
  69. package/dist/assets/{useAppFontEffects-C02YFyAv.js → useAppFontEffects-CNeo0aZs.js} +59 -45
  70. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  71. package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-B4LvR8Gt.js +1 -0
  73. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-CUnQ4TVm.js} +1 -1
  74. package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +414 -0
  75. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  76. package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +2 -0
  77. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  78. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  79. package/dist/assets/{useWorkerHighlightedLines-C9z0ML1z.js → useWorkerHighlightedLines-CJZbJDW3.js} +2 -2
  80. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  81. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  82. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  83. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  84. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  85. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  86. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  87. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  88. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  89. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  90. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  91. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  92. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  93. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  94. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  95. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  96. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  97. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  98. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  99. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  100. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  101. package/dist/index.html +13 -13
  102. package/dist/mini-chat.html +13 -13
  103. package/dist/mobile.html +13 -13
  104. package/package.json +2 -2
  105. package/server/index.js +392 -20
  106. package/server/lib/assistants/DOCUMENTATION.md +26 -21
  107. package/server/lib/assistants/assign.js +2 -1
  108. package/server/lib/assistants/contact-store.js +37 -19
  109. package/server/lib/assistants/contracts.js +11 -1
  110. package/server/lib/assistants/projection-history.test.js +454 -0
  111. package/server/lib/assistants/routes.js +11 -5
  112. package/server/lib/assistants/service.js +850 -294
  113. package/server/lib/assistants/service.test.js +588 -274
  114. package/server/lib/conversations/DOCUMENTATION.md +5 -5
  115. package/server/lib/conversations/service.js +87 -57
  116. package/server/lib/conversations/service.test.js +132 -100
  117. package/server/lib/conversations/validation.js +11 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +51 -5
  120. package/server/lib/event-stream/runtime.js +57 -20
  121. package/server/lib/event-stream/runtime.test.js +112 -0
  122. package/server/lib/llm/DOCUMENTATION.md +95 -84
  123. package/server/lib/llm/catalog.js +111 -45
  124. package/server/lib/llm/catalog.test.js +121 -59
  125. package/server/lib/llm/completions.js +41 -12
  126. package/server/lib/llm/completions.test.js +45 -4
  127. package/server/lib/llm/generate.js +516 -453
  128. package/server/lib/llm/generate.test.js +705 -484
  129. package/server/lib/llm/routes.js +8 -4
  130. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  131. package/server/lib/message-queue/opencode-adapter.js +191 -104
  132. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  133. package/server/lib/message-queue/runtime.js +15 -2
  134. package/server/lib/message-queue/runtime.test.js +6 -6
  135. package/server/lib/message-queue/worker.js +7 -1
  136. package/server/lib/notifications/runtime.js +5 -0
  137. package/server/lib/opencode/DOCUMENTATION.md +103 -16
  138. package/server/lib/opencode/agent-document.d.ts +28 -0
  139. package/server/lib/opencode/agent-document.js +280 -0
  140. package/server/lib/opencode/agent-document.test.js +137 -0
  141. package/server/lib/opencode/agents.js +96 -0
  142. package/server/lib/opencode/auth-state-runtime.js +5 -0
  143. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  144. package/server/lib/opencode/config-entity-routes.js +111 -102
  145. package/server/lib/opencode/config-entity-routes.test.js +123 -10
  146. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  147. package/server/lib/opencode/ensure-cli.js +238 -0
  148. package/server/lib/opencode/ensure-cli.test.js +119 -0
  149. package/server/lib/opencode/env-runtime.js +208 -111
  150. package/server/lib/opencode/env-runtime.test.js +211 -14
  151. package/server/lib/opencode/feature-routes-runtime.js +80 -5
  152. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  153. package/server/lib/opencode/http-server-connections.js +98 -0
  154. package/server/lib/opencode/http-server-connections.test.js +321 -0
  155. package/server/lib/opencode/lifecycle.js +539 -95
  156. package/server/lib/opencode/lifecycle.test.js +698 -6
  157. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  158. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  159. package/server/lib/opencode/network-runtime.js +28 -16
  160. package/server/lib/opencode/network-runtime.test.js +68 -4
  161. package/server/lib/opencode/openchamber-routes.js +7 -1
  162. package/server/lib/opencode/opencode2-pin.js +139 -0
  163. package/server/lib/opencode/opencode2-pin.test.js +88 -0
  164. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +326 -0
  165. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  166. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  167. package/server/lib/opencode/plugin-compatibility.js +15 -0
  168. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  169. package/server/lib/opencode/plugin-routes.js +3 -18
  170. package/server/lib/opencode/plugin-routes.test.js +9 -9
  171. package/server/lib/opencode/plugins.js +4 -3
  172. package/server/lib/opencode/plugins.test.js +12 -0
  173. package/server/lib/opencode/provider-catalog.js +58 -12
  174. package/server/lib/opencode/provider-catalog.test.js +48 -0
  175. package/server/lib/opencode/proxy.js +368 -28
  176. package/server/lib/opencode/proxy.test.js +338 -0
  177. package/server/lib/opencode/routes.js +303 -76
  178. package/server/lib/opencode/routes.test.js +315 -50
  179. package/server/lib/opencode/runtime-contract.js +401 -0
  180. package/server/lib/opencode/runtime-contract.test.js +214 -0
  181. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  182. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  183. package/server/lib/opencode/server-utils-runtime.js +35 -0
  184. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  185. package/server/lib/opencode/shared-service.js +147 -0
  186. package/server/lib/opencode/shared-service.test.js +93 -0
  187. package/server/lib/opencode/shared.js +3 -3
  188. package/server/lib/opencode/shutdown-runtime.js +15 -6
  189. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  190. package/server/lib/opencode/skill-routes.js +26 -40
  191. package/server/lib/opencode/skill-routes.test.js +71 -7
  192. package/server/lib/opencode/skills.test.js +15 -0
  193. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  194. package/server/lib/opencode/static-routes-runtime.js +3 -1
  195. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  196. package/server/lib/opencode/v1-migration-gate.js +157 -0
  197. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  198. package/server/lib/opencode/v2-client.js +60 -0
  199. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  200. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  201. package/server/lib/opencode/watcher.js +2 -1
  202. package/server/lib/opencode/watcher.test.js +1 -1
  203. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  204. package/server/lib/permission-auto-accept/runtime.js +14 -7
  205. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  206. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  207. package/server/lib/scheduled-tasks/DOCUMENTATION.md +79 -39
  208. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  209. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  210. package/server/lib/scheduled-tasks/routes.js +27 -0
  211. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  212. package/server/lib/scheduled-tasks/runtime.js +381 -310
  213. package/server/lib/scheduled-tasks/runtime.test.js +600 -309
  214. package/server/lib/session-goal/DOCUMENTATION.md +173 -22
  215. package/server/lib/session-goal/capability.js +57 -0
  216. package/server/lib/session-goal/capability.test.js +33 -0
  217. package/server/lib/session-goal/routes.js +240 -2
  218. package/server/lib/session-goal/routes.test.js +211 -0
  219. package/server/lib/session-goal/runtime.js +1617 -113
  220. package/server/lib/session-goal/runtime.test.js +1500 -70
  221. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  222. package/server/lib/session-index/event-ingest.js +89 -16
  223. package/server/lib/session-index/event-ingest.test.js +73 -0
  224. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  225. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  226. package/server/lib/session-index/routes.js +63 -10
  227. package/server/lib/session-index/routes.test.js +8 -8
  228. package/server/lib/session-index/service.js +7 -1
  229. package/server/lib/session-index/sync-runtime.js +171 -41
  230. package/server/lib/session-index/sync-runtime.test.js +164 -9
  231. package/server/lib/session-metadata/DOCUMENTATION.md +86 -0
  232. package/server/lib/session-metadata/routes.js +193 -0
  233. package/server/lib/session-metadata/routes.test.js +156 -0
  234. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  235. package/server/lib/session-metadata/session-archive.js +654 -0
  236. package/server/lib/session-metadata/session-archive.test.js +359 -0
  237. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  238. package/server/lib/session-metadata/session-metadata-store.js +375 -0
  239. package/server/lib/session-metadata/session-metadata-store.test.js +379 -0
  240. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  241. package/server/lib/session-metadata/session-projection.js +255 -0
  242. package/server/lib/session-metadata/session-projection.test.js +64 -0
  243. package/server/lib/session-metadata/system-session.js +55 -0
  244. package/server/lib/session-metadata/system-session.test.js +55 -0
  245. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  246. package/server/lib/session-title/native-runtime.test.js +167 -0
  247. package/server/lib/session-title/runtime.js +67 -42
  248. package/server/lib/session-title/session-access.js +46 -0
  249. package/server/lib/session-title/session-access.test.js +91 -0
  250. package/server/lib/session-turn-pages/DOCUMENTATION.md +12 -11
  251. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  252. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  253. package/server/lib/session-turn-pages/routes.js +123 -75
  254. package/server/lib/session-turn-pages/routes.test.js +566 -0
  255. package/server/lib/session-turn-pages/service.js +6 -2
  256. package/server/lib/session-turn-pages/service.test.js +34 -0
  257. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  258. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  259. package/server/lib/small-model/DOCUMENTATION.md +101 -146
  260. package/server/lib/small-model/catalog.js +84 -54
  261. package/server/lib/small-model/catalog.test.js +111 -9
  262. package/server/lib/small-model/custom-api.js +68 -0
  263. package/server/lib/small-model/custom-api.test.js +64 -0
  264. package/server/lib/small-model/index.js +125 -241
  265. package/server/lib/small-model/resolve.js +35 -120
  266. package/server/lib/small-model/resolve.test.js +20 -110
  267. package/server/lib/small-model/routes.js +1 -3
  268. package/server/lib/small-model/summary-settings.test.js +157 -253
  269. package/server/opencode-proxy.test.js +63 -12
  270. package/dist/assets/AssistantView-BUveAgc4.js +0 -3
  271. package/dist/assets/MarkdownRenderer-BbrbDH6-.js +0 -2
  272. package/dist/assets/MobileDetailNavigation-B0aqKXd7.js +0 -1
  273. package/dist/assets/MobileSurface-DW7WfPsy.js +0 -2
  274. package/dist/assets/OverlayScrollbar-DrA0Vsg5.js +0 -2
  275. package/dist/assets/SettingsView-PkXIavr7.js +0 -25
  276. package/dist/assets/appThemeRegistry-D5Yxg4YF.js +0 -1
  277. package/dist/assets/collapsible-D5Phaykh.js +0 -1
  278. package/dist/assets/context-menu-CubSDW2g.js +0 -1
  279. package/dist/assets/es-DA3MqOo0.js +0 -15
  280. package/dist/assets/fr-Dbz0CdIA.js +0 -13
  281. package/dist/assets/gitApi-DXyC4MiV.js +0 -7
  282. package/dist/assets/insertionBoundaries-KlOVrHHt.js +0 -1
  283. package/dist/assets/ja-DumanVQA.js +0 -15
  284. package/dist/assets/ko-C0IkiUjQ.js +0 -15
  285. package/dist/assets/main-BPS5ylib.js +0 -2
  286. package/dist/assets/main-Cpd9VWYs.js +0 -169
  287. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  288. package/dist/assets/miniChat-B8_KLWiY.js +0 -2
  289. package/dist/assets/mobile-CtktKrXn.js +0 -2
  290. package/dist/assets/multirun-CYfP9Cfm.js +0 -4
  291. package/dist/assets/pl-B9No6MQp.js +0 -15
  292. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  293. package/dist/assets/projectMeta-S1hJ7oBp.js +0 -3
  294. package/dist/assets/pt-BR-DyQ0WTDh.js +0 -15
  295. package/dist/assets/renderElectronMiniChatApp-iK14LWm8.js +0 -2
  296. package/dist/assets/renderMobileApp-DORLPWnb.js +0 -28
  297. package/dist/assets/src-rS3zCcsC.css +0 -2
  298. package/dist/assets/store-5B_LDnLM.js +0 -15
  299. package/dist/assets/uk-C_1KkqyP.js +0 -15
  300. package/dist/assets/useDesktopWindowControlsLayout-Dnr-jX0C.js +0 -1
  301. package/dist/assets/useEffectiveDirectory-CZLLyarc.js +0 -1
  302. package/dist/assets/useMobileNavigationStore-CoCiqNgm.js +0 -413
  303. package/dist/assets/useSessionAutoCleanup-xQyETQkG.js +0 -2
  304. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  305. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  306. package/dist/assets/zh-CN-uWILgBdt.js +0 -15
  307. package/dist/assets/zh-TW-MYfdvhbD.js +0 -15
  308. package/server/lib/small-model/call.js +0 -427
  309. package/server/lib/small-model/call.test.js +0 -382
  310. package/server/lib/small-model/opencode-session.js +0 -339
  311. package/server/lib/small-model/opencode-session.test.js +0 -177
@@ -1,347 +1,530 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { detectSessionlessGenerate, generateOpenCodeText, _test } from './generate.js'
3
-
4
- const completedAssistant = (text) => ({
5
- info: {
6
- role: 'assistant',
7
- time: { completed: Date.now() },
2
+ import { generateOpenCodeText, _test } from './generate.js'
3
+
4
+ const denyAllAgent = {
5
+ location: { directory: '/tmp/openchamber-llm' },
6
+ data: {
7
+ id: 'openchamber-llm',
8
+ name: 'openchamber-llm',
9
+ mode: 'primary',
10
+ hidden: true,
11
+ permissions: [
12
+ { action: '*', resource: '*', effect: 'deny' },
13
+ ],
8
14
  },
9
- parts: [{ type: 'text', text }],
10
- })
11
-
12
-
13
- const throwawayGenerate = (client, extra = {}) => generateOpenCodeText({
14
- providerID: 'p',
15
- modelID: 'm',
16
- messages: [{ role: 'user', content: 'work' }],
17
- buildOpenCodeUrl: () => 'http://localhost:1',
18
- getOpenCodeAuthHeaders: () => ({}),
19
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
20
- clientFactory: () => client,
21
- ensureTempDirectory: async () => '/tmp/timeout-fixture',
22
- ...extra,
15
+ }
16
+
17
+ const completedAssistant = (text, extras = {}) => ({
18
+ id: 'msg_asst',
19
+ type: 'assistant',
20
+ time: { created: 1, completed: Date.now() },
21
+ agent: 'openchamber-llm',
22
+ model: { id: 'gpt-5-nano', providerID: 'opencode' },
23
+ content: [{ type: 'text', text }],
24
+ finish: 'stop',
25
+ ...extras,
23
26
  })
24
27
 
25
- const throwawayClient = ({ status, messages, id = 'ses_tmp', remove = vi.fn(async () => ({ data: true })) }) => ({
26
- tool: { ids: async () => ({ data: [] }) },
27
- session: {
28
- create: async () => ({ data: { id } }),
29
- update: async () => ({ data: { id } }),
30
- promptAsync: async () => ({ response: { status: 204 } }),
31
- status,
32
- messages,
33
- delete: remove,
28
+ const jsonResponse = (status, body) => ({
29
+ ok: status >= 200 && status < 300,
30
+ status,
31
+ headers: {
32
+ get: (name) => (String(name).toLowerCase() === 'content-type' ? 'application/json' : null),
34
33
  },
34
+ json: async () => body,
35
+ text: async () => JSON.stringify(body ?? null),
36
+ arrayBuffer: async () => new TextEncoder().encode(JSON.stringify(body ?? null)).buffer,
35
37
  })
36
38
 
37
39
  describe('generate cancellation', () => {
38
- it('cancels throwaway polling and still deletes only its temporary session', async () => {
40
+ it('cancels attachment wait and still removes only its temporary session', async () => {
39
41
  const controller = new AbortController()
40
- const remove = vi.fn(async () => ({ data: true }))
41
- const client = {
42
- tool: { ids: async () => ({ data: [] }) },
43
- session: {
44
- create: async () => ({ data: { id: 'ses_abort_fixture' } }),
45
- update: async () => ({ data: { id: 'ses_abort_fixture' } }),
46
- promptAsync: async () => ({ response: { status: 204 } }),
47
- status: async (_args, { signal }) => { controller.abort(); signal.throwIfAborted() },
48
- delete: remove,
49
- },
50
- }
42
+ const remove = vi.fn(async () => undefined)
43
+ const interrupt = vi.fn(async () => undefined)
44
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
45
+
51
46
  await expect(generateOpenCodeText({
52
- providerID: 'p', modelID: 'm', messages: [{ role: 'user', content: 'work' }],
53
- buildOpenCodeUrl: () => 'http://localhost:1', getOpenCodeAuthHeaders: () => ({}),
54
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
55
- clientFactory: () => client, ensureTempDirectory: async () => '/tmp/abort-fixture',
47
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
48
+ getOpenCodeAuthHeaders: () => ({}),
49
+ providerID: 'opencode',
50
+ modelID: 'gpt-4o',
51
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
52
+ clientFactory: () => ({
53
+ agent: { get: async () => denyAllAgent },
54
+ session: {
55
+ create: async () => ({ id: 'ses_abort_fixture' }),
56
+ prompt: async () => ({ id: 'inbox' }),
57
+ wait: async (_args, { signal }) => {
58
+ controller.abort()
59
+ signal.throwIfAborted()
60
+ },
61
+ interrupt,
62
+ remove,
63
+ instructions: { entry: { put: async () => {} } },
64
+ },
65
+ message: { list: vi.fn() },
66
+ }),
67
+ ensureTempDirectory: async () => '/tmp/abort-fixture',
68
+ forwardImageParts: true,
56
69
  signal: controller.signal,
57
70
  })).rejects.toMatchObject({ name: 'AbortError' })
58
- expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_abort_fixture', directory: '/tmp/abort-fixture' })
71
+ expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_abort_fixture' })
72
+ expect(interrupt).toHaveBeenCalledWith({ sessionID: 'ses_abort_fixture', continue: false })
59
73
  })
60
- it('aborts an in-flight model request when the contact continuation is cancelled', async () => {
74
+
75
+ it('aborts an in-flight session.generate and still removes the throwaway session', async () => {
61
76
  const controller = new AbortController()
62
- const fetchImpl = vi.fn(async (_url, { signal }) => {
77
+ const generate = vi.fn(async (_args, { signal }) => {
63
78
  controller.abort()
64
79
  signal.throwIfAborted()
65
80
  })
81
+ const { client, remove, interrupt } = textSessionClient({ generate })
66
82
  await expect(generateOpenCodeText({
67
- providerID: 'p', modelID: 'm', messages: [{ role: 'user', content: 'work' }],
68
- buildOpenCodeUrl: () => 'http://localhost:1', getOpenCodeAuthHeaders: () => ({}),
69
- detect: async () => ({ available: true, mode: 'http', url: 'http://localhost:1/generate' }),
70
- fetchImpl, signal: controller.signal,
83
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
84
+ getOpenCodeAuthHeaders: () => ({}),
85
+ providerID: 'opencode',
86
+ modelID: 'gpt-5-nano',
87
+ messages: [{ role: 'user', content: 'work' }],
88
+ clientFactory: () => client,
89
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
90
+ signal: controller.signal,
71
91
  })).rejects.toMatchObject({ name: 'AbortError' })
72
- expect(fetchImpl).toHaveBeenCalledTimes(1)
92
+ expect(generate).toHaveBeenCalledTimes(1)
93
+ expect(interrupt).toHaveBeenCalledWith({ sessionID: 'ses_text', continue: false })
94
+ expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_text' })
73
95
  })
74
96
  })
75
97
 
76
- describe('detectSessionlessGenerate', () => {
77
- it('returns unavailable when the probe 404s', async () => {
78
- const fetchImpl = vi.fn(async () => new Response('missing', { status: 404 }))
79
- await expect(detectSessionlessGenerate({
80
- fetchImpl,
81
- baseUrl: 'http://127.0.0.1:4096',
82
- headers: {},
83
- })).resolves.toEqual({ available: false, mode: 'throwaway-session' })
84
- })
98
+ const denyAllTextAgent = {
99
+ location: { directory: '/tmp/openchamber-text' },
100
+ data: {
101
+ id: 'openchamber-text',
102
+ name: 'openchamber-text',
103
+ mode: 'primary',
104
+ hidden: true,
105
+ permissions: [{ action: '*', resource: '*', effect: 'deny' }],
106
+ },
107
+ }
108
+
109
+ function textSessionClient({ generate = vi.fn(async () => ({ text: 'reply' })), agentGet } = {}) {
110
+ const create = vi.fn(async () => ({ id: 'ses_text' }))
111
+ const remove = vi.fn(async () => undefined)
112
+ const interrupt = vi.fn(async () => undefined)
113
+ const prompt = vi.fn()
114
+ const get = agentGet || vi.fn(async () => denyAllTextAgent)
115
+ return {
116
+ client: {
117
+ agent: { get },
118
+ session: { create, generate, remove, interrupt, prompt },
119
+ },
120
+ agentGet: get,
121
+ create,
122
+ generate,
123
+ remove,
124
+ interrupt,
125
+ prompt,
126
+ }
127
+ }
128
+
129
+ describe('generateOpenCodeText — text path', () => {
130
+ it('runs session.generate in a verified deny-all throwaway session and removes it', async () => {
131
+ const { client, agentGet, create, generate, remove, prompt } = textSessionClient()
132
+ const ensureTempDirectory = vi.fn(async ({ agentName, agentMarkdown }) => {
133
+ expect(agentName).toBe(_test.TEXT_AGENT_NAME)
134
+ expect(agentMarkdown).toBe(_test.TEXT_AGENT_MARKDOWN)
135
+ return '/tmp/openchamber-text'
136
+ })
85
137
 
86
- it('returns available when the probe returns JSON', async () => {
87
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
88
- status: 200,
89
- headers: { 'Content-Type': 'application/json' },
90
- }))
91
- await expect(detectSessionlessGenerate({
92
- fetchImpl,
93
- baseUrl: 'http://127.0.0.1:4096',
94
- headers: {},
95
- })).resolves.toMatchObject({ available: true, mode: 'http' })
138
+ const result = await generateOpenCodeText({
139
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
140
+ getOpenCodeAuthHeaders: () => ({}),
141
+ providerID: 'opencode-go',
142
+ modelID: 'deepseek-v4-flash',
143
+ messages: [
144
+ { role: 'system', content: 'Be brief' },
145
+ { role: 'user', content: 'hi' },
146
+ ],
147
+ clientFactory: () => client,
148
+ ensureTempDirectory,
149
+ })
150
+
151
+ expect(result).toEqual({ text: 'reply', source: 'session.generate' })
152
+ expect(agentGet).toHaveBeenCalledWith(expect.objectContaining({
153
+ agentID: 'openchamber-text',
154
+ location: { directory: '/tmp/openchamber-text' },
155
+ }), expect.anything())
156
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({
157
+ agent: 'openchamber-text',
158
+ model: { id: 'deepseek-v4-flash', providerID: 'opencode-go' },
159
+ location: { directory: '/tmp/openchamber-text' },
160
+ }), expect.anything())
161
+ expect(agentGet.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0])
162
+ const generateArgs = generate.mock.calls[0][0]
163
+ expect(generateArgs.sessionID).toBe('ses_text')
164
+ expect(generateArgs.prompt.startsWith('Be brief')).toBe(true)
165
+ expect(generateArgs.prompt).toContain('User: hi')
166
+ expect(prompt).not.toHaveBeenCalled()
167
+ expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_text' })
96
168
  })
97
169
 
98
- it('treats HTML 200 on /generate as unavailable', async () => {
99
- const fetchImpl = vi.fn(async (url) => {
100
- expect(String(url)).toMatch(/\/generate$/)
101
- return new Response('<!doctype html><html><body>OpenChamber</body></html>', {
102
- status: 200,
103
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
104
- })
170
+ it.each(['high', undefined])('passes variant %s on the session model', async (variant) => {
171
+ const { client, create } = textSessionClient()
172
+ const result = await generateOpenCodeText({
173
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
174
+ getOpenCodeAuthHeaders: () => ({}),
175
+ providerID: 'opencode',
176
+ modelID: 'gpt-5-nano',
177
+ messages: [{ role: 'user', content: 'hi' }],
178
+ variant,
179
+ clientFactory: () => client,
180
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
105
181
  })
106
- await expect(detectSessionlessGenerate({
107
- fetchImpl,
108
- baseUrl: 'http://127.0.0.1:4096',
109
- headers: {},
110
- })).resolves.toEqual({ available: false, mode: 'throwaway-session' })
111
- expect(fetchImpl).toHaveBeenCalled()
182
+ expect(result.text).toBe('reply')
183
+ const model = create.mock.calls[0][0].model
184
+ if (variant) {
185
+ expect(model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode', variant })
186
+ } else {
187
+ expect(model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode' })
188
+ }
112
189
  })
113
- })
114
190
 
115
- describe('generateOpenCodeText', () => {
116
- it.each(['high', undefined])('passes variant %s to the upstream prompt and returns only text parts', async (variant) => {
117
- const client = throwawayClient({
118
- status: async () => ({ data: {} }),
119
- messages: async () => ({ data: [{
120
- ...completedAssistant('public reply'),
121
- parts: [{ type: 'reasoning', text: 'private reasoning' }, { type: 'text', text: 'public reply' }],
122
- }] }),
123
- });
124
- client.session.promptAsync = vi.fn(async () => ({ response: { status: 204 } }));
125
- const result = await throwawayGenerate(client, { variant });
126
- expect(client.session.promptAsync.mock.calls[0][0].variant).toBe(variant);
127
- expect(result.text).toBe('public reply');
128
- });
129
-
130
- it('passes variant to sessionless generation', async () => {
131
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'reply' })));
132
- await throwawayGenerate({}, {
133
- variant: 'high', fetchImpl,
134
- detect: async () => ({ available: true, mode: 'http', url: 'http://localhost:1/generate' }),
135
- });
136
- expect(JSON.parse(fetchImpl.mock.calls[0][1].body).variant).toBe('high');
137
- });
138
-
139
- it('uses promptAsync with model+parts, waits for idle messages, and never calls v2 session.prompt', async () => {
140
- const create = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
141
- const update = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
142
- const ids = vi.fn(async () => ({ data: ['bash', 'edit'] }))
143
- const prompt = vi.fn(async () => {
144
- throw new Error('v2 session.prompt must not be used')
191
+ it('blocks before creating a session when the text agent is not deny-all', async () => {
192
+ const { client, create, generate } = textSessionClient({
193
+ agentGet: vi.fn(async () => ({
194
+ data: { id: 'openchamber-text', permissions: [{ action: 'bash', resource: '*', effect: 'allow' }] },
195
+ })),
145
196
  })
146
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
147
- const status = vi.fn(async () => ({ data: { ses_tmp: { type: 'idle' } } }))
148
- const messages = vi.fn(async () => ({ data: [completedAssistant('reply')] }))
149
- const remove = vi.fn(async () => ({ data: true }))
150
- const createOpencodeClient = vi.fn(() => ({
151
- session: { create, update, prompt, promptAsync, status, messages, delete: remove },
152
- tool: { ids },
153
- }))
154
-
155
- const result = await generateOpenCodeText({
197
+ await expect(generateOpenCodeText({
156
198
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
157
199
  getOpenCodeAuthHeaders: () => ({}),
158
200
  providerID: 'opencode',
159
201
  modelID: 'gpt-5-nano',
160
202
  messages: [{ role: 'user', content: 'hi' }],
161
- clientFactory: createOpencodeClient,
162
- ensureTempDirectory: async ({ agentMarkdown }) => {
163
- expect(agentMarkdown).toContain('openchamber-tool JSON fences')
164
- expect(agentMarkdown).toMatch(/"\*"\s*:\s*deny/)
165
- expect(agentMarkdown).toContain('permission:')
166
- expect(agentMarkdown).not.toContain('effect: deny')
167
- expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
168
- expect(agentMarkdown).toContain('Native permissions are denied')
169
- return '/tmp/openchamber-llm'
170
- },
171
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
203
+ clientFactory: () => client,
204
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
205
+ })).rejects.toMatchObject({ code: 'llm_attachment_generation_unavailable' })
206
+ expect(create).not.toHaveBeenCalled()
207
+ expect(generate).not.toHaveBeenCalled()
208
+ })
209
+
210
+ it('waits for a lazily loaded agent instead of failing on the first read', async () => {
211
+ let reads = 0
212
+ const agentGet = vi.fn(async () => {
213
+ reads += 1
214
+ if (reads < 3) throw new Error('Agent not found: openchamber-text')
215
+ return denyAllTextAgent
216
+ })
217
+ const client = { agent: { get: agentGet } }
218
+ const agent = await _test.assertLlmAgentDenyAll({
219
+ client,
220
+ location: { directory: '/tmp/openchamber-text' },
221
+ agentID: 'openchamber-text',
222
+ waitMs: 1_000,
223
+ pollMs: 1,
172
224
  })
225
+ expect(agent.id).toBe('openchamber-text')
226
+ expect(agentGet).toHaveBeenCalledTimes(3)
227
+ })
173
228
 
174
- expect(create).toHaveBeenCalledWith(expect.objectContaining({
175
- title: expect.stringContaining('[openchamber-llm]'),
176
- agent: 'openchamber-llm',
177
- permission: [{ permission: '*', pattern: '*', action: 'deny' }],
178
- metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
179
- }), expect.anything())
180
- expect(update).toHaveBeenCalled()
181
- expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({
182
- sessionID: 'ses_tmp',
183
- agent: 'openchamber-llm',
184
- model: { providerID: 'opencode', modelID: 'gpt-5-nano' },
185
- tools: { bash: false, edit: false },
186
- parts: [{ type: 'text', text: 'User: hi', synthetic: false }],
187
- }), expect.anything())
188
- expect(prompt).not.toHaveBeenCalled()
189
- expect(status).toHaveBeenCalled()
190
- expect(messages).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_tmp' }), expect.anything())
191
- expect(remove).toHaveBeenCalled()
192
- expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
229
+ it('fails when the agent never appears within the wait window', async () => {
230
+ const agentGet = vi.fn(async () => { throw new Error('Agent not found') })
231
+ await expect(_test.assertLlmAgentDenyAll({
232
+ client: { agent: { get: agentGet } },
233
+ agentID: 'openchamber-text',
234
+ waitMs: 0,
235
+ pollMs: 1,
236
+ })).rejects.toMatchObject({ code: 'llm_attachment_generation_unavailable' })
237
+ expect(agentGet).toHaveBeenCalledTimes(1)
193
238
  })
194
239
 
195
- it('forwards contact file parts on promptAsync so the model can see images', async () => {
196
- const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
197
- const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
198
- const flattened = _test.flattenMessages([
199
- { role: 'user', content: 'look', parts: [image, file] },
200
- ])
201
- expect(flattened.prompt).toContain('User: look')
202
- expect(flattened.prompt).toContain('[image: shot.png (image/png)]')
203
- expect(flattened.prompt).toContain('[file: notes.txt (text/plain)]')
204
- expect(flattened.files).toEqual([image, file])
205
-
206
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
207
- const createOpencodeClient = vi.fn(() => ({
208
- session: {
209
- create: async () => ({ data: { id: 'ses_tmp' } }),
210
- update: async () => ({ data: { id: 'ses_tmp' } }),
211
- prompt: vi.fn(),
212
- promptAsync,
213
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
214
- messages: async () => ({ data: [completedAssistant('saw it')] }),
215
- delete: async () => ({ data: true }),
240
+ it('surfaces session.generate errors and still removes the session', async () => {
241
+ const { client, remove } = textSessionClient({
242
+ generate: vi.fn(async () => { throw new Error('Request is missing x-opencode-session') }),
243
+ })
244
+ await expect(generateOpenCodeText({
245
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
246
+ getOpenCodeAuthHeaders: () => ({}),
247
+ providerID: 'opencode-go',
248
+ modelID: 'deepseek-v4-flash',
249
+ messages: [{ role: 'user', content: 'hi' }],
250
+ clientFactory: () => client,
251
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
252
+ })).rejects.toMatchObject({ code: 'upstream_error', message: 'Request is missing x-opencode-session' })
253
+ expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_text' })
254
+ })
255
+
256
+ it('rejects an empty session.generate reply instead of returning blank success', async () => {
257
+ const { client } = textSessionClient({ generate: vi.fn(async () => ({ text: ' ' })) })
258
+ await expect(generateOpenCodeText({
259
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
260
+ getOpenCodeAuthHeaders: () => ({}),
261
+ providerID: 'opencode',
262
+ modelID: 'gpt-5-nano',
263
+ messages: [{ role: 'user', content: 'hi' }],
264
+ clientFactory: () => client,
265
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
266
+ })).rejects.toMatchObject({ message: 'OpenCode session.generate returned no text' })
267
+ })
268
+
269
+ it('does not invent deltas on the session.generate path', async () => {
270
+ const onTextDelta = vi.fn()
271
+ const subscribers = new Set()
272
+ const globalEventHub = {
273
+ subscribeEvent(fn) {
274
+ subscribers.add(fn)
275
+ return () => { subscribers.delete(fn) }
216
276
  },
217
- tool: { ids: async () => ({ data: [] }) },
218
- }))
219
- await generateOpenCodeText({
277
+ }
278
+ const { client } = textSessionClient({ generate: vi.fn(async () => ({ text: 'full reply' })) })
279
+ const result = await generateOpenCodeText({
220
280
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
221
281
  getOpenCodeAuthHeaders: () => ({}),
222
282
  providerID: 'opencode',
223
283
  modelID: 'gpt-5-nano',
224
- messages: [{ role: 'user', content: 'look', parts: [image, file] }],
225
- clientFactory: createOpencodeClient,
226
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
227
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
228
- forwardImageParts: true,
284
+ messages: [{ role: 'user', content: 'hi' }],
285
+ clientFactory: () => client,
286
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
287
+ onTextDelta,
288
+ globalEventHub,
229
289
  })
230
- expect(promptAsync.mock.calls[0][0].parts).toEqual([
231
- expect.objectContaining({ type: 'text', synthetic: false }),
232
- image,
233
- file,
234
- ])
290
+ expect(result).toEqual({ text: 'full reply', source: 'session.generate' })
291
+ expect(onTextDelta).not.toHaveBeenCalled()
292
+ expect(subscribers.size).toBe(0)
235
293
  })
236
294
 
237
- it('skips image bytes for non-vision generate and keeps text files plus the image description', async () => {
295
+ it('keeps image descriptions for non-vision generate and stays on text path', async () => {
238
296
  const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
239
297
  const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
240
298
  expect(_test.filesForPrompt([image, file], false)).toEqual([file])
241
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
242
- const createOpencodeClient = vi.fn(() => ({
243
- session: {
244
- create: async () => ({ data: { id: 'ses_tmp' } }),
245
- update: async () => ({ data: { id: 'ses_tmp' } }),
246
- prompt: vi.fn(),
247
- promptAsync,
248
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
249
- messages: async () => ({ data: [completedAssistant('cannot see images')] }),
250
- delete: async () => ({ data: true }),
251
- },
252
- tool: { ids: async () => ({ data: [] }) },
253
- }))
299
+ expect(_test.imageFilesForSession([image, file], false)).toEqual([])
300
+
301
+ const { client, generate, prompt } = textSessionClient({ generate: vi.fn(async () => ({ text: 'cannot see images' })) })
254
302
  await generateOpenCodeText({
255
303
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
256
304
  getOpenCodeAuthHeaders: () => ({}),
257
305
  providerID: 'opencode',
258
306
  modelID: 'deepseek-v4-flash',
259
307
  messages: [{ role: 'user', content: 'look', parts: [image, file] }],
260
- clientFactory: createOpencodeClient,
261
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
262
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
308
+ clientFactory: () => client,
309
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
310
+ forwardImageParts: false,
263
311
  })
264
- expect(promptAsync.mock.calls[0][0].parts).toEqual([
265
- expect.objectContaining({
266
- type: 'text',
267
- text: expect.stringContaining('[image: shot.png (image/png)]'),
312
+ expect(prompt).not.toHaveBeenCalled()
313
+ expect(generate.mock.calls[0][0].prompt).toContain('[image: shot.png (image/png)]')
314
+ expect(generate.mock.calls[0][0].prompt).toContain('[file: notes.txt (text/plain)]')
315
+ })
316
+ })
317
+
318
+ describe('generateOpenCodeText — attachment session path', () => {
319
+ it('verifies deny-all before prompt, prompts with files, waits, lists, and removes', async () => {
320
+ const agentGet = vi.fn(async () => denyAllAgent)
321
+ const create = vi.fn(async () => ({ id: 'ses_tmp' }))
322
+ const put = vi.fn(async () => undefined)
323
+ const prompt = vi.fn(async () => ({ id: 'inbox_1', type: 'user' }))
324
+ const wait = vi.fn(async () => undefined)
325
+ const list = vi.fn(async () => ({ data: [completedAssistant('saw it')], cursor: {} }))
326
+ const remove = vi.fn(async () => undefined)
327
+ const interrupt = vi.fn(async () => undefined)
328
+ const text = vi.fn()
329
+ const persistSessionMetadata = vi.fn(async () => ({}))
330
+ const onSystemSessionPersisted = vi.fn()
331
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
332
+
333
+ const result = await generateOpenCodeText({
334
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
335
+ getOpenCodeAuthHeaders: () => ({}),
336
+ providerID: 'opencode',
337
+ modelID: 'gpt-4o',
338
+ messages: [
339
+ { role: 'system', content: 'Be careful' },
340
+ { role: 'user', content: 'look', parts: [image] },
341
+ ],
342
+ variant: 'high',
343
+ persistSessionMetadata,
344
+ onSystemSessionPersisted,
345
+ clientFactory: () => ({
346
+ generate: { text },
347
+ agent: { get: agentGet },
348
+ session: {
349
+ create,
350
+ prompt,
351
+ wait,
352
+ interrupt,
353
+ remove,
354
+ instructions: { entry: { put } },
355
+ },
356
+ message: { list },
268
357
  }),
269
- file,
270
- ])
271
- expect(promptAsync.mock.calls[0][0].parts.some((part) => part.type === 'file' && part.mime.startsWith('image/'))).toBe(false)
358
+ ensureTempDirectory: async ({ agentMarkdown }) => {
359
+ expect(agentMarkdown).toContain('openchamber-tool JSON fences')
360
+ expect(agentMarkdown).toMatch(/action:\s*"\*"/)
361
+ expect(agentMarkdown).toMatch(/effect:\s*deny/)
362
+ return '/tmp/openchamber-llm'
363
+ },
364
+ forwardImageParts: true,
365
+ })
366
+
367
+ expect(result).toEqual({ text: 'saw it', source: 'attachment-session' })
368
+ expect(agentGet).toHaveBeenCalledWith(expect.objectContaining({
369
+ agentID: 'openchamber-llm',
370
+ location: { directory: '/tmp/openchamber-llm' },
371
+ }), expect.anything())
372
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({
373
+ agent: 'openchamber-llm',
374
+ model: { id: 'gpt-4o', providerID: 'opencode', variant: 'high' },
375
+ location: { directory: '/tmp/openchamber-llm' },
376
+ metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
377
+ }), expect.anything())
378
+ expect(put).toHaveBeenCalledWith(expect.objectContaining({
379
+ sessionID: 'ses_tmp',
380
+ key: 'system',
381
+ value: 'Be careful',
382
+ }), expect.anything())
383
+ // agent.get must complete before prompt
384
+ expect(agentGet.mock.invocationCallOrder[0]).toBeLessThan(prompt.mock.invocationCallOrder[0])
385
+ expect(prompt).toHaveBeenCalledWith(expect.objectContaining({
386
+ sessionID: 'ses_tmp',
387
+ text: expect.stringContaining('User: look'),
388
+ files: [{ uri: image.url, name: 'shot.png' }],
389
+ delivery: 'steer',
390
+ }), expect.anything())
391
+ expect(wait).toHaveBeenCalledWith({ sessionID: 'ses_tmp' }, expect.anything())
392
+ expect(list).toHaveBeenCalledWith(expect.objectContaining({
393
+ sessionID: 'ses_tmp',
394
+ order: 'desc',
395
+ }), expect.anything())
396
+ expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_tmp' })
397
+ expect(persistSessionMetadata).toHaveBeenCalledWith('ses_tmp', {
398
+ openchamber: { llm: { purpose: 'chat-completions' } },
399
+ })
400
+ expect(onSystemSessionPersisted).toHaveBeenCalledWith({
401
+ sessionID: 'ses_tmp',
402
+ directory: '/tmp/openchamber-llm',
403
+ metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
404
+ })
405
+ expect(text).not.toHaveBeenCalled()
272
406
  })
273
407
 
274
- it('surfaces the OpenCode assistant error string after promptAsync 204', async () => {
275
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
408
+ it('fails with llm_attachment_generation_unavailable before prompt when deny-all is missing', async () => {
276
409
  const prompt = vi.fn()
277
- const createOpencodeClient = vi.fn(() => ({
278
- session: {
279
- create: async () => ({ data: { id: 'ses_tmp' } }),
280
- update: async () => ({ data: { id: 'ses_tmp' } }),
281
- prompt,
282
- promptAsync,
283
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
284
- messages: async () => ({
285
- data: [{
286
- info: { role: 'assistant', error: { message: 'model refused the request' }, time: { completed: Date.now() } },
287
- parts: [],
288
- }],
289
- }),
290
- delete: async () => ({ data: true }),
410
+ const remove = vi.fn()
411
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
412
+
413
+ await expect(generateOpenCodeText({
414
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
415
+ getOpenCodeAuthHeaders: () => ({}),
416
+ providerID: 'opencode',
417
+ modelID: 'gpt-4o',
418
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
419
+ clientFactory: () => ({
420
+ agent: {
421
+ get: async () => ({
422
+ data: {
423
+ id: 'openchamber-llm',
424
+ permissions: [
425
+ { action: 'read', resource: 'file', effect: 'allow' },
426
+ ],
427
+ },
428
+ }),
429
+ },
430
+ session: {
431
+ create: async () => ({ id: 'ses_tmp' }),
432
+ prompt,
433
+ remove,
434
+ },
435
+ }),
436
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
437
+ forwardImageParts: true,
438
+ })).rejects.toMatchObject({
439
+ code: 'llm_attachment_generation_unavailable',
440
+ })
441
+ expect(prompt).not.toHaveBeenCalled()
442
+ })
443
+
444
+ it('removes the session when wait fails and still cleans hub listeners', async () => {
445
+ const subscribers = new Set()
446
+ const globalEventHub = {
447
+ subscribeEvent(fn) {
448
+ subscribers.add(fn)
449
+ return () => { subscribers.delete(fn) }
291
450
  },
292
- tool: { ids: async () => ({ data: [] }) },
293
- }))
451
+ }
452
+ const remove = vi.fn(async () => undefined)
453
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
294
454
 
295
455
  await expect(generateOpenCodeText({
296
456
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
297
457
  getOpenCodeAuthHeaders: () => ({}),
298
458
  providerID: 'opencode',
299
- modelID: 'gpt-5-nano',
300
- messages: [{ role: 'user', content: 'hi' }],
301
- clientFactory: createOpencodeClient,
459
+ modelID: 'gpt-4o',
460
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
461
+ clientFactory: () => ({
462
+ agent: { get: async () => denyAllAgent },
463
+ session: {
464
+ create: async () => ({ id: 'ses_tmp' }),
465
+ prompt: async () => ({ id: 'inbox' }),
466
+ wait: async () => {
467
+ throw new Error('wait boom')
468
+ },
469
+ remove,
470
+ interrupt: vi.fn(),
471
+ instructions: { entry: { put: async () => {} } },
472
+ },
473
+ message: { list: vi.fn() },
474
+ }),
302
475
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
303
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
476
+ forwardImageParts: true,
477
+ onTextDelta: vi.fn(),
478
+ globalEventHub,
304
479
  })).rejects.toMatchObject({
305
480
  code: 'upstream_error',
306
- message: 'model refused the request',
481
+ message: 'wait boom',
307
482
  })
308
- expect(prompt).not.toHaveBeenCalled()
483
+ expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_tmp' })
484
+ expect(subscribers.size).toBe(0)
309
485
  })
310
486
 
311
- it('falls through to promptAsync when the /generate probe returns HTML 200', async () => {
312
- const fetchImpl = vi.fn(async () => new Response('<!doctype html>', {
313
- status: 200,
314
- headers: { 'Content-Type': 'text/html' },
315
- }))
316
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
317
- const prompt = vi.fn()
318
- const result = await generateOpenCodeText({
487
+ it('surfaces assistant error from message.list and cleans up', async () => {
488
+ const remove = vi.fn(async () => undefined)
489
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
490
+
491
+ await expect(generateOpenCodeText({
319
492
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
320
493
  getOpenCodeAuthHeaders: () => ({}),
321
494
  providerID: 'opencode',
322
- modelID: 'glm-5.3-flash',
323
- messages: [{ role: 'user', content: 'hi' }],
324
- fetchImpl,
495
+ modelID: 'gpt-4o',
496
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
325
497
  clientFactory: () => ({
498
+ agent: { get: async () => denyAllAgent },
326
499
  session: {
327
- create: async () => ({ data: { id: 'ses_tmp' } }),
328
- update: async () => ({ data: { id: 'ses_tmp' } }),
329
- prompt,
330
- promptAsync,
331
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
332
- messages: async () => ({ data: [completedAssistant('reply')] }),
333
- delete: async () => ({ data: true }),
500
+ create: async () => ({ id: 'ses_tmp' }),
501
+ prompt: async () => ({ id: 'inbox' }),
502
+ wait: async () => undefined,
503
+ remove,
504
+ interrupt: vi.fn(),
505
+ instructions: { entry: { put: async () => {} } },
506
+ },
507
+ message: {
508
+ list: async () => ({
509
+ data: [completedAssistant('', {
510
+ finish: 'error',
511
+ error: { message: 'model refused the request' },
512
+ content: [],
513
+ })],
514
+ cursor: {},
515
+ }),
334
516
  },
335
- tool: { ids: async () => ({ data: [] }) },
336
517
  }),
337
518
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
519
+ forwardImageParts: true,
520
+ })).rejects.toMatchObject({
521
+ code: 'upstream_error',
522
+ message: 'model refused the request',
338
523
  })
339
- expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
340
- expect(promptAsync).toHaveBeenCalled()
341
- expect(prompt).not.toHaveBeenCalled()
524
+ expect(remove).toHaveBeenCalled()
342
525
  })
343
526
 
344
- it('forwards filtered throwaway session text deltas via onTextDelta and still returns full text', async () => {
527
+ it('forwards filtered session.text.delta tokens via onTextDelta and still returns full text', async () => {
345
528
  const subscribers = new Set()
346
529
  const globalEventHub = {
347
530
  subscribeEvent(fn) {
@@ -354,112 +537,106 @@ describe('generateOpenCodeText', () => {
354
537
  }
355
538
  const deltas = []
356
539
  const onTextDelta = vi.fn((text) => { deltas.push(text) })
540
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
357
541
 
358
- const promptAsync = vi.fn(async () => {
359
- // Same-session text deltas
542
+ const prompt = vi.fn(async () => {
360
543
  globalEventHub.emit({
361
544
  payload: {
362
- type: 'message.part.delta',
363
- properties: {
545
+ type: 'session.text.delta',
546
+ data: {
364
547
  sessionID: 'ses_tmp',
365
- messageID: 'msg_asst',
366
- partID: 'prt_1',
367
- field: 'text',
548
+ assistantMessageID: 'msg_asst',
549
+ ordinal: 0,
368
550
  delta: 'Hel',
369
551
  },
370
552
  },
371
553
  })
372
554
  globalEventHub.emit({
373
555
  payload: {
374
- type: 'message.part.delta',
375
- properties: {
556
+ type: 'session.text.delta',
557
+ data: {
376
558
  sessionID: 'ses_tmp',
377
- messageID: 'msg_asst',
378
- partID: 'prt_1',
379
- field: 'text',
559
+ assistantMessageID: 'msg_asst',
560
+ ordinal: 1,
380
561
  delta: 'lo',
381
562
  },
382
563
  },
383
564
  })
384
- // Other session — must be ignored
565
+ // Other session — ignored
385
566
  globalEventHub.emit({
386
567
  payload: {
387
- type: 'message.part.delta',
388
- properties: {
568
+ type: 'session.text.delta',
569
+ data: {
389
570
  sessionID: 'ses_other',
390
- messageID: 'msg_other',
391
- partID: 'prt_x',
392
- field: 'text',
571
+ assistantMessageID: 'msg_other',
572
+ ordinal: 0,
393
573
  delta: 'NOPE',
394
574
  },
395
575
  },
396
576
  })
397
- // Same session, different messageID after lock — ignored
577
+ // Same session, different assistantMessageID after lock — ignored
398
578
  globalEventHub.emit({
399
579
  payload: {
400
- type: 'message.part.delta',
401
- properties: {
580
+ type: 'session.text.delta',
581
+ data: {
402
582
  sessionID: 'ses_tmp',
403
- messageID: 'msg_other_asst',
404
- partID: 'prt_2',
405
- field: 'text',
583
+ assistantMessageID: 'msg_other_asst',
584
+ ordinal: 2,
406
585
  delta: 'SKIP',
407
586
  },
408
587
  },
409
588
  })
410
- // Non-text field — ignored
589
+ // Stale ordinal — ignored
411
590
  globalEventHub.emit({
412
591
  payload: {
413
- type: 'message.part.delta',
414
- properties: {
592
+ type: 'session.text.delta',
593
+ data: {
415
594
  sessionID: 'ses_tmp',
416
- messageID: 'msg_asst',
417
- partID: 'prt_1',
418
- field: 'reasoning',
419
- delta: 'think',
595
+ assistantMessageID: 'msg_asst',
596
+ ordinal: 1,
597
+ delta: 'DUP',
420
598
  },
421
599
  },
422
600
  })
423
- return { response: { status: 204 } }
601
+ return { id: 'inbox' }
424
602
  })
425
603
 
426
604
  const result = await generateOpenCodeText({
427
605
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
428
606
  getOpenCodeAuthHeaders: () => ({}),
429
607
  providerID: 'opencode',
430
- modelID: 'gpt-5-nano',
431
- messages: [{ role: 'user', content: 'hi' }],
608
+ modelID: 'gpt-4o',
609
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
432
610
  clientFactory: () => ({
611
+ agent: { get: async () => denyAllAgent },
433
612
  session: {
434
- create: async () => ({ data: { id: 'ses_tmp' } }),
435
- update: async () => ({ data: { id: 'ses_tmp' } }),
436
- prompt: vi.fn(),
437
- promptAsync,
438
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
439
- messages: async () => ({ data: [completedAssistant('Hello')] }),
440
- delete: async () => ({ data: true }),
613
+ create: async () => ({ id: 'ses_tmp' }),
614
+ prompt,
615
+ wait: async () => undefined,
616
+ remove: async () => undefined,
617
+ interrupt: vi.fn(),
618
+ instructions: { entry: { put: async () => {} } },
619
+ },
620
+ message: {
621
+ list: async () => ({ data: [completedAssistant('Hello')], cursor: {} }),
441
622
  },
442
- tool: { ids: async () => ({ data: [] }) },
443
623
  }),
444
624
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
445
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
625
+ forwardImageParts: true,
446
626
  onTextDelta,
447
627
  globalEventHub,
448
628
  })
449
629
 
450
- expect(result).toEqual({ text: 'Hello', source: 'throwaway-session' })
630
+ expect(result).toEqual({ text: 'Hello', source: 'attachment-session' })
451
631
  expect(deltas).toEqual(['Hel', 'lo'])
452
- expect(onTextDelta).toHaveBeenCalledTimes(2)
453
- // Listener removed after complete — further emits must not reach onTextDelta
454
632
  expect(subscribers.size).toBe(0)
455
633
  globalEventHub.emit({
456
634
  payload: {
457
- type: 'message.part.delta',
458
- properties: {
635
+ type: 'session.text.delta',
636
+ data: {
459
637
  sessionID: 'ses_tmp',
460
- messageID: 'msg_asst',
461
- partID: 'prt_1',
462
- field: 'text',
638
+ assistantMessageID: 'msg_asst',
639
+ ordinal: 3,
463
640
  delta: 'late',
464
641
  },
465
642
  },
@@ -467,211 +644,255 @@ describe('generateOpenCodeText', () => {
467
644
  expect(onTextDelta).toHaveBeenCalledTimes(2)
468
645
  })
469
646
 
470
- it('removes the hub listener when generate fails after subscribe', async () => {
471
- const subscribers = new Set()
472
- const globalEventHub = {
473
- subscribeEvent(fn) {
474
- subscribers.add(fn)
475
- return () => { subscribers.delete(fn) }
476
- },
477
- }
478
- const onTextDelta = vi.fn()
479
-
480
- await expect(generateOpenCodeText({
481
- buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
482
- getOpenCodeAuthHeaders: () => ({}),
483
- providerID: 'opencode',
484
- modelID: 'gpt-5-nano',
485
- messages: [{ role: 'user', content: 'hi' }],
486
- clientFactory: () => ({
487
- session: {
488
- create: async () => ({ data: { id: 'ses_tmp' } }),
489
- update: async () => ({ data: { id: 'ses_tmp' } }),
490
- prompt: vi.fn(),
491
- promptAsync: async () => ({ response: { status: 204 } }),
492
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
493
- messages: async () => ({
494
- data: [{
495
- info: { role: 'assistant', error: { message: 'boom' }, time: { completed: Date.now() } },
496
- parts: [],
497
- }],
498
- }),
499
- delete: async () => ({ data: true }),
500
- },
501
- tool: { ids: async () => ({ data: [] }) },
502
- }),
503
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
504
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
505
- onTextDelta,
506
- globalEventHub,
507
- })).rejects.toMatchObject({ code: 'upstream_error', message: 'boom' })
508
-
509
- expect(subscribers.size).toBe(0)
510
- })
511
-
512
- it('throws when tool.ids returns an SDK error instead of denying nothing', async () => {
513
- const remove = vi.fn(async () => ({ data: true }))
514
- await expect(generateOpenCodeText({
515
- buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
516
- getOpenCodeAuthHeaders: () => ({}),
517
- providerID: 'opencode',
518
- modelID: 'gpt-5-nano',
519
- messages: [{ role: 'user', content: 'hi' }],
520
- clientFactory: () => ({
521
- session: {
522
- create: vi.fn(async () => ({ data: { id: 'ses_tmp' } })),
523
- update: vi.fn(),
524
- prompt: vi.fn(),
525
- promptAsync: vi.fn(),
526
- status: vi.fn(),
527
- messages: vi.fn(),
528
- delete: remove,
529
- },
530
- tool: { ids: async () => ({ error: { status: 500, message: 'tool catalog unavailable' } }) },
531
- }),
532
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
533
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
534
- })).rejects.toMatchObject({
535
- code: 'upstream_error',
536
- message: expect.stringContaining('tool.ids failed'),
537
- })
538
- expect(remove).not.toHaveBeenCalled()
539
- })
540
-
541
- it('throws when session.messages returns a deterministic 400', async () => {
542
- const remove = vi.fn(async () => ({ data: true }))
543
- await expect(generateOpenCodeText({
544
- buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
545
- getOpenCodeAuthHeaders: () => ({}),
546
- providerID: 'opencode',
547
- modelID: 'gpt-5-nano',
548
- messages: [{ role: 'user', content: 'hi' }],
549
- clientFactory: () => ({
550
- session: {
551
- create: async () => ({ data: { id: 'ses_tmp' } }),
552
- update: async () => ({ data: { id: 'ses_tmp' } }),
553
- prompt: vi.fn(),
554
- promptAsync: async () => ({ response: { status: 204 } }),
555
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
556
- messages: async () => ({ error: { status: 400, message: 'OutputFormatJsonSchema' } }),
557
- delete: remove,
558
- },
559
- tool: { ids: async () => ({ data: ['bash'] }) },
560
- }),
561
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
562
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
563
- })).rejects.toMatchObject({
564
- code: 'upstream_error',
565
- message: expect.stringMatching(/session\.messages failed.*400|OutputFormatJsonSchema/),
566
- })
567
- expect(remove).toHaveBeenCalled()
568
- })
569
-
570
- it('logs SDK delete errors without erasing a successful generate result', async () => {
647
+ it('logs remove errors without erasing a successful generate result', async () => {
571
648
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
649
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
572
650
  try {
573
651
  const result = await generateOpenCodeText({
574
652
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
575
653
  getOpenCodeAuthHeaders: () => ({}),
576
654
  providerID: 'opencode',
577
- modelID: 'gpt-5-nano',
578
- messages: [{ role: 'user', content: 'hi' }],
655
+ modelID: 'gpt-4o',
656
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
579
657
  clientFactory: () => ({
658
+ agent: { get: async () => denyAllAgent },
580
659
  session: {
581
- create: async () => ({ data: { id: 'ses_tmp' } }),
582
- update: async () => ({ data: { id: 'ses_tmp' } }),
583
- prompt: vi.fn(),
584
- promptAsync: async () => ({ response: { status: 204 } }),
585
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
586
- messages: async () => ({ data: [completedAssistant('ok')] }),
587
- delete: async () => ({ error: { status: 500, message: 'delete denied' } }),
660
+ create: async () => ({ id: 'ses_tmp' }),
661
+ prompt: async () => ({ id: 'inbox' }),
662
+ wait: async () => undefined,
663
+ remove: async () => {
664
+ throw new Error('delete denied')
665
+ },
666
+ interrupt: vi.fn(),
667
+ instructions: { entry: { put: async () => {} } },
668
+ },
669
+ message: {
670
+ list: async () => ({ data: [completedAssistant('ok')], cursor: {} }),
588
671
  },
589
- tool: { ids: async () => ({ data: [] }) },
590
672
  }),
591
673
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
592
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
674
+ forwardImageParts: true,
593
675
  })
594
- expect(result).toEqual({ text: 'ok', source: 'throwaway-session' })
676
+ expect(result).toEqual({ text: 'ok', source: 'attachment-session' })
595
677
  expect(warn).toHaveBeenCalledWith(
596
- expect.stringContaining('[llm] failed to delete throwaway OpenCode session:'),
678
+ expect.stringContaining('[llm] failed to remove throwaway OpenCode session:'),
597
679
  expect.stringContaining('delete denied'),
598
680
  )
599
681
  } finally {
600
682
  warn.mockRestore()
601
683
  }
602
684
  })
685
+ })
603
686
 
604
- it('does not invent deltas on the sessionless /generate JSON path', async () => {
605
- const onTextDelta = vi.fn()
606
- const subscribers = new Set()
607
- const globalEventHub = {
608
- subscribeEvent(fn) {
609
- subscribers.add(fn)
610
- return () => { subscribers.delete(fn) }
611
- },
612
- }
613
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'full reply' }), {
614
- status: 200,
615
- headers: { 'Content-Type': 'application/json' },
616
- }))
687
+ describe('subscribeThrowawayTextDeltas', () => {
688
+ it('returns null without a callback or hub', () => {
689
+ expect(_test.subscribeThrowawayTextDeltas({
690
+ sessionID: 'ses_1',
691
+ onTextDelta: null,
692
+ globalEventHub: { subscribeEvent: () => () => {} },
693
+ })).toBeNull()
694
+ expect(_test.subscribeThrowawayTextDeltas({
695
+ sessionID: 'ses_1',
696
+ onTextDelta: () => {},
697
+ globalEventHub: null,
698
+ })).toBeNull()
699
+ })
700
+ })
701
+
702
+ describe('data URL validation', () => {
703
+ it('rejects oversized attachments', () => {
704
+ const huge = Buffer.alloc(_test.MAX_ATTACHMENT_BYTES + 1, 1).toString('base64')
705
+ expect(() => _test.parseDataUrl(`data:image/png;base64,${huge}`)).toThrow(/limit/)
706
+ })
707
+
708
+ it('accepts small valid data URLs', () => {
709
+ const parsed = _test.parseDataUrl('data:image/png;base64,aa')
710
+ expect(parsed?.mime).toBe('image/png')
711
+ expect(parsed?.byteLength).toBeGreaterThan(0)
712
+ })
713
+ })
714
+
715
+ describe('generateOpenCodeText — real OpenCode.make + fake HTTP', () => {
716
+ it('session.generate text completion through the real client', async () => {
717
+ const { OpenCode } = await import('@opencode/client')
718
+ const calls = []
719
+ const emptyOk = { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
720
+ const fetchImpl = vi.fn(async (url, init) => {
721
+ const path = String(url)
722
+ const method = init?.method || 'GET'
723
+ calls.push(`${method} ${path.replace(/^https?:\/\/[^/]+/, '').split('?')[0]}`)
724
+ if (path.includes('/api/agent/openchamber-text')) {
725
+ return jsonResponse(200, {
726
+ location: { directory: '/tmp/openchamber-text', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
727
+ data: { ...denyAllTextAgent.data, request: {} },
728
+ })
729
+ }
730
+ if (path.endsWith('/api/session') && method === 'POST') {
731
+ return jsonResponse(200, {
732
+ data: {
733
+ id: 'ses_text',
734
+ projectID: 'p',
735
+ cost: 0,
736
+ tokens: {},
737
+ time: { created: 1, updated: 1 },
738
+ location: { directory: '/tmp/openchamber-text' },
739
+ },
740
+ })
741
+ }
742
+ if (path.includes('/api/session/ses_text/generate') && method === 'POST') {
743
+ const body = JSON.parse(init.body)
744
+ expect(body.prompt).toContain('User: hi')
745
+ return jsonResponse(200, { data: { text: 'hello from generate' } })
746
+ }
747
+ if (path.includes('/api/session/ses_text') && method === 'DELETE') return emptyOk
748
+ return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${method} ${path}` })
749
+ })
617
750
 
618
751
  const result = await generateOpenCodeText({
619
752
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
620
- getOpenCodeAuthHeaders: () => ({}),
753
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
621
754
  providerID: 'opencode',
622
755
  modelID: 'gpt-5-nano',
623
756
  messages: [{ role: 'user', content: 'hi' }],
624
- fetchImpl,
625
- detect: async () => ({ available: true, mode: 'http', url: 'http://127.0.0.1:4096/generate' }),
626
- onTextDelta,
627
- globalEventHub,
757
+ clientFactory: () => OpenCode.make({
758
+ baseUrl: 'http://127.0.0.1:4096',
759
+ headers: { Authorization: 'Basic test' },
760
+ fetch: fetchImpl,
761
+ }),
762
+ ensureTempDirectory: async () => '/tmp/openchamber-text',
628
763
  })
629
764
 
630
- expect(result).toEqual({ text: 'full reply', source: 'generate' })
631
- expect(onTextDelta).not.toHaveBeenCalled()
632
- expect(subscribers.size).toBe(0)
765
+ expect(result).toEqual({ text: 'hello from generate', source: 'session.generate' })
766
+ expect(calls.some((c) => c.startsWith('DELETE ') && c.includes('/api/session/ses_text'))).toBe(true)
633
767
  })
634
- })
635
768
 
636
- describe('generate stall timeout', () => {
637
- it('keeps waiting while the throwaway session stays busy past the stall deadline', async () => {
638
- let polls = 0
639
- const result = await throwawayGenerate(throwawayClient({
640
- status: async () => {
641
- polls += 1
642
- return { data: { ses_tmp: { type: polls < 12 ? 'busy' : 'idle' } } }
643
- },
644
- messages: async () => ({ data: [completedAssistant('late reply')] }),
645
- }), {
646
- timeoutMs: 50,
647
- settlePollMs: 10,
769
+ it('attachment deny-all failure happens before prompt on the real client path shape', async () => {
770
+ const { OpenCode } = await import('@opencode/client')
771
+ let prompted = false
772
+ const fetchImpl = vi.fn(async (url, init) => {
773
+ const path = String(url)
774
+ if (path.includes('/api/agent/openchamber-llm')) {
775
+ return jsonResponse(200, {
776
+ location: { directory: '/tmp/openchamber-llm', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
777
+ data: {
778
+ id: 'openchamber-llm',
779
+ name: 'openchamber-llm',
780
+ mode: 'primary',
781
+ hidden: true,
782
+ permissions: [{ action: 'bash', resource: '*', effect: 'allow' }],
783
+ request: {},
784
+ },
785
+ })
786
+ }
787
+ if (path.includes('/prompt')) {
788
+ prompted = true
789
+ return jsonResponse(200, { data: { id: 'inbox' } })
790
+ }
791
+ if (path.endsWith('/api/session') && init?.method === 'POST') {
792
+ return jsonResponse(200, {
793
+ data: {
794
+ id: 'ses_tmp',
795
+ projectID: 'p',
796
+ cost: 0,
797
+ tokens: {},
798
+ time: { created: 1, updated: 1 },
799
+ location: { directory: '/tmp/openchamber-llm' },
800
+ },
801
+ })
802
+ }
803
+ return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${path}` })
648
804
  })
649
- expect(polls).toBeGreaterThan(5)
650
- expect(result).toEqual({ text: 'late reply', source: 'throwaway-session' })
651
- })
652
805
 
653
- it('times out when throwaway settle makes no progress', async () => {
654
- await expect(throwawayGenerate(throwawayClient({
655
- status: async () => ({ data: {} }),
656
- messages: async () => ({ data: [] }),
657
- }), {
658
- timeoutMs: 40,
659
- settlePollMs: 10,
660
- })).rejects.toThrow(/timed out after 40ms without progress/)
806
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
807
+ await expect(generateOpenCodeText({
808
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
809
+ getOpenCodeAuthHeaders: () => ({}),
810
+ providerID: 'opencode',
811
+ modelID: 'gpt-4o',
812
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
813
+ clientFactory: () => OpenCode.make({
814
+ baseUrl: 'http://127.0.0.1:4096',
815
+ fetch: fetchImpl,
816
+ }),
817
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
818
+ forwardImageParts: true,
819
+ })).rejects.toMatchObject({ code: 'llm_attachment_generation_unavailable' })
820
+ expect(prompted).toBe(false)
661
821
  })
662
- })
663
822
 
664
- describe('subscribeThrowawayTextDeltas', () => {
665
- it('returns null without a callback or hub', () => {
666
- expect(_test.subscribeThrowawayTextDeltas({
667
- sessionID: 'ses_1',
668
- onTextDelta: null,
669
- globalEventHub: { subscribeEvent: () => () => {} },
670
- })).toBeNull()
671
- expect(_test.subscribeThrowawayTextDeltas({
672
- sessionID: 'ses_1',
673
- onTextDelta: () => {},
674
- globalEventHub: null,
675
- })).toBeNull()
823
+ it('attachment success path removes the session after message.list', async () => {
824
+ const { OpenCode } = await import('@opencode/client')
825
+ const calls = []
826
+ const fetchImpl = vi.fn(async (url, init) => {
827
+ const path = String(url)
828
+ const method = init?.method || 'GET'
829
+ calls.push(`${method} ${path.replace(/^https?:\/\/[^/]+/, '')}`)
830
+ if (path.includes('/api/agent/openchamber-llm')) {
831
+ return jsonResponse(200, {
832
+ location: { directory: '/tmp/openchamber-llm', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
833
+ data: {
834
+ id: 'openchamber-llm',
835
+ name: 'openchamber-llm',
836
+ mode: 'primary',
837
+ hidden: true,
838
+ permissions: [{ action: '*', resource: '*', effect: 'deny' }],
839
+ request: {},
840
+ },
841
+ })
842
+ }
843
+ if (path.endsWith('/api/session') && method === 'POST') {
844
+ return jsonResponse(200, {
845
+ data: {
846
+ id: 'ses_tmp',
847
+ projectID: 'p',
848
+ cost: 0,
849
+ tokens: {},
850
+ time: { created: 1, updated: 1 },
851
+ location: { directory: '/tmp/openchamber-llm' },
852
+ },
853
+ })
854
+ }
855
+ if (path.includes('/instructions/entries/')) {
856
+ return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
857
+ }
858
+ if (path.includes('/prompt')) {
859
+ return jsonResponse(200, { data: { id: 'inbox_1', type: 'user', text: 'look' } })
860
+ }
861
+ if (path.includes('/wait')) {
862
+ return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
863
+ }
864
+ if (path.includes('/message') && method === 'GET') {
865
+ return jsonResponse(200, {
866
+ data: [completedAssistant('vision ok')],
867
+ cursor: {},
868
+ })
869
+ }
870
+ if (path.includes('/api/session/ses_tmp') && method === 'DELETE') {
871
+ return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
872
+ }
873
+ return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${method} ${path}` })
874
+ })
875
+
876
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
877
+ const result = await generateOpenCodeText({
878
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
879
+ getOpenCodeAuthHeaders: () => ({}),
880
+ providerID: 'opencode',
881
+ modelID: 'gpt-4o',
882
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
883
+ clientFactory: () => OpenCode.make({
884
+ baseUrl: 'http://127.0.0.1:4096',
885
+ fetch: fetchImpl,
886
+ }),
887
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
888
+ forwardImageParts: true,
889
+ })
890
+
891
+ expect(result).toEqual({ text: 'vision ok', source: 'attachment-session' })
892
+ expect(calls.some((c) => c.startsWith('DELETE ') && c.includes('/api/session/ses_tmp'))).toBe(true)
893
+ const agentIdx = calls.findIndex((c) => c.includes('/api/agent/openchamber-llm'))
894
+ const promptIdx = calls.findIndex((c) => c.includes('/prompt'))
895
+ expect(agentIdx).toBeGreaterThanOrEqual(0)
896
+ expect(promptIdx).toBeGreaterThan(agentIdx)
676
897
  })
677
898
  })