@openchambery/web 2.0.0-beta.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-DGyF86jy.js} +1 -1
  3. package/dist/assets/AssistantView-ByGdaGiF.js +3 -0
  4. package/dist/assets/{DiagramView-oeXj4YHn.js → DiagramView-PB0pBILb.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-B_a083XJ.js} +1 -1
  6. package/dist/assets/Icon-BE2hkaYc.js +1 -0
  7. package/dist/assets/MarkdownRenderer-H1MIHXzt.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-CahUctxC.js → MarkdownRendererImpl-CZ8GeckU.js} +3 -3
  9. package/dist/assets/{MarkstreamRendererImpl-5JUaTSdl.js → MarkstreamRendererImpl-BKE6YZ2N.js} +1 -1
  10. package/dist/assets/MobileDetailNavigation-D9KkIrWy.js +1 -0
  11. package/dist/assets/{MobileShareBridge-yCN95dYg.js → MobileShareBridge-1yCjq-60.js} +3 -3
  12. package/dist/assets/MobileSurface-Dqn3OPVX.js +2 -0
  13. package/dist/assets/{MultiRunWindow-C0HqMFpp.js → MultiRunWindow-YCFM6or1.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-CTmsNR65.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-CRfGtcmG.js → OverlayScrollbar-CVgW_Z9B.js} +1 -1
  16. package/dist/assets/SettingsView-CbiuXtYG.js +25 -0
  17. package/dist/assets/{SettingsWindow-B_d5Aqq3.js → SettingsWindow-jOZ7L6K8.js} +1 -1
  18. package/dist/assets/{TerminalView-DdNQQb8W.js → TerminalView-CI-KsDdH.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-4DC5OE1J.js → ToolOutputDialog-izZjgkkg.js} +1 -1
  20. package/dist/assets/appThemeRegistry-MiWlVk4e.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DIIrsMjW.js → button-CcOoDLE1.js} +1 -1
  23. package/dist/assets/{collapsible-CnvUeYst.js → collapsible-CSa0Ndgm.js} +1 -1
  24. package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-DoTwmMb5.js} +1 -1
  25. package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-BsbquPqS.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-yyvjztgj.js +1 -0
  27. package/dist/assets/{diagram-rvn7_qBM.js → diagram-CZKDkADZ.js} +1 -1
  28. package/dist/assets/dialog-Csvrld51.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
  31. package/dist/assets/fileMentionTouchSelection-Bn0f83D3.js +2 -0
  32. package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-B0hhXD86.js} +1 -1
  33. package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
  34. package/dist/assets/{gitApi-da8etTTe.js → gitApi-DpwH7xTg.js} +4 -4
  35. package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-BVu-iykl.js → input-C6Y7mytw.js} +1 -1
  37. package/dist/assets/insertionBoundaries-Cst5Z6Cd.js +1 -0
  38. package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
  39. package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
  40. package/dist/assets/{main-KwlAomA8.js → main-CEs8ipuu.js} +2 -2
  41. package/dist/assets/main-Dkzuy7EG.js +169 -0
  42. package/dist/assets/main-qSeeJRW1.css +1 -0
  43. package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-DhLhFBWX.js} +1 -1
  44. package/dist/assets/{mcpQueries-3GTWeMU-.js → mcpQueries-BBpCJgvK.js} +1 -1
  45. package/dist/assets/miniChat-CR7k42bZ.js +2 -0
  46. package/dist/assets/mobile-rvypjUpd.js +2 -0
  47. package/dist/assets/multirun-DqmJk0mh.js +3 -0
  48. package/dist/assets/persistence-CwHY3fDg.js +3 -0
  49. package/dist/assets/pl-B714sdql.js +15 -0
  50. package/dist/assets/platform-Bxaalnc8.js +6 -0
  51. package/dist/assets/projectMeta-BhKfv5cj.js +1 -0
  52. package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
  53. package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-BLay6f9j.js +2 -0
  55. package/dist/assets/renderMobileApp-BrikirqO.js +28 -0
  56. package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-DjKsteH0.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
  60. package/dist/assets/runtimeEndpointReset-DbfaE3dL.js +148 -0
  61. package/dist/assets/sessionChangeSummary-Cgq9RPUq.js +2 -0
  62. package/dist/assets/{sessionLookup-0Z4e-J1g.js → sessionLookup-BiE36BTy.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-CE7W7lJ9.js → sortable-tabs-strip-CKKp4p3Y.js} +1 -1
  64. package/dist/assets/src-BZ0tISrf.css +2 -0
  65. package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
  66. package/dist/assets/{terminalApi-mDR2jXTo.js → terminalApi-DSJOXNRQ.js} +1 -1
  67. package/dist/assets/{textarea-XD2oX_Ee.js → textarea-DUjc3iDJ.js} +1 -1
  68. package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-CBHR50fA.js} +1 -1
  69. package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
  70. package/dist/assets/{url-CGbJbiJq.js → url-CEnO45_n.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-y_GTVXhJ.js → useAppFontEffects-CWzjhjXr.js} +55 -55
  72. package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-CXj8c_tt.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-Dtqco-Um.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-BW9jNyuK.js → useEffectiveDirectory-DNbDaaNV.js} +1 -1
  75. package/dist/assets/{useFontPreferences-Bjx8zkGM.js → useFontPreferences-DGbTdGy3.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-DYKC2swj.js +415 -0
  77. package/dist/assets/useSessionAutoCleanup-BI0xQcio.js +2 -0
  78. package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-DNkuudYO.js} +1 -1
  79. package/dist/assets/{useWorkerHighlightedLines-BUcSLKlL.js → useWorkerHighlightedLines-BoyYALWC.js} +1 -1
  80. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  81. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  82. package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
  83. package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
  84. package/dist/index.html +13 -13
  85. package/dist/mini-chat.html +13 -13
  86. package/dist/mobile.html +13 -13
  87. package/package.json +3 -3
  88. package/server/index.js +8 -20
  89. package/server/lib/client-auth/remote-clients.js +15 -0
  90. package/server/lib/client-auth/remote-clients.test.js +42 -1
  91. package/server/lib/git/DOCUMENTATION.md +1 -0
  92. package/server/lib/git/routes.js +7 -4
  93. package/server/lib/git/routes.test.js +34 -0
  94. package/server/lib/git/service.js +3 -1
  95. package/server/lib/llm/DOCUMENTATION.md +6 -4
  96. package/server/lib/llm/generate.js +8 -9
  97. package/server/lib/llm/generate.test.js +33 -6
  98. package/server/lib/message-queue/service.js +1 -1
  99. package/server/lib/opencode/DOCUMENTATION.md +33 -22
  100. package/server/lib/opencode/agents.js +13 -22
  101. package/server/lib/opencode/auth-state-runtime.js +3 -3
  102. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  103. package/server/lib/opencode/auth.d.ts +13 -0
  104. package/server/lib/opencode/auth.js +74 -64
  105. package/server/lib/opencode/auth.test.js +57 -0
  106. package/server/lib/opencode/commands.js +1 -1
  107. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  108. package/server/lib/opencode/config-entity-routes.js +10 -123
  109. package/server/lib/opencode/config-entity-routes.test.js +23 -11
  110. package/server/lib/opencode/config-files.d.ts +17 -0
  111. package/server/lib/opencode/config-files.js +187 -0
  112. package/server/lib/opencode/config-files.test.js +201 -0
  113. package/server/lib/opencode/core-routes.js +17 -1
  114. package/server/lib/opencode/core-routes.test.js +43 -15
  115. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  116. package/server/lib/opencode/credential-boundary.js +11 -0
  117. package/server/lib/opencode/ensure-cli.js +4 -1
  118. package/server/lib/opencode/env-config.js +4 -1
  119. package/server/lib/opencode/env-config.test.js +28 -0
  120. package/server/lib/opencode/env-runtime.test.js +18 -1
  121. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  122. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  123. package/server/lib/opencode/lifecycle.js +1 -49
  124. package/server/lib/opencode/lifecycle.test.js +30 -4
  125. package/server/lib/opencode/location-release.d.ts +8 -0
  126. package/server/lib/opencode/location-release.js +32 -0
  127. package/server/lib/opencode/location-release.test.js +43 -0
  128. package/server/lib/opencode/mcp.js +1 -1
  129. package/server/lib/opencode/opencode2-pin.js +2 -1
  130. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  131. package/server/lib/opencode/plugin-routes.test.js +4 -2
  132. package/server/lib/opencode/plugins.js +12 -60
  133. package/server/lib/opencode/plugins.test.js +5 -3
  134. package/server/lib/opencode/providers.js +6 -3
  135. package/server/lib/opencode/proxy.js +7 -0
  136. package/server/lib/opencode/proxy.test.js +13 -0
  137. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  138. package/server/lib/opencode/routes.js +10 -8
  139. package/server/lib/opencode/routes.test.js +51 -10
  140. package/server/lib/opencode/runtime-contract.js +3 -2
  141. package/server/lib/opencode/runtime-contract.test.js +12 -12
  142. package/server/lib/opencode/settings-normalization-runtime.js +3 -1
  143. package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
  144. package/server/lib/opencode/settings-runtime.test.js +58 -1
  145. package/server/lib/opencode/shared.js +6 -175
  146. package/server/lib/opencode/update-discovery.js +39 -0
  147. package/server/lib/opencode/update-discovery.test.js +39 -0
  148. package/server/lib/opencode/upgrade-screen.js +4 -11
  149. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  150. package/server/lib/opencode/version-alignment.test.js +40 -0
  151. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  152. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  153. package/server/lib/question-auto-delegate/core.js +21 -13
  154. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  155. package/server/lib/question-auto-delegate/form-io.js +39 -0
  156. package/server/lib/question-auto-delegate/runtime.js +4 -24
  157. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  158. package/server/lib/quota/DOCUMENTATION.md +9 -0
  159. package/server/lib/quota/provider-credentials.test.js +29 -0
  160. package/server/lib/quota/providers/claude.js +4 -4
  161. package/server/lib/quota/providers/codex.js +4 -4
  162. package/server/lib/quota/providers/codex.test.js +1 -1
  163. package/server/lib/quota/providers/copilot.js +5 -5
  164. package/server/lib/quota/providers/google/auth.js +3 -3
  165. package/server/lib/quota/providers/google/index.js +2 -2
  166. package/server/lib/quota/providers/index.js +3 -7
  167. package/server/lib/quota/providers/index.test.js +2 -2
  168. package/server/lib/quota/providers/kimi.js +4 -4
  169. package/server/lib/quota/providers/minimax-shared.js +4 -4
  170. package/server/lib/quota/providers/nanogpt.js +4 -4
  171. package/server/lib/quota/providers/openai.js +4 -4
  172. package/server/lib/quota/providers/openrouter.js +4 -4
  173. package/server/lib/quota/providers/wafer.js +4 -4
  174. package/server/lib/quota/providers/zai.js +4 -4
  175. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  176. package/server/lib/quota/routes.js +1 -1
  177. package/server/lib/relay/DOCUMENTATION.md +3 -1
  178. package/server/lib/relay/service.js +1 -0
  179. package/server/lib/relay/service.test.js +13 -0
  180. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  181. package/server/lib/scheduled-tasks/runtime.js +12 -4
  182. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  183. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  184. package/server/lib/session-goal/runtime.js +4 -4
  185. package/server/lib/session-goal/runtime.test.js +32 -3
  186. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  187. package/server/lib/session-index/event-ingest.js +13 -0
  188. package/server/lib/session-index/event-ingest.test.js +7 -0
  189. package/server/lib/session-index/service.js +45 -0
  190. package/server/lib/session-index/service.test.js +47 -0
  191. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  192. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  193. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  194. package/server/lib/session-metadata/session-projection.js +23 -0
  195. package/server/lib/session-metadata/session-projection.test.js +35 -0
  196. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  197. package/server/lib/session-title/native-runtime.test.js +59 -5
  198. package/server/lib/session-title/runtime.js +63 -17
  199. package/server/lib/session-title/session-access.js +108 -3
  200. package/server/lib/session-title/session-access.test.js +125 -7
  201. package/server/lib/tts/DOCUMENTATION.md +7 -5
  202. package/server/lib/tts/credential-source.test.js +23 -0
  203. package/server/lib/tts/routes.js +2 -2
  204. package/server/lib/tts/service.js +16 -37
  205. package/dist/assets/AssistantView-CLFPj5Ki.js +0 -3
  206. package/dist/assets/Icon-B8fSTLdP.js +0 -1
  207. package/dist/assets/MarkdownRenderer-BK69MEHQ.js +0 -2
  208. package/dist/assets/MobileDetailNavigation-agQ-Yhpd.js +0 -1
  209. package/dist/assets/MobileSurface-D0HP_lTS.js +0 -2
  210. package/dist/assets/SettingsView-xLYb22wV.js +0 -25
  211. package/dist/assets/appThemeRegistry-LkVH62uF.js +0 -1
  212. package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
  213. package/dist/assets/dialog-CQ63sc9E.js +0 -1
  214. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  215. package/dist/assets/fileMentionTouchSelection-CBzDuQxP.js +0 -3
  216. package/dist/assets/insertionBoundaries-DQrE5qG2.js +0 -1
  217. package/dist/assets/main-D2BzpnUt.css +0 -1
  218. package/dist/assets/main-DYISNvWY.js +0 -169
  219. package/dist/assets/miniChat-DYIqdWi7.js +0 -2
  220. package/dist/assets/mobile-CwiCcmWt.js +0 -2
  221. package/dist/assets/multirun-C3snjTFs.js +0 -4
  222. package/dist/assets/persistence-D0y7226-.js +0 -3
  223. package/dist/assets/pl-BCf5uLlK.js +0 -15
  224. package/dist/assets/platform-CurqO28D.js +0 -6
  225. package/dist/assets/projectMeta-BkhYFMaM.js +0 -3
  226. package/dist/assets/renderElectronMiniChatApp-7iyh_419.js +0 -2
  227. package/dist/assets/renderMobileApp-y0_NeckR.js +0 -28
  228. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  229. package/dist/assets/runtimeConfig-12C5j02f.js +0 -2
  230. package/dist/assets/runtimeEndpointReset-DTY2ZXXb.js +0 -149
  231. package/dist/assets/sessionChangeSummary-CwPBIb3Z.js +0 -2
  232. package/dist/assets/src-lS1keDVV.css +0 -2
  233. package/dist/assets/useDesktopWindowControlsLayout-CR_DcY77.js +0 -1
  234. package/dist/assets/useMobileNavigationStore-DwupvLSA.js +0 -414
  235. package/dist/assets/useSessionAutoCleanup-DkGO_usj.js +0 -2
  236. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  237. package/server/lib/opencode/snippets.js +0 -242
  238. package/server/lib/opencode/snippets.test.js +0 -97
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Startup upgrade screen for OpenCode below 2.0.15.
2
+ * Startup upgrade screen for OpenCode below the runtime contract minimum.
3
3
  *
4
4
  * This module does not own process lifecycle. It classifies the version the
5
5
  * existing startup path already detected, and installs through the owned-cache
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {
11
11
  PINNED_OPENCODE2_VERSION,
12
+ MINIMUM_OPENCODE2_VERSION,
12
13
  isAcceptableOpenCode2HealthVersion,
13
14
  isOpenCode1xVersion,
14
15
  isOpenCode2VersionAtLeast,
@@ -16,7 +17,7 @@ import {
16
17
  } from './opencode2-pin.js';
17
18
 
18
19
  /** Oldest OpenCode this OpenChamber build will enter a session against. */
19
- export const REQUIRED_OPENCODE_VERSION = '2.0.15';
20
+ export const REQUIRED_OPENCODE_VERSION = MINIMUM_OPENCODE2_VERSION;
20
21
 
21
22
  const normalizeVersion = (value) => {
22
23
  if (typeof value !== 'string') return null;
@@ -68,14 +69,6 @@ export function platformSupportsUpgradeScreenInstall(platform = process.platform
68
69
  }
69
70
  }
70
71
 
71
- const pickDetectedVersion = (serveVersion, cliVersion) => {
72
- const serve = normalizeVersion(serveVersion);
73
- const cli = normalizeVersion(cliVersion);
74
- if (serve && !isUpgradeScreenSatisfied(serve)) return serve;
75
- if (serve) return serve;
76
- return cli;
77
- };
78
-
79
72
  /**
80
73
  * @param {{
81
74
  * version?: string | null,
@@ -156,7 +149,7 @@ async function readUpgradeScreenStatus(deps) {
156
149
  ? await Promise.resolve(deps.readCliVersion()).catch(() => null)
157
150
  : null;
158
151
  return describeUpgradeScreen({
159
- version: pickDetectedVersion(serveVersion, cliVersion),
152
+ version: serveVersion || (typeof deps.readServeVersion === 'function' ? null : cliVersion),
160
153
  installation,
161
154
  platformCanInstall: typeof deps.platformCanInstall === 'boolean'
162
155
  ? deps.platformCanInstall
@@ -11,26 +11,26 @@ import {
11
11
  } from './upgrade-screen.js';
12
12
 
13
13
  describe('upgrade screen version gate', () => {
14
- it('uses 2.0.15 even when the runtime pin is older', () => {
15
- expect(REQUIRED_OPENCODE_VERSION).toBe('2.0.15');
16
- expect(resolveUpgradeScreenTarget('2.0.12')).toBe('2.0.15');
17
- expect(resolveUpgradeScreenTarget('2.0.16')).toBe('2.0.16');
14
+ it('uses the floor even when the runtime pin is older', () => {
15
+ expect(REQUIRED_OPENCODE_VERSION).toBe('2.0.20');
16
+ expect(resolveUpgradeScreenTarget('2.0.12')).toBe('2.0.20');
17
+ expect(resolveUpgradeScreenTarget('2.0.23')).toBe('2.0.23');
18
18
  });
19
19
 
20
- it('does not open the gate at or above 2.0.15', () => {
20
+ it('does not open the gate at or above 2.0.20', () => {
21
21
  expect(describeUpgradeScreen({
22
- version: '2.0.15',
22
+ version: '2.0.20',
23
23
  installation: 'managed',
24
24
  platformCanInstall: true,
25
- })).toMatchObject({ state: 'compatible', canInstall: false, minimumVersion: '2.0.15' });
25
+ })).toMatchObject({ state: 'compatible', canInstall: false, minimumVersion: '2.0.20' });
26
26
  expect(describeUpgradeScreen({
27
- version: 'v2.0.16',
27
+ version: 'v2.0.23',
28
28
  installation: 'managed',
29
29
  platformCanInstall: true,
30
30
  }).state).toBe('compatible');
31
31
  });
32
32
 
33
- it('opens the gate below 2.0.15 and offers install only when this runtime can switch', () => {
33
+ it('opens the gate below 2.0.20 and offers install only when this runtime can switch', () => {
34
34
  expect(describeUpgradeScreen({
35
35
  version: '2.0.14',
36
36
  installation: 'managed',
@@ -68,17 +68,17 @@ describe('upgrade screen version gate', () => {
68
68
 
69
69
  it('does not report an unverified install as upgraded', () => {
70
70
  expect(evaluateUpgradeScreenInstallResult({
71
- targetVersion: '2.0.15',
71
+ targetVersion: '2.0.23',
72
72
  serveVersion: '2.0.14',
73
73
  })).toMatchObject({ ok: false, upgraded: false, version: '2.0.14' });
74
74
  expect(evaluateUpgradeScreenInstallResult({
75
- targetVersion: '2.0.15',
75
+ targetVersion: '2.0.23',
76
76
  serveVersion: null,
77
77
  }).upgraded).toBe(false);
78
78
  expect(evaluateUpgradeScreenInstallResult({
79
- targetVersion: '2.0.15',
80
- serveVersion: '2.0.15',
81
- })).toMatchObject({ ok: true, upgraded: true, version: '2.0.15' });
79
+ targetVersion: '2.0.23',
80
+ serveVersion: '2.0.23',
81
+ })).toMatchObject({ ok: true, upgraded: true, version: '2.0.23' });
82
82
  });
83
83
  });
84
84
 
@@ -94,7 +94,7 @@ describe('installRequiredOpenCode', () => {
94
94
  calls.push('install');
95
95
  return '/cache/opencode';
96
96
  }),
97
- readBinaryVersion: vi.fn(() => '2.0.15'),
97
+ readBinaryVersion: vi.fn(() => '2.0.23'),
98
98
  persistBinary: vi.fn(async () => {
99
99
  calls.push('persist');
100
100
  }),
@@ -114,20 +114,20 @@ describe('installRequiredOpenCode', () => {
114
114
  return { deps, calls };
115
115
  };
116
116
 
117
- it('installs 2.0.15 and only succeeds after the running serve is verified', async () => {
117
+ it('installs 2.0.23 and only succeeds after the running serve is verified', async () => {
118
118
  const { deps, calls } = createDeps({
119
119
  readServeVersion: vi.fn()
120
120
  .mockResolvedValueOnce('2.0.14')
121
- .mockResolvedValueOnce('2.0.15'),
121
+ .mockResolvedValueOnce('2.0.23'),
122
122
  });
123
123
 
124
124
  await expect(installRequiredOpenCode(deps)).resolves.toMatchObject({
125
125
  ok: true,
126
126
  upgraded: true,
127
- version: '2.0.15',
128
- targetVersion: '2.0.15',
127
+ version: '2.0.23',
128
+ targetVersion: '2.0.23',
129
129
  });
130
- expect(deps.install).toHaveBeenCalledWith({ version: '2.0.15' });
130
+ expect(deps.install).toHaveBeenCalledWith({ version: '2.0.23' });
131
131
  expect(calls).toEqual(['install', 'persist', 'force', 'restart', 'ready']);
132
132
  });
133
133
 
@@ -137,7 +137,7 @@ describe('installRequiredOpenCode', () => {
137
137
  .mockResolvedValueOnce('1.18.30')
138
138
  .mockResolvedValueOnce('1.18.30'),
139
139
  readCliVersion: () => '1.18.30',
140
- readBinaryVersion: () => '2.0.15',
140
+ readBinaryVersion: () => '2.0.23',
141
141
  });
142
142
 
143
143
  await expect(installRequiredOpenCode(deps)).rejects.toThrow(/still 1\.18\.30/);
@@ -161,11 +161,11 @@ describe('installRequiredOpenCode', () => {
161
161
  });
162
162
 
163
163
  describe('upgrade screen routes', () => {
164
- it('hides the gate for 2.0.15 and reports install failure without upgraded:true', async () => {
164
+ it('hides the gate for 2.0.20 and reports install failure without upgraded:true', async () => {
165
165
  const app = express();
166
166
  app.use(express.json());
167
167
  const readServeVersion = vi.fn()
168
- .mockResolvedValueOnce('2.0.15')
168
+ .mockResolvedValueOnce('2.0.20')
169
169
  .mockResolvedValueOnce('2.0.12')
170
170
  .mockResolvedValueOnce('2.0.12');
171
171
  registerUpgradeScreenRoutes(app, {
@@ -176,14 +176,14 @@ describe('upgrade screen routes', () => {
176
176
  install: vi.fn(async () => {
177
177
  throw new Error('registry unreachable');
178
178
  }),
179
- readBinaryVersion: () => '2.0.15',
179
+ readBinaryVersion: () => '2.0.23',
180
180
  persistBinary: vi.fn(),
181
181
  forceBinary: vi.fn(),
182
182
  restart: vi.fn(),
183
183
  });
184
184
 
185
185
  const compatible = await request(app).get('/api/opencode/compatibility').expect(200);
186
- expect(compatible.body).toMatchObject({ state: 'compatible', version: '2.0.15', minimumVersion: '2.0.15' });
186
+ expect(compatible.body).toMatchObject({ state: 'compatible', version: '2.0.20', minimumVersion: '2.0.20' });
187
187
 
188
188
  const failed = await request(app).post('/api/opencode/install-required').expect(500);
189
189
  expect(failed.body).toMatchObject({
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { MINIMUM_OPENCODE2_VERSION, PINNED_OPENCODE2_VERSION } from './opencode2-pin.js';
3
+ import { evaluateRuntimeContract } from './runtime-contract.js';
4
+ import { describeUpgradeScreen, installRequiredOpenCode, resolveUpgradeScreenTarget } from './upgrade-screen.js';
5
+ import { ensurePinnedOpenCode2Cli } from './ensure-cli.js';
6
+
7
+ describe('v2 version alignment', () => {
8
+ it.each(['1.18.30', '2.0.19', null, 'unknown'])('blocks execution and startup for %s', (version) => {
9
+ expect(evaluateRuntimeContract({ serveVersion: version, cliVersion: '2.0.23', reachable: true, authenticated: true }).executionAllowed).toBe(false);
10
+ expect(describeUpgradeScreen({ version }).state).not.toBe('compatible');
11
+ });
12
+ it.each(['2.0.20', '2.0.23', '2.1.0'])('admits running %s independently of CLI pin', (version) => {
13
+ expect(evaluateRuntimeContract({ serveVersion: version, cliVersion: '2.0.15', reachable: true, authenticated: true })).toMatchObject({ executionAllowed: true, versionMismatch: true, minVerifiedVersion: '2.0.20' });
14
+ expect(describeUpgradeScreen({ version }).state).toBe('compatible');
15
+ });
16
+ it('installs the audited pin on first install and preserves newer discovered CLIs', async () => {
17
+ expect(MINIMUM_OPENCODE2_VERSION).toBe('2.0.20');
18
+ expect(PINNED_OPENCODE2_VERSION).toBe('2.0.23');
19
+ expect(resolveUpgradeScreenTarget()).toBe('2.0.23');
20
+ const install = vi.fn(async () => '/owned/opencode');
21
+ await expect(ensurePinnedOpenCode2Cli({ readVersion: (path) => path === '/owned/opencode' ? '2.0.23' : '', install })).resolves.toMatchObject({ version: '2.0.23', installed: true });
22
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ version: '2.0.23' }));
23
+ install.mockClear();
24
+ await expect(ensurePinnedOpenCode2Cli({ discoveredPath: '/global/opencode', readVersion: () => '2.1.0', install })).resolves.toMatchObject({ version: '2.1.0', installed: false });
25
+ expect(install).not.toHaveBeenCalled();
26
+ });
27
+ it('does not infer a successful installation from an unreadable binary', async () => {
28
+ await expect(ensurePinnedOpenCode2Cli({ readVersion: () => '', install: async () => '/owned/opencode' })).rejects.toThrow('got unknown');
29
+ });
30
+ it('verifies the live service after explicit install, and rolls selection back on failure', async () => {
31
+ const persistBinary = vi.fn();
32
+ const restart = vi.fn();
33
+ const deps = { resolveOwnership: async () => ({ ownership: 'shared-service' }), readServeVersion: vi.fn().mockResolvedValueOnce('2.0.19').mockResolvedValueOnce('2.0.23'), readCliVersion: () => '2.0.23', platformCanInstall: true, install: async () => '/owned/opencode', readBinaryVersion: () => '2.0.23', persistBinary, restart, isSharedService: () => true };
34
+ await expect(installRequiredOpenCode(deps)).resolves.toMatchObject({ upgraded: true, version: '2.0.23' });
35
+ expect(restart).toHaveBeenCalledWith({ binaryPath: '/owned/opencode' });
36
+ deps.readServeVersion.mockResolvedValue('2.0.19');
37
+ await expect(installRequiredOpenCode(deps)).rejects.toMatchObject({ code: 'UPGRADE_SCREEN_NOT_VERIFIED' });
38
+ expect(persistBinary).toHaveBeenLastCalledWith('');
39
+ });
40
+ });
@@ -2,12 +2,15 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Server-owned automatic handling of OpenCode `question.asked` prompts. When
5
+ Server-owned automatic handling of OpenCode 2 question-tool forms (`form.created`,
6
+ `data.form.metadata.kind === "question"`). When
6
7
  enabled (default), pending questions are answered after 30 seconds with a fixed
7
8
  continuation text so agents (including background/subagent sessions) can proceed
8
9
  without a connected UI. Manual reply/reject still use the official OpenCode
9
- SDK shape; the Host only adds a single-flight claim so timer, manual, and
10
- delegate paths cannot double-POST upstream.
10
+ form schema. The Host adds a single-flight claim for timer, delegate, and
11
+ compatibility question routes. Native manual form replies settle via
12
+ `form.replied` / `form.cancelled`; upstream form settlement arbitrates any race
13
+ with a native reply already in flight.
11
14
 
12
15
  Permission auto-accept is intentionally out of scope — this module never
13
16
  replies to permission requests.
@@ -42,6 +45,7 @@ Auto-submit requires **settings ready + enabled + authoritative directory**.
42
45
  |---|---|
43
46
  | `core.js` / `core.d.ts` | Platform-agnostic state machine (IO/timer injected). Shared contract for VS Code. |
44
47
  | `runtime.js` | Web adapter: OpenCode upstream fetch, global event hub, settings, SSE tips, project + session-index directories. |
48
+ | `form-io.js` / `form-io.d.ts` | Shared Web/VS Code native form list, schema lookup, answer mapping and cancellation. |
45
49
  | `routes.js` | OpenChamber routes + precise `/api/question/:id/reply\|reject` intercepts. |
46
50
 
47
51
  ## Snapshot
@@ -62,21 +66,31 @@ Tip event (not a data payload):
62
66
  | `POST` | `.../requests/:id/pause` | `{ sessionID, directory, reason: 'interaction'\|'user' }` — bound identity conflicts → 409. Directory may be empty; pause still cancels the auto timer. |
63
67
  | `POST` | `.../requests/:id/delegate` | recovers identity via scoped list; allowed when toggle off |
64
68
  | `POST` | `/api/question/:id/reply` | manual: `answers` must be an array (incl. `[]`); never auto text |
65
- | `POST` | `/api/question/:id/reject` | SDK body preserved |
69
+ | `POST` | `/api/question/:id/reject` | Compatibility route mapped to native form cancellation |
66
70
 
67
71
  Concurrent claim losers: HTTP **409** + `code: "question_submission_claimed"`.
68
72
 
69
73
  ## Claim / upstream rules
70
74
 
75
+ OpenCode 2.0.15 upstream uses `GET /api/form?location[directory]=...` for
76
+ pending forms and `GET /api/session/:sessionID/form/:formID` before submitting
77
+ `POST .../reply` with `{ answer }`. Only question-tool forms are eligible.
78
+ The fresh schema owns field keys, option values and string/multiselect shape.
79
+ Failed schema reads are `notSent` and pause for retry. Cancellation uses
80
+ `POST .../cancel`. Session directory recovery reads `info.location.directory`;
81
+ live events read `payload.location.directory` and `payload.data`.
82
+ Web, Electron and mobile hosts use the Web adapter; VS Code imports the same
83
+ form IO and core, restoring `/api` for origin-only upstream URLs.
84
+
71
85
  1. One authority owns at most one upstream POST per `requestID` (sync claim).
72
86
  2. **Definitive reject** (400/409/422) or pre-send throw → **release claim**, pause for human retry. **No immediate auto retry.**
73
87
  3. **Unclear post-dispatch result** (timeout/5xx/`uncertain`) → `uncertain` tombstone; no retry; still **blocks** goal.
74
88
  4. **Settled** must not be downgraded by a late POST failure.
75
89
  5. Successful **scoped** empty list may converge `uncertain`/pending → `external` settled **only for that directory**.
76
- 6. Manual reply forwards `answers` as-is (including `[]`). Fixed text is auto/delegate only.
90
+ 6. Compatibility manual reply passes `answers` (including `[]`) to the form adapter, which maps them by authoritative fields. Fixed text is auto/delegate only.
77
91
  7. Client `sessionID`/`directory` hints never override a bound identity (conflict → 409).
78
92
  8. Auto-submit requires an authoritative directory binding (from event, session cache, or `getSession` write-back). Missing directory → no timer.
79
- 9. Unknown pause/delegate recovers via scoped `question.list` — never invents `questionCount=1`.
93
+ 9. Unknown pause/delegate recovers via scoped `form.list`, filtered to question-tool forms — never invents `questionCount=1`.
80
94
 
81
95
  ## Reconcile
82
96
 
@@ -119,6 +133,7 @@ Concurrent claim losers: HTTP **409** + `code: "question_submission_claimed"`.
119
133
  ```sh
120
134
  bunx vitest run --project @openchamber/web \
121
135
  packages/web/server/lib/question-auto-delegate/core.test.js \
136
+ packages/web/server/lib/question-auto-delegate/runtime.test.js \
122
137
  packages/web/server/lib/question-auto-delegate/routes.test.js \
123
138
  packages/web/server/lib/session-goal/runtime.test.js
124
139
  ```
@@ -114,6 +114,8 @@ export interface QuestionAutoDelegatePendingQuestion {
114
114
  sessionID: string;
115
115
  directory?: string | null;
116
116
  questions?: unknown[] | null;
117
+ fields?: unknown[];
118
+ metadata?: { kind?: unknown };
117
119
  }
118
120
 
119
121
  export interface QuestionAutoDelegateSessionInfo {
@@ -167,11 +169,13 @@ export interface QuestionAutoDelegateIO {
167
169
  requestID: string,
168
170
  directory: string | undefined,
169
171
  answers: string[][],
172
+ sessionID: string,
170
173
  ): Promise<QuestionAutoDelegateUpstreamResult>;
171
174
  postReject(
172
175
  requestID: string,
173
176
  directory: string | undefined,
174
- body?: unknown,
177
+ body: unknown,
178
+ sessionID: string,
175
179
  ): Promise<QuestionAutoDelegateUpstreamResult>;
176
180
  /** Project directories used for reconnect/start reconcile. */
177
181
  listDirectories(): Promise<string[]>;
@@ -25,7 +25,8 @@ const normalizeQuestion = (raw, directoryHint = '') => {
25
25
  const sessionID = asTrimmedString(raw.sessionID);
26
26
  if (!requestID || !sessionID) return null;
27
27
  const directory = asTrimmedString(raw.directory) || asTrimmedString(directoryHint);
28
- const questions = Array.isArray(raw.questions) ? raw.questions : [];
28
+ if (Array.isArray(raw.fields) && raw.metadata?.kind !== 'question') return null;
29
+ const questions = Array.isArray(raw.fields) ? raw.fields : Array.isArray(raw.questions) ? raw.questions : [];
29
30
  return {
30
31
  requestID,
31
32
  sessionID,
@@ -608,18 +609,18 @@ export function createQuestionAutoDelegateCore({
608
609
  try {
609
610
  if (kind === 'reject') {
610
611
  entry.upstreamDispatched = true;
611
- upstream = await io.postReject(requestID, directory, input.body);
612
+ upstream = await io.postReject(requestID, directory, input.body, entry.sessionID);
612
613
  } else if (authority === 'manual') {
613
614
  entry.upstreamDispatched = true;
614
615
  // Forward the caller payload exactly (answers may be empty).
615
- upstream = await io.postReply(requestID, directory, input.answers);
616
+ upstream = await io.postReply(requestID, directory, input.answers, entry.sessionID);
616
617
  } else {
617
618
  const count = entry.questionCount > 0 ? entry.questionCount : 1;
618
619
  const answers = Array.isArray(input.answers) && input.answers.length > 0
619
620
  ? input.answers
620
621
  : buildAutoDelegateAnswers(count, autoAnswer);
621
622
  entry.upstreamDispatched = true;
622
- upstream = await io.postReply(requestID, directory, answers);
623
+ upstream = await io.postReply(requestID, directory, answers, entry.sessionID);
623
624
  }
624
625
  } catch (error) {
625
626
  // Throw before a classified upstream result: treat as pre-send failure → release for retry.
@@ -1157,12 +1158,18 @@ export function createQuestionAutoDelegateCore({
1157
1158
  const processEvent = (payload, directoryHint = '') => {
1158
1159
  if (disposed || !payload || typeof payload !== 'object') return;
1159
1160
  const type = typeof payload.type === 'string' ? payload.type : '';
1160
- const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
1161
- const hint = asTrimmedString(directoryHint);
1161
+ const properties = payload.data && typeof payload.data === 'object'
1162
+ ? payload.data
1163
+ : payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
1164
+ const hint = asTrimmedString(payload.location?.directory) || asTrimmedString(directoryHint);
1162
1165
 
1163
1166
  if (type === 'session.created' || type === 'session.updated') {
1164
- rememberSession(properties.info, hint);
1165
- const info = properties.info;
1167
+ const info = properties.info ?? {
1168
+ id: properties.sessionID,
1169
+ parentID: properties.parentID,
1170
+ directory: properties.location?.directory,
1171
+ };
1172
+ rememberSession(info, hint);
1166
1173
  const sessionId = asTrimmedString(info?.id);
1167
1174
  const directory = asTrimmedString(info?.directory) || hint;
1168
1175
  if (sessionId && directory) {
@@ -1176,8 +1183,9 @@ export function createQuestionAutoDelegateCore({
1176
1183
  return;
1177
1184
  }
1178
1185
 
1179
- if (type === 'question.asked') {
1180
- const normalized = normalizeQuestion(properties, hint);
1186
+ if (type === 'question.asked' || type === 'form.created') {
1187
+ if (type === 'form.created' && properties.form?.metadata?.kind !== 'question') return;
1188
+ const normalized = normalizeQuestion(type === 'form.created' ? properties.form : properties, hint);
1181
1189
  if (!normalized) return;
1182
1190
  rememberSession({ id: normalized.sessionID, directory: normalized.directory }, normalized.directory || hint);
1183
1191
  if (!normalized.directory) {
@@ -1191,13 +1199,13 @@ export function createQuestionAutoDelegateCore({
1191
1199
  return;
1192
1200
  }
1193
1201
 
1194
- if (type === 'question.replied' || type === 'question.rejected') {
1195
- const requestID = asTrimmedString(properties.id) || asTrimmedString(properties.requestID);
1202
+ if (type === 'question.replied' || type === 'question.rejected' || type === 'form.replied' || type === 'form.cancelled') {
1203
+ const requestID = asTrimmedString(properties.formID) || asTrimmedString(properties.id) || asTrimmedString(properties.requestID);
1196
1204
  if (!requestID) return;
1197
1205
  const entry = requests.get(requestID);
1198
1206
  if (!entry) return;
1199
1207
  if (entry.state === TERMINAL_SETTLED) return;
1200
- const resolution = type === 'question.rejected' ? 'rejected' : 'replied';
1208
+ const resolution = type === 'question.rejected' || type === 'form.cancelled' ? 'rejected' : 'replied';
1201
1209
  if (entry.state === 'uncertain' || entry.state === 'submitting') {
1202
1210
  markSettled(entry, resolution, entry.submittedBy ?? null);
1203
1211
  bump();
@@ -0,0 +1,6 @@
1
+ import type { QuestionAutoDelegateIO, QuestionAutoDelegateUpstreamResult } from './core.js';
2
+
3
+ export function createQuestionFormIO(upstream: (
4
+ path: string,
5
+ options?: { directory?: string | null; method?: string; body?: unknown },
6
+ ) => Promise<QuestionAutoDelegateUpstreamResult>): Pick<QuestionAutoDelegateIO, 'listQuestions' | 'postReply' | 'postReject'>;
@@ -0,0 +1,39 @@
1
+ /** Native OpenCode form IO shared by Web and the VS Code host. */
2
+ export function createQuestionFormIO(upstream) {
3
+ const formPath = (sessionID, requestID) => {
4
+ if (!sessionID) throw new Error('Question form requires a sessionID');
5
+ return `/session/${encodeURIComponent(sessionID)}/form/${encodeURIComponent(requestID)}`;
6
+ };
7
+ return {
8
+ async listQuestions(directory) {
9
+ const result = await upstream('/form', { directory });
10
+ if (!result.ok) return null;
11
+ const rows = result.body?.data;
12
+ if (!Array.isArray(rows)) return null;
13
+ return rows.filter((form) => form?.metadata?.kind === 'question');
14
+ },
15
+ async postReply(requestID, directory, answers, sessionID) {
16
+ const path = formPath(sessionID, requestID);
17
+ const result = await upstream(path, { directory });
18
+ // A failed schema read never means the answer POST was dispatched.
19
+ if (!result.ok) return { ...result, notSent: true, uncertain: false };
20
+ const form = result.body?.data;
21
+ if (form?.id !== requestID || form?.sessionID !== sessionID
22
+ || form.metadata?.kind !== 'question' || !Array.isArray(form.fields) || !form.fields.length) {
23
+ throw new Error('Invalid question form schema');
24
+ }
25
+ const answer = Object.fromEntries(form.fields.map((field, index) => {
26
+ if (!field.key || (field.type !== 'string' && field.type !== 'multiselect')) {
27
+ throw new Error('Unsupported question form field');
28
+ }
29
+ const values = (answers[index] ?? []).map((value) =>
30
+ field.options?.find((option) => (option.label || option.value) === value)?.value ?? value);
31
+ return [field.key, field.type === 'multiselect' ? values : values[0] ?? ''];
32
+ }));
33
+ return upstream(`${path}/reply`, { directory, method: 'POST', body: { answer } });
34
+ },
35
+ async postReject(requestID, directory, _body, sessionID) {
36
+ return upstream(`${formPath(sessionID, requestID)}/cancel`, { directory, method: 'POST' });
37
+ },
38
+ };
39
+ }
@@ -2,6 +2,7 @@ import {
2
2
  createQuestionAutoDelegateCore,
3
3
  QUESTION_AUTO_DELEGATE_DELAY_MS,
4
4
  } from './core.js';
5
+ import { createQuestionFormIO } from './form-io.js';
5
6
 
6
7
  const REQUEST_TIMEOUT_MS = 15_000;
7
8
 
@@ -44,7 +45,7 @@ export function createQuestionAutoDelegateRuntime({
44
45
  } = {}) {
45
46
  const upstream = async (path, { directory, method = 'GET', body } = {}) => {
46
47
  const url = new URL(buildOpenCodeUrl(path, ''));
47
- if (directory) url.searchParams.set('directory', directory);
48
+ if (directory) url.searchParams.set('location[directory]', directory);
48
49
  let response;
49
50
  try {
50
51
  response = await fetchImpl(url, {
@@ -149,14 +150,7 @@ export function createQuestionAutoDelegateRuntime({
149
150
  }
150
151
  return Array.from(dirs);
151
152
  },
152
- async listQuestions(directory) {
153
- const result = await upstream('/question', { directory });
154
- if (!result.ok) return null;
155
- const payload = result.body;
156
- if (Array.isArray(payload)) return payload;
157
- if (Array.isArray(payload?.data)) return payload.data;
158
- return null;
159
- },
153
+ ...createQuestionFormIO(upstream),
160
154
  async getSession(sessionID, directory) {
161
155
  const result = await upstream(`/session/${encodeURIComponent(sessionID)}`, { directory });
162
156
  if (!result.ok) return null;
@@ -167,23 +161,9 @@ export function createQuestionAutoDelegateRuntime({
167
161
  return {
168
162
  id: asTrimmedString(info.id) || sessionID,
169
163
  parentID: asTrimmedString(info.parentID) || null,
170
- directory: asTrimmedString(info.directory) || asTrimmedString(directory) || null,
164
+ directory: asTrimmedString(info.location?.directory) || asTrimmedString(info.directory) || asTrimmedString(directory) || null,
171
165
  };
172
166
  },
173
- async postReply(requestID, directory, answers) {
174
- return upstream(`/question/${encodeURIComponent(requestID)}/reply`, {
175
- directory,
176
- method: 'POST',
177
- body: { answers },
178
- });
179
- },
180
- async postReject(requestID, directory, body) {
181
- return upstream(`/question/${encodeURIComponent(requestID)}/reject`, {
182
- directory,
183
- method: 'POST',
184
- body: body && typeof body === 'object' ? body : {},
185
- });
186
- },
187
167
  },
188
168
  });
189
169
 
@@ -0,0 +1,138 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import { createQuestionAutoDelegateRuntime } from './runtime.js';
3
+ import { QUESTION_AUTO_DELEGATE_ANSWER } from './core.js';
4
+
5
+ const form = {
6
+ id: 'frm_question', sessionID: 'ses_test', title: 'Choose',
7
+ metadata: { kind: 'question' },
8
+ fields: [{ key: 'approach', type: 'string', custom: true, options: [{ label: 'A', value: 'a' }] }],
9
+ };
10
+ const runtimes = [];
11
+ afterEach(() => {
12
+ runtimes.splice(0).forEach((runtime) => runtime.dispose());
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ const setup = async (pending = []) => {
17
+ vi.useFakeTimers();
18
+ let onEvent;
19
+ const fetchImpl = vi.fn(async (url, options) => {
20
+ const path = url.pathname;
21
+ if (path === '/api/form') return Response.json({ data: pending });
22
+ if (path === '/api/session/ses_test') return Response.json({ data: { id: 'ses_test', location: { directory: '/project' } } });
23
+ if (path === '/api/session/ses_test/form/frm_question') return Response.json({ data: form });
24
+ if (path === '/api/session/ses_test/form/frm_question/reply' && options.method === 'POST') return new Response(null, { status: 204 });
25
+ return Response.json({ error: 'Not found' }, { status: 404 });
26
+ });
27
+ const runtime = createQuestionAutoDelegateRuntime({
28
+ globalEventHub: { subscribeEvent: (fn) => { onEvent = fn; return () => {}; } },
29
+ buildOpenCodeUrl: (path) => `http://opencode.test/api${path}`,
30
+ getOpenCodeAuthHeaders: () => ({}),
31
+ readSettingsFromDiskMigrated: async () => ({ projects: [{ path: '/project' }] }),
32
+ fetchImpl,
33
+ });
34
+ runtimes.push(runtime);
35
+ runtime.start();
36
+ await vi.advanceTimersByTimeAsync(0);
37
+ return {
38
+ runtime, fetchImpl,
39
+ emit: (type, data, directory = '/project') => onEvent({ directory: 'global', payload: {
40
+ type, data, ...(directory ? { location: { directory } } : {}),
41
+ } }),
42
+ posts: () => fetchImpl.mock.calls.filter(([, options]) => options.method === 'POST'),
43
+ };
44
+ };
45
+
46
+ it('auto-delegates a native question form after exactly 30 seconds without a UI', async () => {
47
+ const { emit, posts, runtime } = await setup();
48
+ emit('form.created', { sessionID: form.sessionID, form });
49
+ await vi.advanceTimersByTimeAsync(29_999);
50
+ expect(posts()).toHaveLength(0);
51
+ await vi.advanceTimersByTimeAsync(1);
52
+ expect(posts()).toHaveLength(1);
53
+ expect(posts()[0][0].pathname).toBe('/api/session/ses_test/form/frm_question/reply');
54
+ expect(JSON.parse(posts()[0][1].body)).toEqual({ answer: { approach: QUESTION_AUTO_DELEGATE_ANSWER } });
55
+ expect(runtime.snapshot().requests[0].state).toBe('settled');
56
+ });
57
+
58
+ it('recovers pending native question forms on startup', async () => {
59
+ const { posts } = await setup([form]);
60
+ await vi.advanceTimersByTimeAsync(30_000);
61
+ expect(posts()).toHaveLength(1);
62
+ });
63
+
64
+ it.each(['form.replied', 'form.cancelled'])('cancels the timer on %s', async (type) => {
65
+ const { emit, posts, runtime } = await setup();
66
+ emit('form.created', { sessionID: form.sessionID, form });
67
+ expect(runtime.snapshot().requests).toHaveLength(1);
68
+ emit(type, { sessionID: form.sessionID, id: form.id });
69
+ await vi.advanceTimersByTimeAsync(30_000);
70
+ expect(posts()).toHaveLength(0);
71
+ expect(runtime.snapshot().requests[0].state).toBe('settled');
72
+ });
73
+
74
+ it('does not auto-answer non-question forms', async () => {
75
+ const other = { ...form, metadata: { kind: 'configuration' } };
76
+ const { emit, posts, runtime } = await setup([other]);
77
+ emit('form.created', { sessionID: other.sessionID, form: other });
78
+ await vi.advanceTimersByTimeAsync(30_000);
79
+ expect(posts()).toHaveLength(0);
80
+ expect(runtime.snapshot().requests).toEqual([]);
81
+ });
82
+
83
+ it('resolves a directory-less event through the native session location', async () => {
84
+ const { emit, posts } = await setup();
85
+ emit('form.created', { form }, '');
86
+ await vi.advanceTimersByTimeAsync(30_000);
87
+ expect(posts()).toHaveLength(1);
88
+ });
89
+
90
+ it('preserves pending questions when native list reconciliation fails', async () => {
91
+ const { emit, runtime, fetchImpl, posts } = await setup();
92
+ emit('form.created', { form });
93
+ const original = fetchImpl.getMockImplementation();
94
+ fetchImpl.mockImplementation((url, options) => url.pathname === '/api/form'
95
+ ? Promise.resolve(new Response(null, { status: 503 })) : original(url, options));
96
+ await runtime.reconcile();
97
+ expect(runtime.snapshot().coverage.state).toBe('partial');
98
+ expect(runtime.snapshot().requests[0].state).toBe('counting');
99
+ await vi.advanceTimersByTimeAsync(30_000);
100
+ expect(posts()).toHaveLength(1);
101
+ });
102
+
103
+ it('pauses for retry when schema lookup fails, without sending an answer', async () => {
104
+ const { emit, runtime, fetchImpl, posts } = await setup();
105
+ emit('form.created', { form });
106
+ const original = fetchImpl.getMockImplementation();
107
+ fetchImpl.mockImplementation((url, options) => url.pathname.endsWith('/form/frm_question')
108
+ ? Promise.resolve(new Response(null, { status: 503 })) : original(url, options));
109
+ await vi.advanceTimersByTimeAsync(30_000);
110
+ expect(posts()).toHaveLength(0);
111
+ expect(runtime.snapshot().requests[0].state).toBe('paused');
112
+ });
113
+
114
+ it('honors user takeover and explicit delegation of native forms', async () => {
115
+ const { emit, runtime, posts } = await setup();
116
+ emit('form.created', { form });
117
+ const identity = { requestID: form.id, sessionID: form.sessionID, directory: '/project' };
118
+ await runtime.pause({ ...identity, reason: 'interaction' });
119
+ await vi.advanceTimersByTimeAsync(30_000);
120
+ expect(posts()).toHaveLength(0);
121
+ await runtime.delegate(identity);
122
+ expect(posts()).toHaveLength(1);
123
+ });
124
+
125
+ it('uses the freshly read schema for multiple fields and array answers', async () => {
126
+ const { emit, fetchImpl, posts } = await setup();
127
+ const original = fetchImpl.getMockImplementation();
128
+ fetchImpl.mockImplementation((url, options) => url.pathname.endsWith('/form/frm_question')
129
+ ? Promise.resolve(Response.json({ data: { ...form, fields: [
130
+ { key: 'actual_key', type: 'string', custom: true },
131
+ { key: 'targets', type: 'multiselect', custom: true, options: [] },
132
+ ] } })) : original(url, options));
133
+ emit('form.created', { form: { ...form, fields: [...form.fields, { key: 'old', type: 'multiselect' }] } });
134
+ await vi.advanceTimersByTimeAsync(30_000);
135
+ expect(JSON.parse(posts()[0][1].body)).toEqual({ answer: {
136
+ actual_key: QUESTION_AUTO_DELEGATE_ANSWER, targets: [QUESTION_AUTO_DELEGATE_ANSWER],
137
+ } });
138
+ });