@vantran-se/openchamber-web 1.22.1 → 1.23.0

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 +2 -3
  2. package/bin/cli.js +11 -1
  3. package/bin/cli.test.js +92 -0
  4. package/bin/lib/cli-startup.js +49 -0
  5. package/bin/lib/commands-startup.js +41 -6
  6. package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
  7. package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
  8. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
  9. package/dist/assets/FilesView-BdTOpew9.js +17 -0
  10. package/dist/assets/GitView-Dqf9vHJh.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
  12. package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
  13. package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
  14. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
  15. package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
  17. package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
  18. package/dist/assets/PlanView-BoJxdRZe.js +5 -0
  19. package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
  21. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  22. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
  23. package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
  25. package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
  26. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
  27. package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
  28. package/dist/assets/de-C8jV7J9y.js +10 -0
  29. package/dist/assets/es-BI6J1M5R.js +16 -0
  30. package/dist/assets/fr-D6oDFt7E.js +14 -0
  31. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  32. package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
  33. package/dist/assets/index-BqhiCmYS.js +39 -0
  34. package/dist/assets/index-FqSOBz0J.css +1 -0
  35. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
  36. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
  37. package/dist/assets/ja-C_4i5EQu.js +16 -0
  38. package/dist/assets/ko-BFbOTpbq.js +16 -0
  39. package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
  40. package/dist/assets/main-CJmb2dF8.js +930 -0
  41. package/dist/assets/markdownCore-DVuZpnli.js +5 -0
  42. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
  43. package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
  44. package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
  45. package/dist/assets/pl-COFvT56l.js +16 -0
  46. package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
  48. package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
  50. package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
  51. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
  52. package/dist/assets/surface-BvRzS58U.js +1 -0
  53. package/dist/assets/tr-B8Q0Go2l.js +7 -0
  54. package/dist/assets/uk-Dk4a-Fn-.js +16 -0
  55. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
  56. package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
  57. package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
  58. package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
  59. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
  61. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  62. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  63. package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
  64. package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
  65. package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
  66. package/dist/index.html +5 -43
  67. package/dist/mini-chat.html +4 -4
  68. package/dist/mobile.html +4 -4
  69. package/package.json +8 -5
  70. package/server/index.d.ts +15 -0
  71. package/server/index.js +82 -13
  72. package/server/lib/agent-tool/runtime.js +2 -19
  73. package/server/lib/changelog/update-notes.js +82 -0
  74. package/server/lib/changelog/update-notes.test.js +59 -0
  75. package/server/lib/data-dir-migration.js +40 -0
  76. package/server/lib/data-dir-migration.test.js +59 -0
  77. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  78. package/server/lib/dev-tunnel/runtime.js +4 -4
  79. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  80. package/server/lib/event-stream/runtime.test.js +12 -12
  81. package/server/lib/fs/DOCUMENTATION.md +5 -0
  82. package/server/lib/fs/routes.js +63 -21
  83. package/server/lib/fs/routes.test.js +187 -1
  84. package/server/lib/git/DOCUMENTATION.md +32 -6
  85. package/server/lib/git/routes.js +40 -4
  86. package/server/lib/git/service.js +291 -215
  87. package/server/lib/git/service.test.js +593 -25
  88. package/server/lib/inherited-env.js +16 -9
  89. package/server/lib/inherited-env.test.js +8 -9
  90. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  91. package/server/lib/mcp-reconnect/runtime.js +122 -0
  92. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  93. package/server/lib/message-queue/DOCUMENTATION.md +161 -0
  94. package/server/lib/message-queue/runtime.js +870 -0
  95. package/server/lib/message-queue/runtime.test.js +482 -0
  96. package/server/lib/network-defaults.js +23 -0
  97. package/server/lib/network-defaults.test.js +52 -0
  98. package/server/lib/openchamber-sessions/routes.js +102 -0
  99. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  100. package/server/lib/opencode/DOCUMENTATION.md +30 -6
  101. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  102. package/server/lib/opencode/config-paths.test.js +116 -0
  103. package/server/lib/opencode/core-routes.js +1 -0
  104. package/server/lib/opencode/feature-routes-runtime.js +7 -0
  105. package/server/lib/opencode/input-history-scope.js +14 -0
  106. package/server/lib/opencode/managed-plugin-config.js +35 -0
  107. package/server/lib/opencode/openchamber-routes.js +64 -4
  108. package/server/lib/opencode/openchamber-routes.test.js +130 -3
  109. package/server/lib/opencode/plugins.js +2 -2
  110. package/server/lib/opencode/providers.js +44 -2
  111. package/server/lib/opencode/providers.test.js +131 -4
  112. package/server/lib/opencode/routes.js +9 -7
  113. package/server/lib/opencode/server-startup-runtime.js +1 -1
  114. package/server/lib/opencode/settings-files.js +214 -0
  115. package/server/lib/opencode/settings-helpers.js +113 -41
  116. package/server/lib/opencode/settings-helpers.test.js +342 -0
  117. package/server/lib/opencode/settings-registry.json +747 -0
  118. package/server/lib/opencode/settings-runtime.js +141 -20
  119. package/server/lib/opencode/settings-runtime.test.js +191 -4
  120. package/server/lib/opencode/shared.js +4 -1
  121. package/server/lib/opencode/shutdown-runtime.js +2 -0
  122. package/server/lib/opencode/skill-routes.js +2 -1
  123. package/server/lib/opencode/snippets.js +1 -2
  124. package/server/lib/package-manager.js +4 -29
  125. package/server/lib/package-manager.test.js +2 -2
  126. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  127. package/server/lib/project-context/routes.http.test.js +18 -0
  128. package/server/lib/project-context/routes.js +15 -0
  129. package/server/lib/project-context/runtime.js +248 -23
  130. package/server/lib/project-context/runtime.test.js +106 -3
  131. package/server/lib/projects/DOCUMENTATION.md +76 -0
  132. package/server/lib/projects/project-config.js +142 -9
  133. package/server/lib/projects/project-id.js +17 -0
  134. package/server/lib/projects/project-setup.js +409 -0
  135. package/server/lib/projects/project-setup.test.js +557 -0
  136. package/server/lib/projects/routes.js +59 -0
  137. package/server/lib/quota/DOCUMENTATION.md +40 -2
  138. package/server/lib/quota/credentials/providers.js +4 -0
  139. package/server/lib/quota/credentials/store.js +1 -1
  140. package/server/lib/quota/credentials/store.test.js +4 -4
  141. package/server/lib/quota/index.js +1 -0
  142. package/server/lib/quota/providers/cline-pass.js +125 -0
  143. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  144. package/server/lib/quota/providers/exe-dev.js +75 -0
  145. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  146. package/server/lib/quota/providers/hyper.js +118 -0
  147. package/server/lib/quota/providers/hyper.test.js +126 -0
  148. package/server/lib/quota/providers/index.js +22 -0
  149. package/server/lib/quota/providers/neuralwatt.js +3 -6
  150. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  151. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  152. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  153. package/server/lib/quota/providers/opencode-go.js +2 -1
  154. package/server/lib/quota/providers/opencode-go.test.js +31 -2
  155. package/server/lib/quota/providers/openrouter.js +119 -24
  156. package/server/lib/quota/providers/openrouter.test.js +330 -0
  157. package/server/lib/quota/routes.js +2 -0
  158. package/server/lib/quota/utils/auth.js +1 -1
  159. package/server/lib/relay/DOCUMENTATION.md +56 -1
  160. package/server/lib/relay/cross-compat.test.js +26 -0
  161. package/server/lib/relay/downstream-scheduler.js +154 -0
  162. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  163. package/server/lib/relay/e2ee.js +10 -8
  164. package/server/lib/relay/flow-control.test.js +177 -0
  165. package/server/lib/relay/host-client.js +37 -8
  166. package/server/lib/relay/host-client.test.js +9 -1
  167. package/server/lib/relay/tunnel-codec.js +9 -0
  168. package/server/lib/relay/tunnel-host.js +69 -18
  169. package/server/lib/relay/tunnel-host.test.js +9 -1
  170. package/server/lib/security/request-security.js +21 -6
  171. package/server/lib/security/request-security.test.js +42 -8
  172. package/server/lib/session-assist/runtime.js +7 -10
  173. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  174. package/server/lib/session-goal/create.js +3 -1
  175. package/server/lib/session-goal/runtime.js +81 -20
  176. package/server/lib/session-goal/runtime.test.js +426 -0
  177. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  178. package/server/lib/skills-catalog/install.js +3 -2
  179. package/server/lib/small-model/DOCUMENTATION.md +7 -2
  180. package/server/lib/small-model/call.js +17 -6
  181. package/server/lib/small-model/call.test.js +52 -0
  182. package/server/lib/small-model/index.js +7 -11
  183. package/server/lib/small-model/routes.js +2 -1
  184. package/server/lib/small-model/runtime-providers.js +15 -7
  185. package/server/lib/small-model/runtime-providers.test.js +7 -1
  186. package/server/lib/system-prompt/runtime.js +2 -21
  187. package/server/lib/terminal/DOCUMENTATION.md +14 -10
  188. package/server/lib/terminal/runtime.js +190 -31
  189. package/server/lib/terminal/runtime.test.js +571 -37
  190. package/server/lib/terminal/shells.js +17 -0
  191. package/server/lib/terminal/shells.test.js +32 -1
  192. package/server/lib/ui-auth/ui-auth.js +4 -1
  193. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  194. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  195. package/server/lib/walkthrough/index.js +1 -0
  196. package/server/lib/walkthrough/model-settings.js +8 -12
  197. package/server/lib/walkthrough/prompt.js +3 -1
  198. package/server/lib/walkthrough/routes.test.js +26 -5
  199. package/server/lib/walkthrough/sources.js +23 -2
  200. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  201. package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
  202. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  203. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  204. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  205. package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
  206. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  207. package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
  208. package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
  209. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  210. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  211. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  212. package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
  213. package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
  214. package/dist/assets/de-DqVn5vme.js +0 -9
  215. package/dist/assets/es-hj43Z53i.js +0 -15
  216. package/dist/assets/fr-BmkZt2mF.js +0 -13
  217. package/dist/assets/index-L_ncV-6H.css +0 -1
  218. package/dist/assets/index-qES19dlJ.js +0 -7
  219. package/dist/assets/ja-DioMIEGF.js +0 -15
  220. package/dist/assets/ko-BqPgaQJb.js +0 -15
  221. package/dist/assets/main-CYgSu6dx.js +0 -928
  222. package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
  223. package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
  224. package/dist/assets/pl-slULBF4R.js +0 -15
  225. package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
  226. package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
  227. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  228. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  229. package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
  230. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  231. package/dist/assets/uk-BkkfxbOs.js +0 -15
  232. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  233. package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
  234. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  235. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  236. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
  237. package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
  238. package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
@@ -46,6 +46,78 @@ const startIdleTick = async (fetchImpl) => {
46
46
  return { runtime, getSmallModelService };
47
47
  };
48
48
 
49
+ const assistantMessage = (id, infoOverrides = {}) => ({
50
+ info: {
51
+ id,
52
+ sessionID: SESSION_ID,
53
+ role: 'assistant',
54
+ providerID: 'provider',
55
+ modelID: 'model',
56
+ time: { created: 2, completed: 2 },
57
+ tokens: { input: 1, output: 1, cache: { read: 0 } },
58
+ ...infoOverrides,
59
+ },
60
+ parts: [{ type: 'text', text: 'The agent made progress.' }],
61
+ });
62
+
63
+ const createRuntimeHarness = ({ messages, messageFactory, goalOverrides = {}, maxAutoTurns = 20 }) => {
64
+ const requests = [];
65
+ let messageFetchCount = 0;
66
+ const activeSession = {
67
+ ...session,
68
+ metadata: { openchamber: { goal: { ...goal, ...goalOverrides } } },
69
+ };
70
+ const service = {
71
+ generateSmallModelText: vi.fn(async () => ({
72
+ text: '{"verdict":"continue","note":"More work remains"}',
73
+ providerID: 'provider',
74
+ modelID: 'model',
75
+ })),
76
+ };
77
+ const fetchImpl = vi.fn(async (input, init = {}) => {
78
+ const pathname = requestPath(input);
79
+ requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
80
+ if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') {
81
+ activeSession.metadata = JSON.parse(init.body).metadata;
82
+ return jsonResponse(activeSession);
83
+ }
84
+ if (pathname === `/session/${SESSION_ID}`) return jsonResponse(activeSession);
85
+ if (pathname === '/session/status') return jsonResponse({});
86
+ if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
87
+ if (pathname === `/session/${SESSION_ID}/message`) {
88
+ const nextMessages = messageFactory ? messageFactory(messageFetchCount) : messages;
89
+ messageFetchCount += 1;
90
+ return jsonResponse(nextMessages);
91
+ }
92
+ if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
93
+ throw new Error(`Unexpected request: ${pathname}`);
94
+ });
95
+ vi.stubGlobal('fetch', fetchImpl);
96
+ const runtime = createSessionGoalRuntime({
97
+ buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
98
+ getOpenCodeAuthHeaders: () => ({}),
99
+ getSmallModelService: async () => service,
100
+ isEnabled: () => true,
101
+ idleQuietMs: 10,
102
+ maxAutoTurns,
103
+ });
104
+ return { runtime, requests, service, activeSession };
105
+ };
106
+
107
+ const runIdleTick = async (runtime) => {
108
+ runtime.processPayload({
109
+ type: 'session.status',
110
+ properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
111
+ });
112
+ await vi.runOnlyPendingTimersAsync();
113
+ };
114
+
115
+ const lastPatchedGoal = (requests) => {
116
+ const patches = requests.filter((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
117
+ expect(patches.length).toBeGreaterThan(0);
118
+ return JSON.parse(patches.at(-1).body).metadata.openchamber.goal;
119
+ };
120
+
49
121
  describe('session goal live activity gate', () => {
50
122
  beforeEach(() => {
51
123
  vi.useFakeTimers();
@@ -178,4 +250,358 @@ describe('session goal live activity gate', () => {
178
250
  });
179
251
  runtime.stop();
180
252
  });
253
+
254
+ it('skips audit and sends continuation prompt when assistant message finishes with length stop', async () => {
255
+ const requests = [];
256
+ const fetchImpl = vi.fn(async (input, init = {}) => {
257
+ const pathname = requestPath(input);
258
+ requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
259
+ if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
260
+ if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
261
+ if (pathname === '/session/status') return jsonResponse({});
262
+ if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
263
+ if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
264
+ if (pathname === `/session/${SESSION_ID}/message`) {
265
+ return jsonResponse([{
266
+ info: {
267
+ id: 'msg_assistant_len',
268
+ sessionID: SESSION_ID,
269
+ role: 'assistant',
270
+ providerID: 'provider',
271
+ modelID: 'model',
272
+ finish: 'length',
273
+ time: { completed: 2 },
274
+ tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
275
+ },
276
+ parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
277
+ }]);
278
+ }
279
+ throw new Error(`Unexpected request: ${pathname}`);
280
+ });
281
+ const service = {
282
+ generateSmallModelText: vi.fn(),
283
+ };
284
+ vi.stubGlobal('fetch', fetchImpl);
285
+ const runtime = createSessionGoalRuntime({
286
+ buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
287
+ getOpenCodeAuthHeaders: () => ({}),
288
+ getSmallModelService: async () => service,
289
+ isEnabled: () => true,
290
+ idleQuietMs: 10,
291
+ });
292
+
293
+ runtime.processPayload({
294
+ type: 'session.status',
295
+ properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
296
+ });
297
+ await vi.runOnlyPendingTimersAsync();
298
+
299
+ // Audit must be skipped on length-truncated turn
300
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
301
+
302
+ // Goal accounting is persisted and turnsUsed incremented
303
+ const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
304
+ expect(patch).toBeDefined();
305
+ const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
306
+ expect(writtenGoal).toMatchObject({
307
+ status: 'active',
308
+ turnsUsed: 2,
309
+ });
310
+
311
+ // Continuation prompt is dispatched
312
+ const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
313
+ expect(promptAsync).toBeDefined();
314
+ const promptBody = JSON.parse(promptAsync.body);
315
+ expect(promptBody.parts[0].text).toContain('Continue working toward the active session goal.');
316
+ runtime.stop();
317
+ });
318
+
319
+ it('skips audit and sends continuation prompt when assistant message carries MessageOutputLengthError', async () => {
320
+ const requests = [];
321
+ const fetchImpl = vi.fn(async (input, init = {}) => {
322
+ const pathname = requestPath(input);
323
+ requests.push({ pathname, method: init.method ?? 'GET', body: init.body });
324
+ if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
325
+ if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
326
+ if (pathname === '/session/status') return jsonResponse({});
327
+ if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
328
+ if (pathname === `/session/${SESSION_ID}/prompt_async`) return jsonResponse({ ok: true });
329
+ if (pathname === `/session/${SESSION_ID}/message`) {
330
+ return jsonResponse([{
331
+ info: {
332
+ id: 'msg_assistant_len_err',
333
+ sessionID: SESSION_ID,
334
+ role: 'assistant',
335
+ providerID: 'provider',
336
+ modelID: 'model',
337
+ error: { name: 'MessageOutputLengthError', message: 'Maximum token limit reached' },
338
+ time: { completed: 2 },
339
+ tokens: { input: 100, output: 4096, reasoning: 4096, cache: { read: 0 } },
340
+ },
341
+ parts: [{ type: 'reasoning', text: 'Drafting extensive implementation...' }],
342
+ }]);
343
+ }
344
+ throw new Error(`Unexpected request: ${pathname}`);
345
+ });
346
+ const service = {
347
+ generateSmallModelText: vi.fn(),
348
+ };
349
+ vi.stubGlobal('fetch', fetchImpl);
350
+ const runtime = createSessionGoalRuntime({
351
+ buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
352
+ getOpenCodeAuthHeaders: () => ({}),
353
+ getSmallModelService: async () => service,
354
+ isEnabled: () => true,
355
+ idleQuietMs: 10,
356
+ });
357
+
358
+ runtime.processPayload({
359
+ type: 'session.status',
360
+ properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
361
+ });
362
+ await vi.runOnlyPendingTimersAsync();
363
+
364
+ // Audit must be skipped on MessageOutputLengthError
365
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
366
+
367
+ // Goal remains active and turnsUsed incremented
368
+ const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH');
369
+ expect(patch).toBeDefined();
370
+ const writtenGoal = JSON.parse(patch.body).metadata.openchamber.goal;
371
+ expect(writtenGoal).toMatchObject({
372
+ status: 'active',
373
+ turnsUsed: 2,
374
+ });
375
+
376
+ // Continuation prompt is dispatched
377
+ const promptAsync = requests.find((request) => request.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === 'POST');
378
+ expect(promptAsync).toBeDefined();
379
+ runtime.stop();
380
+ });
381
+
382
+ it.each([
383
+ { name: 'APIError', error: { name: 'APIError', message: 'provider failed' } },
384
+ { name: 'StructuredOutputError', error: { name: 'StructuredOutputError', message: 'invalid output' } },
385
+ { name: 'unnamed errors', error: {} },
386
+ ])('blocks a length finish when the assistant also has a non-length $name', async ({ error }) => {
387
+ const { runtime, requests, service } = createRuntimeHarness({
388
+ messages: [assistantMessage('msg_assistant_error', { finish: 'length', error })],
389
+ });
390
+
391
+ await runIdleTick(runtime);
392
+
393
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
394
+ expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
395
+ expect(lastPatchedGoal(requests)).toMatchObject({
396
+ status: 'blocked',
397
+ statusReason: error.name || 'assistant turn failed',
398
+ });
399
+ runtime.stop();
400
+ });
401
+
402
+ it('blocks after two consecutive length-truncated agent turns without persisting a counter, regardless of message IDs', async () => {
403
+ const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
404
+ const summary = assistantMessage('summary', { summary: true, time: { created: 15, completed: 16 } });
405
+ const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
406
+ const { runtime, requests, service } = createRuntimeHarness({
407
+ messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary, secondLength],
408
+ });
409
+
410
+ await runIdleTick(runtime);
411
+ runtime.processPayload({
412
+ type: 'session.status',
413
+ properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
414
+ });
415
+ await vi.runOnlyPendingTimersAsync();
416
+
417
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
418
+ expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
419
+ expect(lastPatchedGoal(requests)).toMatchObject({
420
+ status: 'blocked',
421
+ statusReason: 'repeated output truncation',
422
+ });
423
+ runtime.stop();
424
+ });
425
+
426
+ it('allows one explicit Resume after repeated truncation, then blocks another cutoff', async () => {
427
+ const first = assistantMessage('first', { finish: 'length', time: { created: 10, completed: 11 } });
428
+ const second = assistantMessage('second', { finish: 'length', time: { created: 20, completed: 21 } });
429
+ const messages = [first, second];
430
+ const { runtime, requests, activeSession } = createRuntimeHarness({ messages });
431
+ try {
432
+ await runIdleTick(runtime);
433
+ expect(lastPatchedGoal(requests).status).toBe('blocked');
434
+ expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
435
+ Object.assign(activeSession.metadata.openchamber.goal, { status: 'active', statusReason: 'resumed', turnsUsed: 0 });
436
+ await runIdleTick(runtime);
437
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active', statusReason: '', turnsUsed: 1 });
438
+ expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
439
+ messages.push(assistantMessage('third', { finish: 'length', time: { created: 30, completed: 31 } }));
440
+ await runIdleTick(runtime);
441
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'blocked', statusReason: 'repeated output truncation' });
442
+ expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(1);
443
+ } finally {
444
+ runtime.stop();
445
+ }
446
+ });
447
+
448
+ it.each([
449
+ { tokenBudget: 1, error: undefined, expectedStatus: 'budgetLimited' },
450
+ { tokenBudget: null, error: { name: 'APIError' }, expectedStatus: 'blocked' },
451
+ ])('keeps $expectedStatus protection on explicit Resume', async ({ tokenBudget, error, expectedStatus }) => {
452
+ const { runtime, requests } = createRuntimeHarness({
453
+ goalOverrides: { statusReason: 'resumed', tokenBudget },
454
+ messages: [assistantMessage('resumed', { finish: 'length', error })],
455
+ });
456
+ try {
457
+ await runIdleTick(runtime);
458
+ expect(lastPatchedGoal(requests).status).toBe(expectedStatus);
459
+ expect(requests.filter((request) => request.pathname.endsWith('/prompt_async'))).toHaveLength(0);
460
+ } finally {
461
+ runtime.stop();
462
+ }
463
+ });
464
+
465
+ it('continues after a truncated agent turn followed by a length-finished summary', async () => {
466
+ const firstLength = assistantMessage('agent-length', { finish: 'length', time: { created: 10, completed: 11 } });
467
+ const summary = assistantMessage('summary', {
468
+ summary: true,
469
+ finish: 'length',
470
+ time: { created: 15, completed: 16 },
471
+ });
472
+ const { runtime, requests, service } = createRuntimeHarness({
473
+ messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, summary],
474
+ });
475
+
476
+ await runIdleTick(runtime);
477
+ await runIdleTick(runtime);
478
+
479
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
480
+ expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
481
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
482
+ runtime.stop();
483
+ });
484
+
485
+ it('does not infer a repeated streak when a completed assistant timestamp is missing', async () => {
486
+ const firstLength = assistantMessage('z', { finish: 'length', time: { completed: 11 } });
487
+ const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
488
+ const { runtime, requests, service } = createRuntimeHarness({
489
+ messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
490
+ });
491
+
492
+ await runIdleTick(runtime);
493
+ await runIdleTick(runtime);
494
+
495
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
496
+ expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
497
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
498
+ runtime.stop();
499
+ });
500
+
501
+ it('ignores a missing timestamp on an older assistant when known post-goal turns establish a streak', async () => {
502
+ const olderLength = assistantMessage('legacy', { finish: 'length', time: { completed: 5 } });
503
+ const firstLength = assistantMessage('z', { finish: 'length', time: { created: 10, completed: 11 } });
504
+ const secondLength = assistantMessage('a', { finish: 'length', time: { created: 20, completed: 21 } });
505
+ const { runtime, requests, service } = createRuntimeHarness({
506
+ goalOverrides: { createdAt: 6 },
507
+ messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [olderLength, firstLength, secondLength],
508
+ });
509
+
510
+ await runIdleTick(runtime);
511
+ await runIdleTick(runtime);
512
+
513
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
514
+ expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(1);
515
+ expect(lastPatchedGoal(requests)).toMatchObject({
516
+ status: 'blocked',
517
+ statusReason: 'repeated output truncation',
518
+ });
519
+ runtime.stop();
520
+ });
521
+
522
+ it('does not count a previous truncated turn created at the goal boundary', async () => {
523
+ const firstLength = assistantMessage('before-boundary', { finish: 'length', time: { created: 10, completed: 11 } });
524
+ const secondLength = assistantMessage('after-boundary', { finish: 'length', time: { created: 20, completed: 21 } });
525
+ const { runtime, requests, service } = createRuntimeHarness({
526
+ goalOverrides: { createdAt: 10 },
527
+ messageFactory: (fetchCount) => fetchCount < 2 ? [firstLength] : [firstLength, secondLength],
528
+ });
529
+
530
+ await runIdleTick(runtime);
531
+ await runIdleTick(runtime);
532
+
533
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
534
+ expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
535
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
536
+ runtime.stop();
537
+ });
538
+
539
+ it('allows length recovery after an ordinary assistant turn breaks the streak', async () => {
540
+ const firstLength = assistantMessage('msg_length_before_normal', { finish: 'length' });
541
+ const normal = assistantMessage('msg_normal');
542
+ const secondLength = assistantMessage('msg_length_after_normal', { finish: 'length' });
543
+ const { runtime, requests, service } = createRuntimeHarness({
544
+ messageFactory: (fetchCount) => fetchCount < 2
545
+ ? [firstLength]
546
+ : [firstLength, normal, secondLength],
547
+ });
548
+
549
+ await runIdleTick(runtime);
550
+ await runIdleTick(runtime);
551
+
552
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
553
+ expect(requests.filter((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toHaveLength(2);
554
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'active' });
555
+ runtime.stop();
556
+ });
557
+
558
+ it('keeps MessageAbortedError pause behavior instead of continuing', async () => {
559
+ const { runtime, requests, service } = createRuntimeHarness({
560
+ messages: [assistantMessage('msg_aborted', { error: { name: 'MessageAbortedError' } })],
561
+ });
562
+
563
+ await runIdleTick(runtime);
564
+
565
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
566
+ expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
567
+ expect(lastPatchedGoal(requests)).toMatchObject({
568
+ status: 'paused',
569
+ statusReason: 'paused after abort',
570
+ });
571
+ runtime.stop();
572
+ });
573
+
574
+ it('checks the token budget before allowing length recovery', async () => {
575
+ const { runtime, requests, service } = createRuntimeHarness({
576
+ goalOverrides: { tokenBudget: 5 },
577
+ messages: [assistantMessage('msg_budget_length', {
578
+ finish: 'length',
579
+ tokens: { input: 3, output: 3, cache: { read: 0 } },
580
+ })],
581
+ });
582
+
583
+ await runIdleTick(runtime);
584
+
585
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
586
+ expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
587
+ expect(lastPatchedGoal(requests)).toMatchObject({ status: 'budgetLimited' });
588
+ runtime.stop();
589
+ });
590
+
591
+ it('checks the auto-continuation cap before allowing length recovery', async () => {
592
+ const { runtime, requests, service } = createRuntimeHarness({
593
+ maxAutoTurns: 1,
594
+ messages: [assistantMessage('msg_cap_length', { finish: 'length' })],
595
+ });
596
+
597
+ await runIdleTick(runtime);
598
+
599
+ expect(service.generateSmallModelText).not.toHaveBeenCalled();
600
+ expect(requests.some((request) => request.pathname === `/session/${SESSION_ID}/prompt_async`)).toBe(false);
601
+ expect(lastPatchedGoal(requests)).toMatchObject({
602
+ status: 'blocked',
603
+ statusReason: 'auto-continuation limit reached',
604
+ });
605
+ runtime.stop();
606
+ });
181
607
  });
@@ -58,7 +58,7 @@ The following functions are internal helpers used by exported functions:
58
58
  - `safeRm(dir)`: Safely remove directory recursively (ignores errors).
59
59
  - `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
60
60
  - `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
61
- - `normalizeUserSkillDir(userSkillDir)`: Normalize user skill directory path (handles legacy `~/.config/opencode/skill` → `~/.config/opencode/skills` migration).
61
+ - `normalizeUserSkillDir(userSkillDir)`: Normalize the user skill directory path (handles the legacy `skill` directory in the XDG config location, or `~/.config/opencode/skill` when XDG is unset, by selecting the plural `skills` directory when appropriate).
62
62
 
63
63
  ### Git Clone Helpers (`install.js`, `scan.js`)
64
64
  - `cloneRepo({ cloneUrl, identity, tempDir })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode.
@@ -4,13 +4,14 @@ import path from 'path';
4
4
 
5
5
  import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
6
6
  import { parseSkillRepoSource } from './source.js';
7
+ import { OPENCODE_CONFIG_DIR } from '../opencode/shared.js';
7
8
 
8
9
  const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
9
10
 
10
11
  function normalizeUserSkillDir(userSkillDir) {
11
12
  if (!userSkillDir) return null;
12
- const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
13
- const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
13
+ const legacySkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
14
+ const pluralSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skills');
14
15
  if (userSkillDir === legacySkillDir) {
15
16
  if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
16
17
  return pluralSkillDir;
@@ -91,6 +91,10 @@ other runtime API.
91
91
  a blocker instead of a raw 500 message.
92
92
  - `call.js` — wire formats and per-provider auth, replicating OpenCode's
93
93
  plugin auth loaders:
94
+ - OpenCode-hosted providers receive `x-opencode-session`. Session-backed
95
+ features reuse the real OpenCode session id, walkthrough retries reuse the
96
+ walkthrough cache key, and standalone one-shot actions receive a fresh
97
+ opaque id for that generation.
94
98
  - **GitHub Copilot**: fetches the requested model's authenticated `/models`
95
99
  metadata from `https://api.githubcopilot.com` (or
96
100
  `copilot-api.<enterprise>`) and honors its advertised endpoint, preferring
@@ -113,8 +117,9 @@ other runtime API.
113
117
  - Everything else: OpenAI-compatible `/chat/completions` against the
114
118
  provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
115
119
  in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
116
- endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the
117
- provider's `api` field from the models.dev catalog. The credential follows
120
+ endpoint, (3) the selected model's endpoint OpenCode resolved at runtime,
121
+ (4) the provider-level runtime endpoint, or (5) the provider's `api` field
122
+ from the models.dev catalog. The credential follows
118
123
  the same shape: config `options.apiKey`, then the runtime credential, then
119
124
  the auth.json entry. `provider.<id>.options.headers` is sent with the
120
125
  request and overrides the bearer default, so gateways that authenticate on
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
5
6
  import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js';
6
7
  import { getCatalogProvider } from './catalog.js';
@@ -605,6 +606,8 @@ const readProviderConfig = (workingDirectory, providerID) => {
605
606
  }
606
607
  }
607
608
 
609
+ const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
610
+
608
611
  // ---------------------------------------------------------------------------
609
612
  // Dispatch
610
613
  // ---------------------------------------------------------------------------
@@ -645,10 +648,11 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID
645
648
  || null;
646
649
  }
647
650
 
648
- export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
651
+ export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
649
652
  const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
650
653
  const providerConfig = readProviderConfig(workingDirectory, providerID);
651
654
  const runtimeProvider = await getRuntimeProvider(providerID);
655
+ const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
652
656
  // Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
653
657
  // wins, then what OpenCode itself resolved at runtime (the only place a
654
658
  // plugin's credential exists), and the auth.json entry last.
@@ -758,9 +762,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
758
762
  // base URL for that provider (openai itself included). When a custom provider
759
763
  // is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
760
764
  // fall back to its baseURL from the OpenCode provider config, then to the
761
- // endpoint OpenCode resolved at runtime — which for a plugin provider is the
762
- // only place it exists, and for several of them is a local proxy the plugin
763
- // itself runs. The openai provider also respects
765
+ // selected model's endpoint OpenCode resolved at runtime, then to the
766
+ // provider-level runtime endpoint. For a plugin provider, the runtime listing
767
+ // is the only place those endpoints exist, and several are local proxies the
768
+ // plugin itself runs. The openai provider also respects
764
769
  // provider.openai.options.baseURL — OpenCode itself uses the same config for
765
770
  // all providers including openai.
766
771
  const provider = getCatalogProvider(catalog, providerID);
@@ -770,7 +775,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
770
775
  ? providerConfigUrl
771
776
  : providerID === 'openai'
772
777
  ? defaultOpenaiUrl
773
- : runtimeProvider?.baseURL
778
+ : runtimeModel?.api?.url
779
+ ?? runtimeProvider?.baseURL
774
780
  ?? (typeof provider?.api === 'string' && provider.api
775
781
  ? provider.api
776
782
  : null);
@@ -795,7 +801,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
795
801
  baseURL,
796
802
  // Configured headers last: a gateway that authenticates on its own header
797
803
  // must be able to override the bearer default rather than sit beside it.
798
- headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
804
+ headers: mergeHeadersCaseInsensitive(
805
+ mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
806
+ providerID.startsWith('opencode')
807
+ ? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() }
808
+ : null,
809
+ ),
799
810
  modelID,
800
811
  prompt,
801
812
  system,
@@ -483,6 +483,29 @@ describe('callSmallModel — custom provider config', () => {
483
483
  });
484
484
 
485
485
  describe('catalog-based base URL (no config override)', () => {
486
+ it('identifies OpenCode Go requests with the owning conversation', async () => {
487
+ readConfig.mockReturnValue({});
488
+ fetchMock.mockResolvedValue(ok('ok'));
489
+
490
+ await callSmallModel({
491
+ auth: { 'opencode-go': { type: 'api', key: 'go-key' } },
492
+ catalog: {
493
+ 'opencode-go': {
494
+ id: 'opencode-go',
495
+ api: 'https://opencode.ai/zen/go/v1',
496
+ models: { utility: { id: 'utility' } },
497
+ },
498
+ },
499
+ workingDirectory: '/proj',
500
+ sessionID: 'ses_conversation',
501
+ providerID: 'opencode-go',
502
+ modelID: 'utility',
503
+ prompt: 'hi',
504
+ });
505
+
506
+ expect(lastCall(fetchMock).init.headers['x-opencode-session']).toBe('ses_conversation');
507
+ });
508
+
486
509
  it('uses the catalog api field when no config baseURL is set', async () => {
487
510
  readConfig.mockReturnValue({});
488
511
  fetchMock.mockResolvedValue(ok('ok'));
@@ -542,6 +565,35 @@ describe('callSmallModel — custom provider config', () => {
542
565
  expect(init.headers.Authorization).toBe('Bearer plugin-key');
543
566
  });
544
567
 
568
+ it('uses the selected runtime model endpoint', async () => {
569
+ readConfig.mockReturnValue({});
570
+ getRuntimeProvider.mockResolvedValue({
571
+ id: 'runtime-provider',
572
+ apiKey: 'plugin-key',
573
+ baseURL: 'https://runtime-provider/v1beta',
574
+ models: new Map([
575
+ ['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }],
576
+ ['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }],
577
+ ]),
578
+ anonymousZen: false,
579
+ });
580
+ fetchMock.mockResolvedValue(ok('done'));
581
+
582
+ await callSmallModel({
583
+ auth: {},
584
+ catalog: {},
585
+ workingDirectory: '/proj',
586
+ providerID: 'runtime-provider',
587
+ modelID: 'selected-model',
588
+ prompt: 'hi',
589
+ });
590
+
591
+ const { url, init } = lastCall(fetchMock);
592
+ expect(url).toBe('https://runtime-provider/v1/chat/completions');
593
+ expect(url).not.toContain('/v1beta');
594
+ expect(JSON.parse(init.body).model).toBe('selected-model');
595
+ });
596
+
545
597
  it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
546
598
  readConfig.mockReturnValue({});
547
599
  // OpenCode reports an OAuth access token as `options.apiKey` for openai;
@@ -6,6 +6,7 @@ import { readConfigLayers } from '../opencode/shared.js';
6
6
  import { getModelCatalog } from './catalog.js';
7
7
  import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
8
8
  import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
9
+ import { readMergedSettingsSync } from '../opencode/settings-files.js';
9
10
  import { getRuntimeProviderSnapshot } from './runtime-providers.js';
10
11
 
11
12
  // Never a small model, whatever the transport looks like. A plugin can publish
@@ -26,16 +27,10 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
26
27
  // OpenChamber's own settings: when the user unchecks "use default small model"
27
28
  // their explicit override outranks every other resolution step.
28
29
  const readSmallModelSettingsOverride = () => {
29
- try {
30
- const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
31
- const settings = JSON.parse(raw);
32
- if (!settings || typeof settings !== 'object') return null;
33
- if (settings.smallModelUseDefault !== false) return null;
34
- const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
35
- return override || null;
36
- } catch {
37
- return null;
38
- }
30
+ const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
31
+ if (settings.smallModelUseDefault !== false) return null;
32
+ const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
33
+ return override || null;
39
34
  };
40
35
 
41
36
  // Rough safety clamp so a huge input never blows the model's context window.
@@ -102,7 +97,7 @@ const readConfiguredSmallModel = (workingDirectory) => {
102
97
  * Generates text with the user's small model, resolved and authenticated
103
98
  * entirely server-side from the OpenCode config and auth store.
104
99
  */
105
- export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
100
+ export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
106
101
  if (typeof prompt !== 'string' || !prompt.trim()) {
107
102
  throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
108
103
  }
@@ -169,6 +164,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
169
164
  auth,
170
165
  catalog,
171
166
  workingDirectory: directory,
167
+ sessionID,
172
168
  providerID: resolved.providerID,
173
169
  modelID: resolved.modelID,
174
170
  prompt: clamped.prompt,