@openchambery/web 1.19.20 → 2.0.0-beta.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 (189) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
  52. package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
  53. package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
  168. package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
  170. package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
  171. package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CHpibtno.js +0 -7
  174. package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-BoRXxmB3.js +0 -2
  178. package/dist/assets/main-DCDcdV5o.js +0 -169
  179. package/dist/assets/miniChat-12fxk5KR.js +0 -2
  180. package/dist/assets/mobile-CkZem9Ad.js +0 -2
  181. package/dist/assets/pl-DW2XJGC4.js +0 -15
  182. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  183. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  184. package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
  185. package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
  186. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  187. package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
  188. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  189. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -2,12 +2,20 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
5
8
 
6
- vi.mock('@opencode-ai/sdk/v2', () => ({
7
- createOpencodeClient: vi.fn(),
9
+ vi.mock('../opencode/v2-client.js', () => ({
10
+ makeOpenCodeV2Client: vi.fn(),
8
11
  }));
9
12
 
10
- const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
13
+ vi.mock('../session-goal/objectives.js', () => ({
14
+ writeObjective: vi.fn(async () => ({ content: 'ok' })),
15
+ }));
16
+
17
+ const { makeOpenCodeV2Client } = await import('../opencode/v2-client.js');
18
+ const { writeObjective } = await import('../session-goal/objectives.js');
11
19
  const { createScheduledTaskRunHistoryStore } = await import('./run-history-store.js');
12
20
  const {
13
21
  computeNextRunAt,
@@ -310,10 +318,26 @@ describe('scheduled-tasks project sync isolation', () => {
310
318
  });
311
319
  });
312
320
 
321
+ describe('scheduled-tasks v2 cutover residuals', () => {
322
+ it('runtime source has no 1.x SDK, HeyAPI wrappers, or retired session APIs', () => {
323
+ const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'runtime.js'), 'utf8');
324
+ expect(source).not.toContain('@opencode-ai/sdk');
325
+ expect(source).not.toContain('createOpencodeClient');
326
+ expect(source).not.toContain('prompt_async');
327
+ expect(source).not.toContain('session.abort');
328
+ expect(source).not.toContain('session.status({');
329
+ expect(source).not.toContain('session.messages');
330
+ expect(source).not.toContain('session.update');
331
+ expect(source).not.toContain('time: { archived');
332
+ expect(source).not.toContain('result?.error');
333
+ });
334
+ });
335
+
313
336
  describe('scheduled-tasks run history and session lifecycle', () => {
314
337
  beforeEach(() => {
315
338
  vi.restoreAllMocks();
316
- createOpencodeClient.mockReset();
339
+ makeOpenCodeV2Client.mockReset();
340
+ writeObjective.mockClear();
317
341
  });
318
342
 
319
343
  const createHistoryStore = () => {
@@ -341,105 +365,84 @@ describe('scheduled-tasks run history and session lifecycle', () => {
341
365
  };
342
366
 
343
367
  const createSuccessfulClient = ({
344
- updateResult,
345
368
  sessionID = 'ses_1',
346
369
  /** Override session settlement polls after prompt/command admission. */
347
370
  settlement = 'success',
371
+ activeImpl,
372
+ messageListImpl,
348
373
  } = {}) => {
349
- const create = vi.fn(async () => ({ data: { id: sessionID } }));
350
- const update = vi.fn(async () => updateResult ?? { data: { id: sessionID } });
351
- const command = vi.fn(async () => ({ data: {} }));
352
- const abort = vi.fn(async () => ({ data: true }));
374
+ const create = vi.fn(async () => ({ id: sessionID }));
375
+ const prompt = vi.fn(async () => ({ id: 'inbox_1', type: 'user' }));
376
+ const command = vi.fn(async () => ({ id: 'inbox_1', type: 'user' }));
377
+ const interrupt = vi.fn(async () => undefined);
353
378
  const list = vi.fn(async () => ({ data: [] }));
354
- // Default settlement: idle + completed assistant so history records the real
355
- // turn outcome (not prompt_async admission alone).
379
+ // Default settlement: idle (absent from active map) + completed assistant
380
+ // so history records the real turn outcome (not prompt admission alone).
356
381
  let poll = 0;
357
- const status = vi.fn(async () => {
382
+ const active = vi.fn(async () => {
383
+ if (typeof activeImpl === 'function') {
384
+ return activeImpl();
385
+ }
358
386
  poll += 1;
359
387
  if (settlement === 'busy-then-success' && poll < 2) {
360
- return { data: { [sessionID]: { type: 'busy' } } };
388
+ return { [sessionID]: { type: 'running' } };
361
389
  }
362
- return { data: { [sessionID]: { type: 'idle' } } };
390
+ return {};
363
391
  });
364
- const messages = vi.fn(async () => {
392
+ const messageList = vi.fn(async () => {
393
+ if (typeof messageListImpl === 'function') {
394
+ return messageListImpl();
395
+ }
365
396
  if (settlement === 'assistant-error') {
366
397
  return {
367
398
  data: [{
368
- info: {
369
- id: 'msg_err',
370
- role: 'assistant',
371
- error: { name: 'ProviderError', message: 'upstream failed' },
372
- },
399
+ id: 'msg_err',
400
+ type: 'assistant',
401
+ error: { name: 'ProviderError', message: 'upstream failed' },
373
402
  }],
374
403
  };
375
404
  }
376
- if (settlement === 'busy-then-success') {
377
- if (poll < 2) {
378
- return { data: [{ info: { id: 'msg_user', role: 'user' } }] };
379
- }
405
+ if (settlement === 'busy-then-success' && poll < 2) {
406
+ return { data: [{ id: 'msg_user', type: 'user' }] };
380
407
  }
381
408
  return {
382
409
  data: [{
383
- info: {
384
- id: 'msg_ok',
385
- role: 'assistant',
386
- time: { completed: Date.now() },
387
- },
410
+ id: 'msg_ok',
411
+ type: 'assistant',
412
+ time: { created: Date.now(), completed: Date.now() },
413
+ finish: 'stop',
388
414
  }],
389
415
  };
390
416
  });
391
- const get = vi.fn(async () => {
392
- if (settlement === 'goal-complete') {
393
- return {
394
- data: {
395
- id: sessionID,
396
- metadata: {
397
- openchamber: {
398
- goal: { id: 'g1', status: 'complete', objective: 'done' },
399
- },
400
- },
401
- },
402
- };
403
- }
404
- if (settlement === 'goal-blocked') {
405
- return {
406
- data: {
407
- id: sessionID,
408
- metadata: {
409
- openchamber: {
410
- goal: { id: 'g1', status: 'blocked', objective: 'x', note: 'stuck' },
411
- },
412
- },
413
- },
414
- };
415
- }
416
- return { data: { id: sessionID, metadata: {} } };
417
- });
418
- createOpencodeClient.mockReturnValue({
419
- session: { create, update, command, abort, status, messages, get },
417
+ const get = vi.fn(async () => ({
418
+ id: sessionID,
419
+ location: { directory: '/tmp/project-1' },
420
+ }));
421
+ makeOpenCodeV2Client.mockReturnValue({
422
+ session: { create, prompt, command, interrupt, active, get },
423
+ message: { list: messageList },
420
424
  command: { list },
421
425
  });
422
- return { create, update, command, abort, list, status, messages, get };
426
+ return { create, prompt, command, interrupt, list, active, messageList, get };
423
427
  };
424
428
 
425
- const settledSessionApis = (sessionID = 'ses_1') => ({
426
- status: vi.fn(async () => ({ data: { [sessionID]: { type: 'idle' } } })),
427
- messages: vi.fn(async () => ({
428
- data: [{ info: { id: 'msg_ok', role: 'assistant', time: { completed: Date.now() } } }],
429
- })),
430
- get: vi.fn(async () => ({ data: { id: sessionID, metadata: {} } })),
429
+ const completedAssistant = (id = 'msg_ok') => ({
430
+ id,
431
+ type: 'assistant',
432
+ time: { created: 1_000, completed: 1_100 },
433
+ finish: 'stop',
434
+ });
435
+
436
+ const erroredAssistant = (id = 'msg_err') => ({
437
+ id,
438
+ type: 'assistant',
439
+ time: { created: 2_000, completed: 2_100 },
440
+ error: { name: 'ProviderError', message: 'upstream failed' },
431
441
  });
432
442
 
433
- it('creates session with Scheduled title and scheduledTask metadata, attaches history, archives before prompt', async () => {
443
+ it('creates session with Scheduled title and location, attaches history, then prompts via session.prompt', async () => {
434
444
  const history = createHistoryStore();
435
445
  const client = createSuccessfulClient();
436
- const fetchMock = vi.fn(async (url, init) => {
437
- if (String(url).includes('prompt_async')) {
438
- return { ok: true, text: async () => '' };
439
- }
440
- throw new Error(`unexpected fetch ${url} ${init?.method}`);
441
- });
442
- vi.stubGlobal('fetch', fetchMock);
443
446
 
444
447
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
445
448
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -463,49 +466,35 @@ describe('scheduled-tasks run history and session lifecycle', () => {
463
466
  });
464
467
  expect(typeof started.id).toBe('string');
465
468
 
469
+ expect(makeOpenCodeV2Client).toHaveBeenCalledWith({
470
+ baseUrl: 'http://127.0.0.1:4096',
471
+ authHeaders: {},
472
+ });
466
473
  expect(client.create).toHaveBeenCalledWith(expect.objectContaining({
467
- directory: '/tmp/project-1',
474
+ location: { directory: '/tmp/project-1' },
468
475
  title: expect.stringMatching(/^\[Scheduled\] Task /),
469
- metadata: {
470
- openchamber: {
471
- scheduledTask: {
472
- projectID: 'project-1',
473
- taskID: 'task-1',
474
- runID: started.id,
475
- name: 'Task',
476
- },
477
- },
478
- },
476
+ model: { id: 'gpt-4.1', providerID: 'openai' },
479
477
  }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
478
+ expect(client.create.mock.calls[0][0].metadata).toBeUndefined();
479
+ expect(client.create.mock.calls[0][0].directory).toBeUndefined();
480
480
  expect(history.attachSession).toHaveBeenCalledWith(started.id, 'ses_1');
481
- expect(client.update).toHaveBeenCalledWith(expect.objectContaining({
481
+ expect(client.prompt).toHaveBeenCalledWith({
482
482
  sessionID: 'ses_1',
483
- directory: '/tmp/project-1',
484
- time: { archived: expect.any(Number) },
485
- }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
486
- expect(client.update.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0]);
483
+ text: 'run',
484
+ }, expect.objectContaining({ signal: expect.any(AbortSignal) }));
487
485
  expect(history.finishRun).toHaveBeenCalledWith(started.id, expect.objectContaining({
488
486
  status: 'success',
489
487
  sessionId: 'ses_1',
490
488
  }));
491
- // Must wait for session settlement, not finish on prompt_async admission alone.
492
- expect(client.status).toHaveBeenCalled();
493
- expect(client.messages).toHaveBeenCalled();
494
- expect(client.status.mock.invocationCallOrder[0]).toBeGreaterThan(fetchMock.mock.invocationCallOrder[0]);
495
-
496
- vi.unstubAllGlobals();
489
+ // Must wait for session settlement, not finish on prompt admission alone.
490
+ expect(client.active).toHaveBeenCalled();
491
+ expect(client.messageList).toHaveBeenCalled();
492
+ expect(client.active.mock.invocationCallOrder[0]).toBeGreaterThan(client.prompt.mock.invocationCallOrder[0]);
497
493
  });
498
494
 
499
495
  it('records error outcome and real duration when the assistant turn fails after admission', async () => {
500
496
  const history = createHistoryStore();
501
497
  const client = createSuccessfulClient({ settlement: 'assistant-error' });
502
- const fetchMock = vi.fn(async (url) => {
503
- if (String(url).includes('prompt_async')) {
504
- return { ok: true, text: async () => '' };
505
- }
506
- throw new Error(`unexpected fetch ${url}`);
507
- });
508
- vi.stubGlobal('fetch', fetchMock);
509
498
 
510
499
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
511
500
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -528,22 +517,13 @@ describe('scheduled-tasks run history and session lifecycle', () => {
528
517
  durationMs: expect.any(Number),
529
518
  }),
530
519
  );
531
- expect(client.messages).toHaveBeenCalled();
532
-
533
- vi.unstubAllGlobals();
520
+ expect(client.messageList).toHaveBeenCalled();
534
521
  });
535
522
 
536
- it('keeps the run open while the session is busy and only finalizes after idle settlement', async () => {
523
+ it('keeps the run open while the session is in the active map and only finalizes after idle settlement', async () => {
537
524
  vi.useFakeTimers();
538
525
  const history = createHistoryStore();
539
526
  createSuccessfulClient({ settlement: 'busy-then-success' });
540
- const fetchMock = vi.fn(async (url) => {
541
- if (String(url).includes('prompt_async')) {
542
- return { ok: true, text: async () => '' };
543
- }
544
- throw new Error(`unexpected fetch ${url}`);
545
- });
546
- vi.stubGlobal('fetch', fetchMock);
547
527
 
548
528
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
549
529
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -555,7 +535,7 @@ describe('scheduled-tasks run history and session lifecycle', () => {
555
535
  await runtime.syncProject('project-1');
556
536
 
557
537
  const runPromise = runtime.runNow('project-1', 'task-1');
558
- // First settlement poll sees busy and sleeps — history must still be open.
538
+ // First settlement poll sees membership and sleeps — history must still be open.
559
539
  await vi.advanceTimersByTimeAsync(0);
560
540
  expect(history.finishRun).not.toHaveBeenCalled();
561
541
 
@@ -568,17 +548,15 @@ describe('scheduled-tasks run history and session lifecycle', () => {
568
548
  expect.objectContaining({ status: 'success', sessionId: 'ses_1' }),
569
549
  );
570
550
 
571
- vi.unstubAllGlobals();
572
551
  vi.useRealTimers();
573
552
  });
574
553
 
575
- it('does not prompt when archive fails and finalizes the run as error', async () => {
554
+ it('does not prompt when attachSession fails and finalizes the run as error', async () => {
576
555
  const history = createHistoryStore();
577
- createSuccessfulClient({
578
- updateResult: { error: { status: 500, message: 'archive failed' } },
556
+ const client = createSuccessfulClient();
557
+ history.attachSession.mockImplementation(() => {
558
+ throw new Error('attach exploded');
579
559
  });
580
- const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
581
- vi.stubGlobal('fetch', fetchMock);
582
560
 
583
561
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
584
562
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -592,28 +570,22 @@ describe('scheduled-tasks run history and session lifecycle', () => {
592
570
 
593
571
  expect(result.ok).toBe(false);
594
572
  expect(result.status).toBe('error');
595
- expect(fetchMock).not.toHaveBeenCalled();
573
+ expect(result.error).toMatch(/attachSession failed/);
574
+ expect(client.prompt).not.toHaveBeenCalled();
596
575
  expect(history.finishRun).toHaveBeenCalledWith(
597
576
  expect.any(String),
598
577
  expect.objectContaining({ status: 'error' }),
599
578
  );
600
-
601
- vi.unstubAllGlobals();
602
579
  });
603
580
 
604
- it('retries archive once on 404 then prompts on success', async () => {
581
+ it('does not treat session.active failure as idle success', async () => {
605
582
  vi.useFakeTimers();
606
583
  const history = createHistoryStore();
607
- const create = vi.fn(async () => ({ data: { id: 'ses_404' } }));
608
- const update = vi.fn()
609
- .mockResolvedValueOnce({ error: { status: 404 } })
610
- .mockResolvedValueOnce({ data: { id: 'ses_404' } });
611
- createOpencodeClient.mockReturnValue({
612
- session: { create, update, command: vi.fn(), ...settledSessionApis('ses_404') },
613
- command: { list: vi.fn(async () => ({ data: [] })) },
584
+ createSuccessfulClient({
585
+ activeImpl: async () => {
586
+ throw new Error('active unavailable');
587
+ },
614
588
  });
615
- const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
616
- vi.stubGlobal('fetch', fetchMock);
617
589
 
618
590
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
619
591
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -621,63 +593,67 @@ describe('scheduled-tasks run history and session lifecycle', () => {
621
593
  const runtime = createRuntime(updateScheduledTaskState, {
622
594
  runHistoryStore: history,
623
595
  waitForOpenCodeReady: vi.fn(async () => {}),
596
+ maxRunDurationMs: 2_500,
624
597
  });
625
598
  await runtime.syncProject('project-1');
626
599
 
627
600
  const runPromise = runtime.runNow('project-1', 'task-1');
628
- await vi.advanceTimersByTimeAsync(250);
601
+ await vi.advanceTimersByTimeAsync(1_000);
602
+ expect(history.finishRun).not.toHaveBeenCalled();
603
+
604
+ await vi.advanceTimersByTimeAsync(1_500);
629
605
  const result = await runPromise;
630
606
 
631
- expect(result.ok).toBe(true);
632
- expect(update).toHaveBeenCalledTimes(2);
633
- expect(fetchMock).toHaveBeenCalledTimes(1);
607
+ expect(result.ok).toBe(false);
608
+ expect(result.status).toBe('error');
609
+ expect(result.error).toBe('schedule run timed out');
610
+ expect(history.finishRun).toHaveBeenCalledWith(
611
+ expect.any(String),
612
+ expect.objectContaining({
613
+ status: 'error',
614
+ error: 'schedule run timed out',
615
+ }),
616
+ );
634
617
 
635
- vi.unstubAllGlobals();
636
618
  vi.useRealTimers();
637
619
  });
638
620
 
639
- it('watchdog timeout aborts in-flight work, calls session.abort, and does not continue later stages', async () => {
621
+ it('watchdog timeout interrupts in-flight work and does not continue later stages', async () => {
640
622
  vi.useFakeTimers();
641
623
  const history = createHistoryStore();
642
624
  let resolvePrompt;
643
625
  let promptStarted = false;
644
626
  let promptCompleted = false;
645
- const create = vi.fn(async () => ({ data: { id: 'ses_timeout' } }));
646
- const update = vi.fn(async () => ({ data: { id: 'ses_timeout' } }));
647
- const abort = vi.fn(async () => ({ data: true }));
627
+ const create = vi.fn(async () => ({ id: 'ses_timeout' }));
628
+ const interrupt = vi.fn(async () => undefined);
648
629
  const command = vi.fn(async () => {
649
630
  throw new Error('command must not run after hanging prompt path');
650
631
  });
651
632
  const list = vi.fn(async () => ({ data: [] }));
652
- createOpencodeClient.mockReturnValue({
653
- session: { create, update, command, abort },
654
- command: { list },
655
- });
656
-
657
- const fetchMock = vi.fn((url, init) => {
658
- if (String(url).includes('prompt_async')) {
659
- promptStarted = true;
660
- return new Promise((resolve, reject) => {
661
- resolvePrompt = () => {
662
- promptCompleted = true;
663
- resolve({ ok: true, text: async () => '' });
633
+ const prompt = vi.fn((_params, options) => {
634
+ promptStarted = true;
635
+ return new Promise((resolve, reject) => {
636
+ resolvePrompt = () => {
637
+ promptCompleted = true;
638
+ resolve({ id: 'inbox_1', type: 'user' });
639
+ };
640
+ const signal = options?.signal;
641
+ if (signal) {
642
+ const onAbort = () => {
643
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
664
644
  };
665
- const signal = init?.signal;
666
- if (signal) {
667
- const onAbort = () => {
668
- reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
669
- };
670
- if (signal.aborted) {
671
- onAbort();
672
- return;
673
- }
674
- signal.addEventListener('abort', onAbort, { once: true });
645
+ if (signal.aborted) {
646
+ onAbort();
647
+ return;
675
648
  }
676
- });
677
- }
678
- throw new Error(`unexpected fetch ${url}`);
649
+ signal.addEventListener('abort', onAbort, { once: true });
650
+ }
651
+ });
652
+ });
653
+ makeOpenCodeV2Client.mockReturnValue({
654
+ session: { create, prompt, command, interrupt },
655
+ command: { list },
679
656
  });
680
- vi.stubGlobal('fetch', fetchMock);
681
657
 
682
658
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
683
659
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -706,9 +682,9 @@ describe('scheduled-tasks run history and session lifecycle', () => {
706
682
  expect(result.ok).toBe(false);
707
683
  expect(result.status).toBe('error');
708
684
  expect(result.error).toBe('schedule run timed out');
709
- expect(abort).toHaveBeenCalledWith({
685
+ expect(interrupt).toHaveBeenCalledWith({
710
686
  sessionID: 'ses_timeout',
711
- directory: '/tmp/project-1',
687
+ continue: false,
712
688
  });
713
689
  expect(promptCompleted).toBe(false);
714
690
  expect(command).not.toHaveBeenCalled();
@@ -731,18 +707,16 @@ describe('scheduled-tasks run history and session lifecycle', () => {
731
707
  } finally {
732
708
  process.off('unhandledRejection', onUnhandled);
733
709
  resolvePrompt?.();
734
- vi.unstubAllGlobals();
735
710
  vi.useRealTimers();
736
711
  }
737
712
  });
738
713
 
739
- it('watchdog timeout during hanging command aborts session and never prompt_async', async () => {
714
+ it('watchdog timeout during hanging command interrupts session and never session.prompt', async () => {
740
715
  vi.useFakeTimers();
741
716
  const history = createHistoryStore();
742
717
  let commandReached = false;
743
- const create = vi.fn(async () => ({ data: { id: 'ses_cmd' } }));
744
- const update = vi.fn(async () => ({ data: { id: 'ses_cmd' } }));
745
- const abort = vi.fn(async () => ({ data: true }));
718
+ const create = vi.fn(async () => ({ id: 'ses_cmd' }));
719
+ const interrupt = vi.fn(async () => undefined);
746
720
  const command = vi.fn((_params, options) => {
747
721
  commandReached = true;
748
722
  return new Promise((_resolve, reject) => {
@@ -761,14 +735,13 @@ describe('scheduled-tasks run history and session lifecycle', () => {
761
735
  });
762
736
  });
763
737
  const list = vi.fn(async () => ({ data: [{ name: 'review' }] }));
764
- createOpencodeClient.mockReturnValue({
765
- session: { create, update, command, abort },
766
- command: { list },
738
+ const prompt = vi.fn(async () => {
739
+ throw new Error('session.prompt must not run when command is selected');
767
740
  });
768
- const fetchMock = vi.fn(async () => {
769
- throw new Error('prompt_async must not run when command is selected');
741
+ makeOpenCodeV2Client.mockReturnValue({
742
+ session: { create, prompt, command, interrupt },
743
+ command: { list },
770
744
  });
771
- vi.stubGlobal('fetch', fetchMock);
772
745
 
773
746
  const taskWithCommand = {
774
747
  ...scheduledTask,
@@ -808,47 +781,41 @@ describe('scheduled-tasks run history and session lifecycle', () => {
808
781
  status: 'error',
809
782
  error: 'schedule run timed out',
810
783
  });
811
- expect(abort).toHaveBeenCalledWith({
784
+ expect(interrupt).toHaveBeenCalledWith({
812
785
  sessionID: 'ses_cmd',
813
- directory: '/tmp/project-1',
786
+ continue: false,
814
787
  });
815
- expect(fetchMock).not.toHaveBeenCalled();
788
+ expect(command).toHaveBeenCalledWith(expect.objectContaining({
789
+ sessionID: 'ses_cmd',
790
+ command: 'review',
791
+ arguments: 'src',
792
+ model: { id: 'gpt-4.1', providerID: 'openai' },
793
+ }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
794
+ expect(list).toHaveBeenCalledWith({
795
+ location: { directory: '/tmp/project-1' },
796
+ }, expect.objectContaining({ signal: expect.any(AbortSignal) }));
797
+ expect(prompt).not.toHaveBeenCalled();
816
798
 
817
- vi.unstubAllGlobals();
818
799
  vi.useRealTimers();
819
800
  });
820
801
 
821
- it('watchdog timeout during hanging small-model distill returns without waiting and blocks later goal stages', async () => {
822
- vi.useFakeTimers();
802
+ it('goalEnabled without persist refuses before OpenCode ready / distill / session create (no side effects)', async () => {
823
803
  const history = createHistoryStore();
824
- let resolveDistill;
825
- let distillStarted = false;
826
- const create = vi.fn(async () => ({ data: { id: 'ses_distill' } }));
827
- const update = vi.fn(async () => ({ data: { id: 'ses_distill' } }));
828
- const abort = vi.fn(async () => ({ data: true }));
829
- const command = vi.fn(async () => {
830
- throw new Error('command must not run after hanging distill');
804
+ const create = vi.fn(async () => ({ id: 'ses_distill' }));
805
+ const interrupt = vi.fn(async () => undefined);
806
+ const prompt = vi.fn(async () => {
807
+ throw new Error('session.prompt must not run without goal persist');
831
808
  });
832
- const list = vi.fn(async () => ({ data: [] }));
833
- createOpencodeClient.mockReturnValue({
834
- session: { create, update, command, abort },
835
- command: { list },
836
- });
837
-
838
- const fetchMock = vi.fn(async (url, init) => {
839
- throw new Error(`unexpected fetch ${url} ${init?.method}`);
809
+ makeOpenCodeV2Client.mockReturnValue({
810
+ session: { create, prompt, command: vi.fn(), interrupt },
811
+ command: { list: vi.fn(async () => ({ data: [] })) },
840
812
  });
841
- vi.stubGlobal('fetch', fetchMock);
842
813
 
843
- const generateSmallModelText = vi.fn(() => {
844
- distillStarted = true;
845
- return new Promise((resolve) => {
846
- resolveDistill = () => resolve({ text: 'distilled criteria' });
847
- });
848
- });
814
+ const generateSmallModelText = vi.fn(async () => ({ text: 'should not distill' }));
849
815
  const getSmallModelService = vi.fn(async () => ({ generateSmallModelText }));
816
+ const waitForOpenCodeReady = vi.fn(async () => {});
850
817
 
851
- // Oversized prompt forces small-model distill (threshold 5000).
818
+ // Oversized prompt would force distill if goal were admitted — it must not.
852
819
  const largePrompt = `finish the migration ${'x'.repeat(5100)}`;
853
820
  const goalTask = {
854
821
  ...scheduledTask,
@@ -872,78 +839,161 @@ describe('scheduled-tasks run history and session lifecycle', () => {
872
839
  buildOpenCodeUrl: vi.fn(() => 'http://127.0.0.1:4096'),
873
840
  getOpenCodeAuthHeaders: vi.fn(() => ({})),
874
841
  getSmallModelService,
875
- waitForOpenCodeReady: vi.fn(async () => {}),
842
+ waitForOpenCodeReady,
876
843
  logger: { info: vi.fn(), warn: vi.fn() },
877
844
  runHistoryStore: history,
878
- maxRunDurationMs: 1_000,
845
+ // intentionally no persistSessionGoal
879
846
  });
880
847
  await runtime.syncProject('project-1');
848
+ const result = await runtime.runNow('project-1', 'task-1');
881
849
 
882
- const unhandled = [];
883
- const onUnhandled = (reason) => {
884
- unhandled.push(reason);
850
+ expect(result.ok).toBe(false);
851
+ expect(result.error).toMatch(/session goal persist is not configured/);
852
+ expect(waitForOpenCodeReady).not.toHaveBeenCalled();
853
+ expect(getSmallModelService).not.toHaveBeenCalled();
854
+ expect(generateSmallModelText).not.toHaveBeenCalled();
855
+ expect(create).not.toHaveBeenCalled();
856
+ expect(prompt).not.toHaveBeenCalled();
857
+ expect(writeObjective).not.toHaveBeenCalled();
858
+ expect(history.attachSession).not.toHaveBeenCalled();
859
+ expect(history.finishRun).toHaveBeenCalledWith(
860
+ expect.any(String),
861
+ expect.objectContaining({
862
+ status: 'error',
863
+ error: expect.stringMatching(/session goal persist is not configured/),
864
+ }),
865
+ );
866
+ });
867
+
868
+ it('goalEnabled without persist refuses before session create / objective write', async () => {
869
+ const history = createHistoryStore();
870
+ const client = createSuccessfulClient({ sessionID: 'ses_goal' });
871
+
872
+ const goalTask = {
873
+ ...scheduledTask,
874
+ execution: {
875
+ ...scheduledTask.execution,
876
+ prompt: 'finish the migration',
877
+ goalEnabled: true,
878
+ goalTokenBudget: 12_000,
879
+ },
885
880
  };
886
- process.on('unhandledRejection', onUnhandled);
881
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
882
+ task: { ...goalTask, state: { ...goalTask.state, ...state } },
883
+ }));
884
+ const runtime = createScheduledTasksRuntime({
885
+ projectConfigRuntime: {
886
+ listScheduledTasks: vi.fn(async () => [goalTask]),
887
+ updateScheduledTaskState,
888
+ upsertScheduledTask: vi.fn(),
889
+ },
890
+ listProjects: vi.fn(async () => [{ id: 'project-1', path: '/tmp/project-1' }]),
891
+ buildOpenCodeUrl: vi.fn(() => 'http://127.0.0.1:4096'),
892
+ getOpenCodeAuthHeaders: vi.fn(() => ({})),
893
+ waitForOpenCodeReady: vi.fn(async () => {}),
894
+ logger: { info: vi.fn(), warn: vi.fn() },
895
+ runHistoryStore: history,
896
+ // intentionally no persistSessionGoal
897
+ });
898
+ await runtime.syncProject('project-1');
899
+ const result = await runtime.runNow('project-1', 'task-1');
887
900
 
888
- try {
889
- const runPromise = runtime.runNow('project-1', 'task-1');
890
- await vi.advanceTimersByTimeAsync(0);
891
- expect(distillStarted).toBe(true);
892
- expect(generateSmallModelText).toHaveBeenCalled();
901
+ // Missing persist is an explicit run failure before any side effects.
902
+ expect(result.ok).toBe(false);
903
+ expect(result.status).toBe('error');
904
+ expect(result.error).toMatch(/session goal persist is not configured/);
905
+ expect(client.create).not.toHaveBeenCalled();
906
+ expect(client.prompt).not.toHaveBeenCalled();
907
+ expect(writeObjective).not.toHaveBeenCalled();
908
+ expect(history.finishRun).toHaveBeenCalledWith(
909
+ expect.any(String),
910
+ expect.objectContaining({
911
+ status: 'error',
912
+ error: expect.stringMatching(/session goal persist is not configured/),
913
+ }),
914
+ );
915
+ // Task config is preserved (goalEnabled stays on the task); only this run fails.
916
+ const finalPatch = updateScheduledTaskState.mock.calls.at(-1)[2];
917
+ expect(finalPatch.lastStatus).toBe('error');
918
+ expect(Object.hasOwn(finalPatch, 'lastSessionId')).toBe(false);
919
+ });
893
920
 
894
- // Timeout must return while distill is still gated (non-cancellable).
895
- await vi.advanceTimersByTimeAsync(1_000);
896
- const result = await runPromise;
921
+ it('goalEnabled with persist writes objective + active goal then prompts', async () => {
922
+ const history = createHistoryStore();
923
+ const client = createSuccessfulClient({ sessionID: 'ses_goal_ok' });
924
+ const persistSessionGoal = vi.fn(async (_sessionId, _directory, goal) => ({
925
+ openchamber: { goal },
926
+ }));
927
+ const onGoalPersisted = vi.fn();
897
928
 
898
- expect(result.ok).toBe(false);
899
- expect(result.status).toBe('error');
900
- expect(result.error).toBe('schedule run timed out');
901
- expect(abort).toHaveBeenCalledWith({
902
- sessionID: 'ses_distill',
903
- directory: '/tmp/project-1',
904
- });
905
- expect(fetchMock).not.toHaveBeenCalled();
906
- expect(command).not.toHaveBeenCalled();
929
+ const goalTask = {
930
+ ...scheduledTask,
931
+ execution: {
932
+ ...scheduledTask.execution,
933
+ prompt: 'finish the migration',
934
+ goalEnabled: true,
935
+ goalTokenBudget: 12_000,
936
+ },
937
+ };
938
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
939
+ task: { ...goalTask, state: { ...goalTask.state, ...state } },
940
+ }));
941
+ const runtime = createScheduledTasksRuntime({
942
+ projectConfigRuntime: {
943
+ listScheduledTasks: vi.fn(async () => [goalTask]),
944
+ updateScheduledTaskState,
945
+ upsertScheduledTask: vi.fn(),
946
+ },
947
+ listProjects: vi.fn(async () => [{ id: 'project-1', path: '/tmp/project-1' }]),
948
+ buildOpenCodeUrl: vi.fn(() => 'http://127.0.0.1:4096'),
949
+ getOpenCodeAuthHeaders: vi.fn(() => ({})),
950
+ waitForOpenCodeReady: vi.fn(async () => {}),
951
+ logger: { info: vi.fn(), warn: vi.fn() },
952
+ runHistoryStore: history,
953
+ persistSessionGoal,
954
+ onGoalPersisted,
955
+ });
956
+ await runtime.syncProject('project-1');
957
+ const result = await runtime.runNow('project-1', 'task-1');
907
958
 
908
- const started = history.startRun.mock.calls[0][0];
909
- expect(history.attachSession).toHaveBeenCalledWith(started.id, 'ses_distill');
910
- expect(history.finishRun).toHaveBeenCalledWith(
911
- started.id,
912
- expect.objectContaining({
913
- status: 'error',
914
- error: 'schedule run timed out',
959
+ expect(result.ok).toBe(true);
960
+ expect(client.create).toHaveBeenCalled();
961
+ expect(writeObjective).toHaveBeenCalledWith('ses_goal_ok', 'finish the migration');
962
+ expect(persistSessionGoal).toHaveBeenCalledTimes(1);
963
+ expect(persistSessionGoal.mock.calls[0][0]).toBe('ses_goal_ok');
964
+ expect(persistSessionGoal.mock.calls[0][1]).toBe('/tmp/project-1');
965
+ expect(persistSessionGoal.mock.calls[0][2]).toMatchObject({
966
+ status: 'active',
967
+ objectiveFile: true,
968
+ objective: '',
969
+ tokenBudget: 12_000,
970
+ tokensUsed: 0,
971
+ turnsUsed: 0,
972
+ blockedStreak: 0,
973
+ note: '',
974
+ statusReason: '',
975
+ lastAccountedMessageID: '',
976
+ });
977
+ expect(typeof persistSessionGoal.mock.calls[0][2].id).toBe('string');
978
+ expect(persistSessionGoal.mock.calls[0][2].id.length).toBeGreaterThan(0);
979
+ expect(onGoalPersisted).toHaveBeenCalledWith(expect.objectContaining({
980
+ sessionID: 'ses_goal_ok',
981
+ directory: '/tmp/project-1',
982
+ metadata: expect.objectContaining({
983
+ openchamber: expect.objectContaining({
984
+ goal: expect.objectContaining({ status: 'active' }),
915
985
  }),
916
- );
917
-
918
- // Release distill after finalize; abort gate must block goal PATCH / prompt.
919
- resolveDistill?.();
920
- await vi.advanceTimersByTimeAsync(0);
921
- await Promise.resolve();
922
- await Promise.resolve();
923
- expect(fetchMock).not.toHaveBeenCalled();
924
- expect(unhandled).toEqual([]);
925
- } finally {
926
- process.off('unhandledRejection', onUnhandled);
927
- resolveDistill?.();
928
- vi.unstubAllGlobals();
929
- vi.useRealTimers();
930
- }
986
+ }),
987
+ }));
988
+ expect(client.prompt).toHaveBeenCalled();
931
989
  });
932
990
 
933
- it('goalEnabled PATCH preserves scheduledTask marker alongside goal', async () => {
991
+ it('goalEnabled persist failure aborts before prompt', async () => {
934
992
  const history = createHistoryStore();
935
- // Goal runs settle on terminal goal metadata, not the first idle turn.
936
- const client = createSuccessfulClient({ sessionID: 'ses_goal', settlement: 'goal-complete' });
937
- const fetchMock = vi.fn(async (url, init) => {
938
- if (String(url).includes('/session/ses_goal') && init?.method === 'PATCH') {
939
- return { ok: true, text: async () => '' };
940
- }
941
- if (String(url).includes('prompt_async')) {
942
- return { ok: true, text: async () => '' };
943
- }
944
- throw new Error(`unexpected fetch ${url} ${init?.method}`);
993
+ const client = createSuccessfulClient({ sessionID: 'ses_goal_fail' });
994
+ const persistSessionGoal = vi.fn(async () => {
995
+ throw new Error('store unavailable');
945
996
  });
946
- vi.stubGlobal('fetch', fetchMock);
947
997
 
948
998
  const goalTask = {
949
999
  ...scheduledTask,
@@ -951,7 +1001,6 @@ describe('scheduled-tasks run history and session lifecycle', () => {
951
1001
  ...scheduledTask.execution,
952
1002
  prompt: 'finish the migration',
953
1003
  goalEnabled: true,
954
- goalTokenBudget: 12_000,
955
1004
  },
956
1005
  };
957
1006
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
@@ -969,42 +1018,225 @@ describe('scheduled-tasks run history and session lifecycle', () => {
969
1018
  waitForOpenCodeReady: vi.fn(async () => {}),
970
1019
  logger: { info: vi.fn(), warn: vi.fn() },
971
1020
  runHistoryStore: history,
1021
+ persistSessionGoal,
1022
+ });
1023
+ await runtime.syncProject('project-1');
1024
+ const result = await runtime.runNow('project-1', 'task-1');
1025
+
1026
+ expect(result.ok).toBe(false);
1027
+ expect(result.error).toMatch(/store unavailable/);
1028
+ expect(writeObjective).toHaveBeenCalled();
1029
+ expect(persistSessionGoal).toHaveBeenCalled();
1030
+ expect(client.prompt).not.toHaveBeenCalled();
1031
+ });
1032
+
1033
+ it('ordinary non-goal runs still create a session and settle on the assistant tail', async () => {
1034
+ const history = createHistoryStore();
1035
+ const client = createSuccessfulClient({ sessionID: 'ses_plain' });
1036
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1037
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1038
+ }));
1039
+ const runtime = createRuntime(updateScheduledTaskState, {
1040
+ runHistoryStore: history,
1041
+ waitForOpenCodeReady: vi.fn(async () => {}),
972
1042
  });
973
1043
  await runtime.syncProject('project-1');
974
1044
  const result = await runtime.runNow('project-1', 'task-1');
975
1045
 
976
1046
  expect(result.ok).toBe(true);
977
- expect(client.get).toHaveBeenCalled();
978
- const patchCall = fetchMock.mock.calls.find(
979
- ([url, init]) => String(url).includes('/session/ses_goal') && init?.method === 'PATCH',
1047
+ expect(client.create).toHaveBeenCalled();
1048
+ expect(client.prompt).toHaveBeenCalled();
1049
+ expect(writeObjective).not.toHaveBeenCalled();
1050
+ });
1051
+
1052
+ it('settles from the latest-side page: >50 older completed + newest error → error', async () => {
1053
+ const history = createHistoryStore();
1054
+ // order=desc page: newest first. Older completed assistants must not win.
1055
+ const latestPage = [
1056
+ erroredAssistant('msg_latest'),
1057
+ ...Array.from({ length: 49 }, (_, index) => completedAssistant(`msg_old_${index}`)),
1058
+ ];
1059
+ createSuccessfulClient({
1060
+ messageListImpl: async () => ({ data: latestPage }),
1061
+ });
1062
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1063
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1064
+ }));
1065
+ const runtime = createRuntime(updateScheduledTaskState, {
1066
+ runHistoryStore: history,
1067
+ waitForOpenCodeReady: vi.fn(async () => {}),
1068
+ });
1069
+ await runtime.syncProject('project-1');
1070
+ const result = await runtime.runNow('project-1', 'task-1');
1071
+
1072
+ expect(result.ok).toBe(false);
1073
+ expect(result.status).toBe('error');
1074
+ expect(result.error).toBe('ProviderError');
1075
+ expect(history.finishRun).toHaveBeenCalledWith(
1076
+ expect.any(String),
1077
+ expect.objectContaining({ status: 'error', error: 'ProviderError' }),
980
1078
  );
981
- expect(patchCall).toBeTruthy();
982
- const body = JSON.parse(patchCall[1].body);
983
- expect(body.metadata.openchamber.scheduledTask).toEqual({
984
- projectID: 'project-1',
985
- taskID: 'task-1',
986
- runID: history.startRun.mock.calls[0][0].id,
987
- name: 'Task',
1079
+ });
1080
+
1081
+ it('settles from the latest-side page: >50 older errors + newest completed → success', async () => {
1082
+ const history = createHistoryStore();
1083
+ const latestPage = [
1084
+ completedAssistant('msg_latest'),
1085
+ ...Array.from({ length: 49 }, (_, index) => erroredAssistant(`msg_old_${index}`)),
1086
+ ];
1087
+ createSuccessfulClient({
1088
+ messageListImpl: async () => ({ data: latestPage }),
988
1089
  });
989
- expect(body.metadata.openchamber.goal).toMatchObject({
990
- status: 'active',
991
- tokenBudget: 12_000,
1090
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1091
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1092
+ }));
1093
+ const runtime = createRuntime(updateScheduledTaskState, {
1094
+ runHistoryStore: history,
1095
+ waitForOpenCodeReady: vi.fn(async () => {}),
992
1096
  });
993
- expect(typeof body.metadata.openchamber.goal.objectiveFile).toBe('boolean');
994
- expect(typeof body.metadata.openchamber.goal.objective).toBe('string');
995
- if (!body.metadata.openchamber.goal.objectiveFile) {
996
- expect(body.metadata.openchamber.goal.objective.length).toBeGreaterThan(0);
997
- }
998
- expect(client.abort).not.toHaveBeenCalled();
1097
+ await runtime.syncProject('project-1');
1098
+ const result = await runtime.runNow('project-1', 'task-1');
999
1099
 
1000
- vi.unstubAllGlobals();
1100
+ expect(result.ok).toBe(true);
1101
+ expect(history.finishRun).toHaveBeenCalledWith(
1102
+ expect.any(String),
1103
+ expect.objectContaining({ status: 'success' }),
1104
+ );
1105
+ });
1106
+
1107
+ it('does not succeed when the latest message is model-switched', async () => {
1108
+ vi.useFakeTimers();
1109
+ const history = createHistoryStore();
1110
+ createSuccessfulClient({
1111
+ messageListImpl: async () => ({
1112
+ data: [{
1113
+ id: 'msg_switch',
1114
+ type: 'model-switched',
1115
+ time: { created: Date.now() },
1116
+ model: { providerID: 'openai', id: 'gpt-4.1' },
1117
+ }],
1118
+ }),
1119
+ });
1120
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1121
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1122
+ }));
1123
+ const runtime = createRuntime(updateScheduledTaskState, {
1124
+ runHistoryStore: history,
1125
+ waitForOpenCodeReady: vi.fn(async () => {}),
1126
+ maxRunDurationMs: 2_500,
1127
+ });
1128
+ await runtime.syncProject('project-1');
1129
+
1130
+ const runPromise = runtime.runNow('project-1', 'task-1');
1131
+ await vi.advanceTimersByTimeAsync(1_000);
1132
+ expect(history.finishRun).not.toHaveBeenCalled();
1133
+ await vi.advanceTimersByTimeAsync(1_500);
1134
+ const result = await runPromise;
1135
+
1136
+ expect(result.ok).toBe(false);
1137
+ expect(result.error).toBe('schedule run timed out');
1138
+ vi.useRealTimers();
1139
+ });
1140
+
1141
+ it('does not succeed when the assistant finish is tool-calls', async () => {
1142
+ vi.useFakeTimers();
1143
+ const history = createHistoryStore();
1144
+ createSuccessfulClient({
1145
+ messageListImpl: async () => ({
1146
+ data: [{
1147
+ id: 'msg_tools',
1148
+ type: 'assistant',
1149
+ time: { created: 1, completed: 2 },
1150
+ finish: 'tool-calls',
1151
+ }],
1152
+ }),
1153
+ });
1154
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1155
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1156
+ }));
1157
+ const runtime = createRuntime(updateScheduledTaskState, {
1158
+ runHistoryStore: history,
1159
+ waitForOpenCodeReady: vi.fn(async () => {}),
1160
+ maxRunDurationMs: 2_500,
1161
+ });
1162
+ await runtime.syncProject('project-1');
1163
+
1164
+ const runPromise = runtime.runNow('project-1', 'task-1');
1165
+ await vi.advanceTimersByTimeAsync(1_000);
1166
+ expect(history.finishRun).not.toHaveBeenCalled();
1167
+ await vi.advanceTimersByTimeAsync(1_500);
1168
+ const result = await runPromise;
1169
+
1170
+ expect(result.ok).toBe(false);
1171
+ expect(result.error).toBe('schedule run timed out');
1172
+ vi.useRealTimers();
1173
+ });
1174
+
1175
+ it('does not succeed after two incomplete assistant idle probes', async () => {
1176
+ vi.useFakeTimers();
1177
+ const history = createHistoryStore();
1178
+ createSuccessfulClient({
1179
+ messageListImpl: async () => ({
1180
+ data: [{
1181
+ id: 'msg_incomplete',
1182
+ type: 'assistant',
1183
+ time: { created: 1 },
1184
+ // no time.completed, no finish
1185
+ }],
1186
+ }),
1187
+ });
1188
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1189
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1190
+ }));
1191
+ const runtime = createRuntime(updateScheduledTaskState, {
1192
+ runHistoryStore: history,
1193
+ waitForOpenCodeReady: vi.fn(async () => {}),
1194
+ maxRunDurationMs: 3_500,
1195
+ });
1196
+ await runtime.syncProject('project-1');
1197
+
1198
+ const runPromise = runtime.runNow('project-1', 'task-1');
1199
+ // Two idle polls with incomplete assistant must not finalize as success.
1200
+ await vi.advanceTimersByTimeAsync(2_000);
1201
+ expect(history.finishRun).not.toHaveBeenCalled();
1202
+ await vi.advanceTimersByTimeAsync(1_500);
1203
+ const result = await runPromise;
1204
+
1205
+ expect(result.ok).toBe(false);
1206
+ expect(result.error).toBe('schedule run timed out');
1207
+ expect(history.finishRun).toHaveBeenCalledWith(
1208
+ expect.any(String),
1209
+ expect.objectContaining({ status: 'error', error: 'schedule run timed out' }),
1210
+ );
1211
+ vi.useRealTimers();
1001
1212
  });
1002
1213
 
1003
- it('successful runs never call session.abort', async () => {
1214
+ it('lists settlement messages with order=desc (latest-side page)', async () => {
1215
+ const history = createHistoryStore();
1216
+ const client = createSuccessfulClient();
1217
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1218
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1219
+ }));
1220
+ const runtime = createRuntime(updateScheduledTaskState, {
1221
+ runHistoryStore: history,
1222
+ waitForOpenCodeReady: vi.fn(async () => {}),
1223
+ });
1224
+ await runtime.syncProject('project-1');
1225
+ await runtime.runNow('project-1', 'task-1');
1226
+
1227
+ expect(client.messageList).toHaveBeenCalledWith(
1228
+ expect.objectContaining({
1229
+ sessionID: 'ses_1',
1230
+ limit: 50,
1231
+ order: 'desc',
1232
+ }),
1233
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
1234
+ );
1235
+ });
1236
+
1237
+ it('successful runs never call session.interrupt', async () => {
1004
1238
  const history = createHistoryStore();
1005
1239
  const client = createSuccessfulClient();
1006
- const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
1007
- vi.stubGlobal('fetch', fetchMock);
1008
1240
 
1009
1241
  const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1010
1242
  task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
@@ -1017,9 +1249,7 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1017
1249
  const result = await runtime.runNow('project-1', 'task-1');
1018
1250
 
1019
1251
  expect(result.ok).toBe(true);
1020
- expect(client.abort).not.toHaveBeenCalled();
1021
-
1022
- vi.unstubAllGlobals();
1252
+ expect(client.interrupt).not.toHaveBeenCalled();
1023
1253
  });
1024
1254
 
1025
1255
  it('persists lastSessionId when an error run still created a session', async () => {
@@ -1114,14 +1344,8 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1114
1344
  sessionID: 'ses_1',
1115
1345
  }));
1116
1346
 
1117
- client.messages.mockImplementation(async () => ({
1118
- data: [{
1119
- info: {
1120
- id: 'msg_ok',
1121
- role: 'assistant',
1122
- time: { completed: Date.now() },
1123
- },
1124
- }],
1347
+ client.messageList.mockImplementation(async () => ({
1348
+ data: [completedAssistant('msg_ok')],
1125
1349
  }));
1126
1350
 
1127
1351
  await runtime.observeSessionEvent({
@@ -1165,14 +1389,8 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1165
1389
  expect(result.status).toBe('error');
1166
1390
  expect(history.finishRun).toHaveBeenCalledTimes(1);
1167
1391
 
1168
- client.messages.mockImplementation(async () => ({
1169
- data: [{
1170
- info: {
1171
- id: 'msg_ok',
1172
- role: 'assistant',
1173
- time: { completed: Date.now() },
1174
- },
1175
- }],
1392
+ client.messageList.mockImplementation(async () => ({
1393
+ data: [completedAssistant('msg_ok')],
1176
1394
  }));
1177
1395
 
1178
1396
  await runtime.observeSessionEvent({
@@ -1303,14 +1521,8 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1303
1521
  const stateWritesAfterRun = updateScheduledTaskState.mock.calls.length;
1304
1522
  const successEmitsAfterRun = emitTaskRunEvent.mock.calls.filter((call) => call[0].status === 'success').length;
1305
1523
 
1306
- client.messages.mockImplementation(async () => ({
1307
- data: [{
1308
- info: {
1309
- id: 'msg_ok',
1310
- role: 'assistant',
1311
- time: { completed: Date.now() },
1312
- },
1313
- }],
1524
+ client.messageList.mockImplementation(async () => ({
1525
+ data: [completedAssistant('msg_ok')],
1314
1526
  }));
1315
1527
 
1316
1528
  await runtime.observeSessionEvent({