@vantran-se/openchamber-web 1.22.1 → 1.22.2

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 (163) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +4 -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-CV1HNV5l.js} +1 -1
  7. package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
  8. package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
  9. package/dist/assets/FilesView-DDu8zDy5.js +17 -0
  10. package/dist/assets/GitView-1Ixo8z-M.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
  12. package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
  13. package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
  14. package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
  15. package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
  17. package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
  18. package/dist/assets/PlanView-trDaszfq.js +5 -0
  19. package/dist/assets/SettingsView-DStMfBmy.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
  22. package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
  23. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
  24. package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
  25. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
  26. package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
  27. package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
  28. package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
  29. package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
  30. package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
  31. package/dist/assets/index-D42fPvt4.css +1 -0
  32. package/dist/assets/index-DlFbCkEy.js +39 -0
  33. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
  34. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
  35. package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
  36. package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
  37. package/dist/assets/main-B7qTsd_w.js +930 -0
  38. package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
  39. package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
  40. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
  41. package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
  42. package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
  43. package/dist/assets/pl-B2zmkOpw.js +16 -0
  44. package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
  45. package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
  46. package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
  47. package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
  48. package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
  49. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
  50. package/dist/assets/tr-MJmRIzEY.js +7 -0
  51. package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
  52. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
  53. package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
  54. package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
  55. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
  56. package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
  57. package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
  58. package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
  59. package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
  60. package/dist/index.html +5 -5
  61. package/dist/mini-chat.html +4 -4
  62. package/dist/mobile.html +4 -4
  63. package/package.json +8 -4
  64. package/server/index.js +38 -5
  65. package/server/lib/agent-tool/runtime.js +2 -19
  66. package/server/lib/changelog/update-notes.js +82 -0
  67. package/server/lib/changelog/update-notes.test.js +59 -0
  68. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  69. package/server/lib/dev-tunnel/runtime.js +4 -4
  70. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  71. package/server/lib/fs/DOCUMENTATION.md +2 -0
  72. package/server/lib/fs/routes.js +32 -21
  73. package/server/lib/fs/routes.test.js +77 -0
  74. package/server/lib/git/DOCUMENTATION.md +3 -1
  75. package/server/lib/git/routes.js +16 -0
  76. package/server/lib/git/service.js +107 -17
  77. package/server/lib/git/service.test.js +171 -0
  78. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  79. package/server/lib/mcp-reconnect/runtime.js +122 -0
  80. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  81. package/server/lib/message-queue/DOCUMENTATION.md +158 -0
  82. package/server/lib/message-queue/runtime.js +870 -0
  83. package/server/lib/message-queue/runtime.test.js +482 -0
  84. package/server/lib/openchamber-sessions/routes.js +102 -0
  85. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  86. package/server/lib/opencode/DOCUMENTATION.md +13 -5
  87. package/server/lib/opencode/config-paths.test.js +116 -0
  88. package/server/lib/opencode/core-routes.js +1 -0
  89. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  90. package/server/lib/opencode/input-history-scope.js +14 -0
  91. package/server/lib/opencode/managed-plugin-config.js +35 -0
  92. package/server/lib/opencode/openchamber-routes.test.js +2 -2
  93. package/server/lib/opencode/plugins.js +2 -2
  94. package/server/lib/opencode/providers.js +44 -2
  95. package/server/lib/opencode/providers.test.js +131 -4
  96. package/server/lib/opencode/routes.js +4 -4
  97. package/server/lib/opencode/server-startup-runtime.js +1 -1
  98. package/server/lib/opencode/settings-helpers.js +28 -0
  99. package/server/lib/opencode/settings-helpers.test.js +177 -0
  100. package/server/lib/opencode/settings-runtime.js +2 -2
  101. package/server/lib/opencode/settings-runtime.test.js +29 -0
  102. package/server/lib/opencode/shared.js +4 -1
  103. package/server/lib/opencode/shutdown-runtime.js +2 -0
  104. package/server/lib/opencode/skill-routes.js +2 -1
  105. package/server/lib/opencode/snippets.js +1 -2
  106. package/server/lib/package-manager.js +4 -29
  107. package/server/lib/package-manager.test.js +2 -2
  108. package/server/lib/quota/DOCUMENTATION.md +2 -1
  109. package/server/lib/quota/credentials/providers.js +4 -0
  110. package/server/lib/quota/credentials/store.js +1 -1
  111. package/server/lib/quota/credentials/store.test.js +4 -4
  112. package/server/lib/quota/providers/exe-dev.js +75 -0
  113. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  114. package/server/lib/quota/providers/index.js +7 -0
  115. package/server/lib/quota/providers/opencode-go.js +1 -0
  116. package/server/lib/quota/providers/opencode-go.test.js +5 -1
  117. package/server/lib/quota/routes.js +2 -0
  118. package/server/lib/quota/utils/auth.js +1 -1
  119. package/server/lib/relay/DOCUMENTATION.md +1 -1
  120. package/server/lib/relay/host-client.test.js +9 -1
  121. package/server/lib/relay/tunnel-host.js +3 -1
  122. package/server/lib/relay/tunnel-host.test.js +9 -1
  123. package/server/lib/security/request-security.js +21 -6
  124. package/server/lib/security/request-security.test.js +42 -8
  125. package/server/lib/session-assist/runtime.js +1 -0
  126. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  127. package/server/lib/session-goal/create.js +3 -1
  128. package/server/lib/session-goal/runtime.js +77 -11
  129. package/server/lib/session-goal/runtime.test.js +426 -0
  130. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  131. package/server/lib/skills-catalog/install.js +3 -2
  132. package/server/lib/small-model/DOCUMENTATION.md +4 -0
  133. package/server/lib/small-model/call.js +8 -2
  134. package/server/lib/small-model/call.test.js +23 -0
  135. package/server/lib/small-model/index.js +2 -1
  136. package/server/lib/small-model/routes.js +2 -1
  137. package/server/lib/system-prompt/runtime.js +2 -21
  138. package/server/lib/terminal/DOCUMENTATION.md +11 -7
  139. package/server/lib/terminal/runtime.js +180 -26
  140. package/server/lib/terminal/runtime.test.js +553 -26
  141. package/server/lib/terminal/shells.js +17 -0
  142. package/server/lib/terminal/shells.test.js +32 -1
  143. package/server/lib/ui-auth/ui-auth.js +4 -1
  144. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  145. package/server/lib/walkthrough/index.js +1 -0
  146. package/server/lib/walkthrough/routes.test.js +26 -5
  147. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  148. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  149. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  150. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  151. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  152. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  153. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  154. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  155. package/dist/assets/index-L_ncV-6H.css +0 -1
  156. package/dist/assets/index-qES19dlJ.js +0 -7
  157. package/dist/assets/main-CYgSu6dx.js +0 -928
  158. package/dist/assets/pl-slULBF4R.js +0 -15
  159. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  160. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  161. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  162. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  163. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
@@ -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
@@ -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';
@@ -645,7 +646,7 @@ export async function resolveProviderLogin({ auth, workingDirectory, providerID
645
646
  || null;
646
647
  }
647
648
 
648
- export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
649
+ export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
649
650
  const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
650
651
  const providerConfig = readProviderConfig(workingDirectory, providerID);
651
652
  const runtimeProvider = await getRuntimeProvider(providerID);
@@ -795,7 +796,12 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
795
796
  baseURL,
796
797
  // Configured headers last: a gateway that authenticates on its own header
797
798
  // must be able to override the bearer default rather than sit beside it.
798
- headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
799
+ headers: mergeHeadersCaseInsensitive(
800
+ mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
801
+ providerID.startsWith('opencode')
802
+ ? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() }
803
+ : null,
804
+ ),
799
805
  modelID,
800
806
  prompt,
801
807
  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'));
@@ -102,7 +102,7 @@ const readConfiguredSmallModel = (workingDirectory) => {
102
102
  * Generates text with the user's small model, resolved and authenticated
103
103
  * entirely server-side from the OpenCode config and auth store.
104
104
  */
105
- export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
105
+ export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) {
106
106
  if (typeof prompt !== 'string' || !prompt.trim()) {
107
107
  throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
108
108
  }
@@ -169,6 +169,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
169
169
  auth,
170
170
  catalog,
171
171
  workingDirectory: directory,
172
+ sessionID,
172
173
  providerID: resolved.providerID,
173
174
  modelID: resolved.modelID,
174
175
  prompt: clamped.prompt,
@@ -21,13 +21,14 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
21
21
  app.post('/api/small-model/generate', async (req, res) => {
22
22
  try {
23
23
  const { generateSmallModelText } = await getSmallModelService();
24
- const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
24
+ const { prompt, system, maxOutputTokens, model, directory, sessionID, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
25
25
  const result = await generateSmallModelText({
26
26
  prompt,
27
27
  system,
28
28
  maxOutputTokens,
29
29
  model,
30
30
  directory,
31
+ sessionID,
31
32
  preferredProviderID,
32
33
  preferredModelID,
33
34
  restrictToPreferredProvider: restrictToPreferredProvider === true,
@@ -1,5 +1,5 @@
1
- import { parse as parseJsonc } from 'jsonc-parser';
2
1
  import { pathToFileURL } from 'node:url';
2
+ import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
3
3
 
4
4
  const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named';
5
5
  const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.';
@@ -33,25 +33,6 @@ export const OpenChamberSystemPromptPlugin = async () => ({
33
33
  })
34
34
  `;
35
35
 
36
- const mergePluginConfig = (rawConfig, pluginUrl) => {
37
- const errors = [];
38
- const parsed = typeof rawConfig === 'string' && rawConfig.trim()
39
- ? parseJsonc(rawConfig, errors, { allowTrailingComma: true })
40
- : {};
41
- if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
42
- throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its system prompt optimizer');
43
- }
44
- if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
45
- throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its system prompt optimizer');
46
- }
47
- const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
48
- parsed.plugin = [
49
- ...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
50
- pluginUrl,
51
- ];
52
- return JSON.stringify(parsed);
53
- };
54
-
55
36
  export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
56
37
  const pluginDirectory = path.join(dataDir, 'system-prompt');
57
38
  const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js');
@@ -60,7 +41,7 @@ export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
60
41
  await fsPromises.mkdir(pluginDirectory, { recursive: true });
61
42
  await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
62
43
  return {
63
- OPENCODE_CONFIG_CONTENT: mergePluginConfig(rawConfig, pathToFileURL(pluginPath).href),
44
+ OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'system prompt optimizer'),
64
45
  };
65
46
  };
66
47