@openchambery/web 1.19.21-beta.1 → 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 (195) 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-Bh06xinl.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-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-CjCw1X4n.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-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-BKc-qXbQ.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-BTkP7aYI.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-BvSG94mW.js +4 -0
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/projectMeta-DOe49dM_.js +3 -0
  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-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
  52. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
  53. package/dist/assets/{sessionLookup-CbbzalcH.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-BzX5UI-c.css +2 -0
  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-3XR8sUfy.js +1 -0
  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-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
  63. package/dist/assets/{useEffectiveDirectory-CzHynoi_.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-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.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-BtGYjSW5.js +0 -3
  168. package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
  170. package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
  171. package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CcIuekQ3.js +0 -7
  174. package/dist/assets/insertionBoundaries-BocNNP9Q.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-CFLmpNC5.js +0 -169
  178. package/dist/assets/main-Ckj_IpUy.js +0 -2
  179. package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
  180. package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
  181. package/dist/assets/multirun-BGUKIJys.js +0 -4
  182. package/dist/assets/pl-DW2XJGC4.js +0 -15
  183. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  184. package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
  185. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  186. package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
  187. package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
  188. package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
  189. package/dist/assets/src-rS3zCcsC.css +0 -2
  190. package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
  191. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  192. package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
  193. package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
  194. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  195. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -1,24 +1,52 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
- vi.mock('@opencode-ai/sdk/v2', () => ({ createOpencodeClient: vi.fn() }));
4
- const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
3
+ vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: vi.fn() }));
4
+ const { makeOpenCodeV2Client } = await import('../opencode/v2-client.js');
5
5
  const { createOpenCodeMessageQueueAdapter } = await import('./opencode-adapter.js');
6
6
  const { createSessionTurnGate } = await import('./session-turn-gate.js');
7
7
 
8
+ const notFound = (status = 404) => Object.assign(new Error('not_found'), { status, code: 'not_found' });
9
+ const v2Client = ({ active = {}, list = { data: [] }, inbox = [], message } = {}) => ({
10
+ session: {
11
+ active: vi.fn(async () => active),
12
+ inbox: { list: vi.fn(async () => inbox) },
13
+ message: vi.fn(async ({ messageID }) => {
14
+ if (typeof message === 'function') return message(messageID);
15
+ if (message && typeof message === 'object' && messageID in message) return message[messageID];
16
+ throw notFound();
17
+ }),
18
+ },
19
+ message: { list: vi.fn(async () => list) },
20
+ });
21
+
8
22
  describe('OpenCode message queue adapter', () => {
9
23
  it('waits for readiness without forwarding worker options', async () => {
10
24
  const waitForReady = vi.fn(); const adapter = createOpenCodeMessageQueueAdapter({ waitForReady });
11
25
  await adapter.waitForReady({ signal: new AbortController().signal });
12
26
  expect(waitForReady).toHaveBeenCalledWith();
13
27
  });
14
- it('captures runtime, materializes text and files, and sends the captured configuration', async () => {
15
- const promptAsync = vi.fn(() => ({ data: {}, response: { status: 204 } })); createOpencodeClient.mockReturnValue({ session: { promptAsync, messages: vi.fn(() => ({ data: [] })) } });
28
+ it('captures runtime, materializes text and files, and applies sendConfig before prompt', async () => {
29
+ const fetchMock = vi.fn(async () => ({ ok: true, status: 204, json: async () => ({}) }));
30
+ vi.stubGlobal('fetch', fetchMock);
31
+ makeOpenCodeV2Client.mockReturnValue(v2Client());
16
32
  let generation = 1;
17
33
  const adapter = createOpenCodeMessageQueueAdapter({ waitForReady: vi.fn(), buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({ Authorization: 'secret' }), getRuntimeConfig: () => ({ apiBaseUrl: 'http://open.code' }), getRuntimeGeneration: () => generation, getSessionEligibility: () => ({ idle: true, settled: true }), getLatestMessageID: () => 'old', readAttachment: () => ({ type: 'file', url: 'file:///attachment' }) });
18
34
  const runtime = adapter.captureRuntime(); const scope = { sessionID: 'session', directory: '/repo' }; expect(await adapter.checkEligibility(scope)).toMatchObject({ available: true, idle: true, settled: true, latestMessageID: 'old' });
19
35
  const parts = await adapter.materializeAttachments({ content: 'text', attachments: [{}] }); expect(parts).toEqual([{ type: 'text', text: 'text' }, { type: 'file', url: 'file:///attachment' }]);
20
- await adapter.send({ scope, messageID: 'message', runtime, sendConfig: { providerID: 'p', modelID: 'm', agent: 'a', variant: 'v' }, parts }); expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'session', directory: '/repo', messageID: 'message', model: { providerID: 'p', modelID: 'm' }, agent: 'a', variant: 'v', parts }), expect.any(Object));
36
+ await adapter.send({ scope, messageID: 'message', runtime, sendConfig: { providerID: 'p', modelID: 'm', agent: 'a', variant: 'v' }, parts });
37
+ expect(fetchMock).toHaveBeenCalledTimes(3);
38
+ expect(String(fetchMock.mock.calls[0][0])).toContain('/api/session/session/agent');
39
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ agent: 'a' });
40
+ expect(String(fetchMock.mock.calls[1][0])).toContain('/api/session/session/model');
41
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ model: { id: 'm', providerID: 'p', variant: 'v' } });
42
+ expect(String(fetchMock.mock.calls[2][0])).toContain('/api/session/session/prompt');
43
+ const promptBody = JSON.parse(fetchMock.mock.calls[2][1].body);
44
+ expect(promptBody).toEqual(expect.objectContaining({ id: 'message', delivery: 'steer', text: 'text' }));
45
+ expect(promptBody.model).toBeUndefined();
46
+ expect(promptBody.agent).toBeUndefined();
47
+ expect(promptBody.variant).toBeUndefined();
21
48
  generation = 2; expect(await adapter.send({ scope, runtime, sendConfig: { providerID: 'p', modelID: 'm' } })).toMatchObject({ code: 'runtime_stale' });
49
+ vi.unstubAllGlobals();
22
50
  });
23
51
  it('materializes Assistant attachment IDs in delivery order', async () => {
24
52
  const readAttachment = vi.fn((attachment) => ({ type: 'file', url: `file:///${attachment.attachmentID}`, filename: attachment.filename }));
@@ -30,110 +58,352 @@ describe('OpenCode message queue adapter', () => {
30
58
  expect(readAttachment).toHaveBeenCalledWith({ attachmentID: 'image', filename: 'image.png' }, expect.any(Object), expect.any(Object));
31
59
  });
32
60
  it('uses the injected upstream runtime URL and detects its changes', async () => {
33
- const promptAsync = vi.fn(() => ({ data: {}, response: { status: 204 } }));
34
- createOpencodeClient.mockReturnValue({ session: { promptAsync, messages: vi.fn(() => ({ data: [] })) } });
61
+ const fetchMock = vi.fn(async () => ({ ok: true, status: 204, json: async () => ({}) }));
62
+ vi.stubGlobal('fetch', fetchMock);
63
+ makeOpenCodeV2Client.mockReturnValue(v2Client());
35
64
  let upstreamUrl = 'http://opencode-upstream:4096/';
36
65
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => upstreamUrl, getOpenCodeAuthHeaders: () => ({}), getRuntimeConfig: () => ({ apiBaseUrl: upstreamUrl }), readAttachment: () => null });
37
66
  const runtime = adapter.captureRuntime();
38
67
  await adapter.send({ scope: { sessionID: 's', directory: '/repo' }, messageID: 'msg_1', runtime, sendConfig: { providerID: 'p', modelID: 'm' }, parts: [] });
39
- expect(createOpencodeClient).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'http://opencode-upstream:4096' }));
68
+ const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt'));
69
+ expect(String(promptCall[0])).toContain('http://opencode-upstream:4096/api/session/s/prompt');
40
70
  upstreamUrl = 'http://opencode-upstream:4097/';
41
71
  expect(adapter.isCurrent(runtime)).toBe(false);
72
+ vi.unstubAllGlobals();
42
73
  });
43
74
  it('classifies SDK results and exact reconciliation matches without exposing transport details', async () => {
44
- const messages = vi.fn(() => ({ data: [{ id: 'other' }, { id: 'wanted' }] })); createOpencodeClient.mockReturnValue({ session: { promptAsync: vi.fn(() => ({ error: {}, response: { status: 503 } })), messages } });
75
+ const fetchMock = vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) }));
76
+ vi.stubGlobal('fetch', fetchMock);
77
+ makeOpenCodeV2Client.mockReturnValue(v2Client({ inbox: [{ id: 'wanted', sessionID: 's' }] }));
45
78
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), getSessionEligibility: () => ({ idle: true, settled: true }), getLatestMessageID: () => null, readAttachment: () => null });
46
- expect(await adapter.send({ scope: { sessionID: 's', directory: '/d' }, messageID: 'm', sendConfig: { providerID: 'p', modelID: 'm' } })).toMatchObject({ kind: 'ambiguous', status: 503 }); expect(await adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).toEqual({ found: true });
79
+ // Prompt-only path (no sendConfig) classifies transport status on the POST itself.
80
+ expect(await adapter.send({ scope: { sessionID: 's', directory: '/d' }, messageID: 'm', parts: [] })).toMatchObject({ kind: 'ambiguous', status: 503 });
81
+ expect(await adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).toEqual({ found: true });
82
+ vi.unstubAllGlobals();
47
83
  });
48
84
  it('treats explicit 2xx empty bodies as ok and never treats undefined/malformed results as success', async () => {
49
- const promptAsync = vi.fn()
50
- .mockReturnValueOnce({ data: undefined, error: null, response: { status: 204 } })
51
- .mockReturnValueOnce({ data: undefined, error: undefined, response: { status: 202 } })
52
- .mockReturnValueOnce({ data: {}, error: null, response: { status: 200 } })
53
- .mockReturnValueOnce(undefined)
54
- .mockReturnValueOnce(null)
55
- .mockReturnValueOnce({});
56
- createOpencodeClient.mockReturnValue({ session: { promptAsync, messages: vi.fn(() => ({ data: [] })) } });
85
+ const fetchMock = vi.fn()
86
+ .mockResolvedValueOnce({ ok: true, status: 204, json: async () => undefined })
87
+ .mockResolvedValueOnce({ ok: true, status: 202, json: async () => undefined })
88
+ .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) })
89
+ .mockResolvedValueOnce(undefined)
90
+ .mockResolvedValueOnce(null)
91
+ .mockResolvedValueOnce({});
92
+ vi.stubGlobal('fetch', fetchMock);
57
93
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), getRuntimeConfig: () => ({ apiBaseUrl: 'http://open.code' }), readAttachment: () => null });
58
- const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm', sendConfig: { providerID: 'p', modelID: 'm' }, parts: [] };
94
+ const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm', parts: [] };
59
95
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: true, status: 204 });
60
96
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: true, status: 202 });
61
97
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: true, status: 200 });
62
98
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', code: 'malformed_result' });
63
99
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', code: 'malformed_result' });
64
100
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', code: 'malformed_result' });
101
+ vi.unstubAllGlobals();
65
102
  });
66
103
  it('reads failure status from error.status and classifies definitive 4xx failures', async () => {
67
- const promptAsync = vi.fn()
68
- .mockReturnValueOnce({ error: { status: 400 }, response: undefined })
69
- .mockReturnValueOnce({ error: {}, response: { status: 422 } })
70
- .mockReturnValueOnce({ error: { status: 429 } })
71
- .mockReturnValueOnce({ error: { status: 408 } });
72
- createOpencodeClient.mockReturnValue({ session: { promptAsync, messages: vi.fn(() => ({ data: [] })) } });
104
+ const fetchMock = vi.fn()
105
+ .mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({}) })
106
+ .mockResolvedValueOnce({ ok: false, status: 422, json: async () => ({}) })
107
+ .mockResolvedValueOnce({ ok: false, status: 429, json: async () => ({}) })
108
+ .mockResolvedValueOnce({ ok: false, status: 408, json: async () => ({}) })
109
+ .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ _tag: 'SessionBusyError' }) });
110
+ vi.stubGlobal('fetch', fetchMock);
73
111
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
74
- const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm', sendConfig: { providerID: 'p', modelID: 'm' }, parts: [] };
112
+ const base = { scope: { sessionID: 's', directory: '/d' }, messageID: 'm', parts: [] };
75
113
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'failed', status: 400 });
76
114
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'failed', status: 422 });
77
115
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', status: 429 });
78
116
  await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'ambiguous', status: 408 });
117
+ await expect(adapter.send(base)).resolves.toMatchObject({ ok: false, kind: 'retry', code: 'session_busy' });
118
+ vi.unstubAllGlobals();
79
119
  });
80
120
  it('prefers client.v2.session.message exact lookup and treats 404 as found:false', async () => {
81
- const exact = vi.fn()
82
- .mockReturnValueOnce({ data: { info: { id: 'wanted' } } })
83
- .mockReturnValueOnce({ error: { status: 404 }, response: { status: 404 } });
84
- const messages = vi.fn(() => ({ data: [{ id: 'other' }] }));
85
- createOpencodeClient.mockReturnValue({ v2: { session: { message: exact } }, session: { messages, promptAsync: vi.fn() } });
121
+ const api = v2Client({
122
+ inbox: [],
123
+ message: { wanted: { id: 'wanted', type: 'user' } },
124
+ });
125
+ makeOpenCodeV2Client.mockReturnValue(api);
86
126
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
87
127
  await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ found: true });
88
128
  await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'missing')).resolves.toEqual({ found: false });
89
- expect(messages).not.toHaveBeenCalled();
90
- expect(exact).toHaveBeenCalledWith({ sessionID: 's', messageID: 'wanted', directory: '/d' }, expect.any(Object));
91
- });
92
- it('falls back from unsupported exact lookup to legacy session.message then bounded messages', async () => {
93
- const legacyMessage = vi.fn()
94
- .mockReturnValueOnce({ data: { info: { id: 'legacy-hit' } } })
95
- .mockReturnValueOnce({ error: { status: 404 }, response: { status: 404 } });
96
- const messages = vi.fn(() => ({ data: [{ id: 'bounded-hit' }] }));
97
- createOpencodeClient
98
- .mockReturnValueOnce({ session: { message: legacyMessage, messages, promptAsync: vi.fn() } })
99
- .mockReturnValueOnce({ session: { message: legacyMessage, messages, promptAsync: vi.fn() } })
100
- .mockReturnValueOnce({ session: { messages, promptAsync: vi.fn() } });
129
+ expect(api.session.inbox.list).toHaveBeenCalled();
130
+ expect(api.session.message).toHaveBeenCalledWith({ sessionID: 's', messageID: 'wanted' }, expect.any(Object));
131
+ });
132
+ it('reconciles inbox then session.message without session.messages or 1.x fallback', async () => {
133
+ const api = v2Client({
134
+ inbox: [{ id: 'legacy-hit' }],
135
+ message: { 'bounded-hit': { id: 'bounded-hit', type: 'user' } },
136
+ });
137
+ makeOpenCodeV2Client.mockReturnValue(api);
101
138
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
102
139
  await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'legacy-hit')).resolves.toEqual({ found: true });
103
140
  await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'missing')).resolves.toEqual({ found: false });
104
141
  await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'bounded-hit')).resolves.toEqual({ found: true });
105
- expect(messages).toHaveBeenCalledTimes(1);
142
+ expect(api.session.status).toBeUndefined();
143
+ expect(api.session.messages).toBeUndefined();
144
+ expect(api.session.promptAsync).toBeUndefined();
106
145
  });
107
- it('falls back when v2 exact lookup returns 405/501 unsupported', async () => {
108
- const exact = vi.fn().mockReturnValueOnce({ error: { status: 405 }, response: { status: 405 } });
109
- const messages = vi.fn(() => ({ data: [{ info: { id: 'wanted' } }] }));
110
- createOpencodeClient.mockReturnValue({ v2: { session: { message: exact } }, session: { messages, promptAsync: vi.fn() } });
146
+ it('marks non-404 inbox/message failures unavailable instead of falling back', async () => {
147
+ const api = v2Client();
148
+ api.session.inbox.list.mockRejectedValue(Object.assign(new Error('unsupported'), { status: 405 }));
149
+ makeOpenCodeV2Client.mockReturnValue(api);
111
150
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
112
- await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ found: true });
113
- expect(messages).toHaveBeenCalledTimes(1);
151
+ await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ unavailable: true });
114
152
  });
115
- it('uses absent session status as idle and derives settlement from the message tail', async () => {
116
- const messages = vi.fn(() => ({ data: [] })); createOpencodeClient.mockReturnValue({ session: { messages, status: vi.fn(() => ({ data: {} })) } });
153
+ it('uses absent session.active membership as idle and derives settlement from type and time.completed', async () => {
154
+ const list = vi.fn(async () => ({ data: [] }));
155
+ const active = vi.fn(async () => ({}));
156
+ makeOpenCodeV2Client.mockReturnValue({ session: { active, inbox: { list: vi.fn() }, message: vi.fn() }, message: { list } });
117
157
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
118
158
  expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ idle: true, settled: true });
119
- messages.mockReturnValueOnce({ data: [{ info: { id: 'user', role: 'user' } }] }); expect((await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).settled).toBe(false);
120
- messages.mockReturnValueOnce({ data: [{ info: { id: 'assistant', role: 'assistant', time: { completed: 1 } } }] }); expect((await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).settled).toBe(true);
159
+ list.mockResolvedValueOnce({ data: [{ id: 'user', type: 'user', time: { created: 1 } }] });
160
+ expect((await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).settled).toBe(false);
161
+ list.mockResolvedValueOnce({ data: [{ id: 'assistant', type: 'assistant', time: { completed: 1 } }] });
162
+ expect((await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).settled).toBe(true);
163
+ expect(list).toHaveBeenCalledWith({ sessionID: 's', limit: 1, order: 'desc' }, expect.any(Object));
164
+ });
165
+ it('treats a sessionID present in session.active as not idle', async () => {
166
+ makeOpenCodeV2Client.mockReturnValue(v2Client({
167
+ active: { s: { type: 'running' } },
168
+ list: { data: [{ id: 'assistant', type: 'assistant', time: { completed: 1 } }] },
169
+ }));
170
+ const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
171
+ expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ available: true, idle: false });
121
172
  });
122
173
  it('recovers an idle incomplete assistant tail without treating fetch failure as empty', async () => {
123
174
  let now = 0; const turnGate = createSessionTurnGate({ clock: () => now });
124
- const messages = vi.fn(() => ({ data: [{ info: { id: 'assistant', role: 'assistant', time: { created: 1 } } }] }));
125
- const status = vi.fn(() => ({ data: {} })); createOpencodeClient.mockReturnValue({ session: { messages, status } });
175
+ const list = vi.fn(async () => ({ data: [{ id: 'assistant', type: 'assistant', time: { created: 1 } }] }));
176
+ const active = vi.fn(async () => ({}));
177
+ makeOpenCodeV2Client.mockReturnValue({ session: { active, inbox: { list: vi.fn() }, message: vi.fn() }, message: { list } });
126
178
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null, turnGate });
127
179
  expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ settled: false, settlementReason: 'tail_unsettled' });
128
- status.mockReturnValueOnce({ error: {} }); now = 3_000;
180
+ active.mockRejectedValueOnce(Object.assign(new Error('upstream'), { status: 500 })); now = 3_000;
129
181
  await expect(adapter.checkEligibility({ sessionID: 's', directory: '/d' })).resolves.toEqual({ available: false, idle: false, settled: false });
130
182
  expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ settled: false, settlementReason: 'tail_unsettled' });
131
183
  now = 6_000;
132
184
  expect(await adapter.checkEligibility({ sessionID: 's', directory: '/d' })).toMatchObject({ settled: true, settlementReason: 'stopped_assistant' });
133
185
  });
134
186
  it('marks malformed or failed authoritative eligibility reads unavailable', async () => {
135
- createOpencodeClient.mockReturnValue({ session: { messages: vi.fn(() => ({ data: [] })), status: vi.fn(() => ({ error: {} })) } });
187
+ makeOpenCodeV2Client.mockReturnValue({
188
+ session: { active: vi.fn(async () => { throw Object.assign(new Error('upstream'), { status: 500 }); }), inbox: { list: vi.fn() }, message: vi.fn() },
189
+ message: { list: vi.fn(async () => ({ data: [] })) },
190
+ });
136
191
  const adapter = createOpenCodeMessageQueueAdapter({ buildOpenCodeUrl: () => 'http://open.code/', getOpenCodeAuthHeaders: () => ({}), readAttachment: () => null });
137
192
  await expect(adapter.checkEligibility({ sessionID: 's', directory: '/d' })).resolves.toEqual({ available: false, idle: false, settled: false });
138
193
  });
139
194
  });
195
+
196
+ describe('sendConfig serial boundary (v2 prompt contract)', () => {
197
+ it('applies different models in agent→model→prompt order per item', async () => {
198
+ const calls = [];
199
+ const fetchMock = vi.fn(async (url, init) => {
200
+ calls.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null });
201
+ return { ok: true, status: 204, json: async () => ({}) };
202
+ });
203
+ vi.stubGlobal('fetch', fetchMock);
204
+ const adapter = createOpenCodeMessageQueueAdapter({
205
+ buildOpenCodeUrl: () => 'http://open.code/',
206
+ getOpenCodeAuthHeaders: () => ({}),
207
+ readAttachment: () => null,
208
+ });
209
+ const scope = { sessionID: 's', directory: '/d' };
210
+ await adapter.send({
211
+ scope, messageID: 'msg_a', parts: [{ type: 'text', text: 'first' }],
212
+ sendConfig: { providerID: 'openai', modelID: 'gpt-a', agent: 'build', variant: 'fast' },
213
+ });
214
+ await adapter.send({
215
+ scope, messageID: 'msg_b', parts: [{ type: 'text', text: 'second' }],
216
+ sendConfig: { providerID: 'anthropic', modelID: 'claude-b', agent: 'plan' },
217
+ });
218
+ expect(calls.map((c) => c.url.replace(/^.*\/api\/session\/s\//, ''))).toEqual([
219
+ 'agent?directory=%2Fd',
220
+ 'model?directory=%2Fd',
221
+ 'prompt?directory=%2Fd',
222
+ 'agent?directory=%2Fd',
223
+ 'model?directory=%2Fd',
224
+ 'prompt?directory=%2Fd',
225
+ ]);
226
+ expect(calls[0].body).toEqual({ agent: 'build' });
227
+ expect(calls[1].body).toEqual({ model: { id: 'gpt-a', providerID: 'openai', variant: 'fast' } });
228
+ expect(calls[2].body).toEqual(expect.objectContaining({ id: 'msg_a', text: 'first', delivery: 'steer' }));
229
+ expect(calls[2].body.model).toBeUndefined();
230
+ expect(calls[3].body).toEqual({ agent: 'plan' });
231
+ expect(calls[4].body).toEqual({ model: { id: 'claude-b', providerID: 'anthropic' } });
232
+ expect(calls[5].body).toEqual(expect.objectContaining({ id: 'msg_b', text: 'second' }));
233
+ vi.unstubAllGlobals();
234
+ });
235
+
236
+ it('does not POST prompt when model switch fails', async () => {
237
+ const fetchMock = vi.fn(async (url) => {
238
+ if (String(url).includes('/model')) return { ok: false, status: 400, json: async () => ({ _tag: 'InvalidRequestError' }) };
239
+ return { ok: true, status: 204, json: async () => ({}) };
240
+ });
241
+ vi.stubGlobal('fetch', fetchMock);
242
+ const adapter = createOpenCodeMessageQueueAdapter({
243
+ buildOpenCodeUrl: () => 'http://open.code/',
244
+ getOpenCodeAuthHeaders: () => ({}),
245
+ readAttachment: () => null,
246
+ });
247
+ const result = await adapter.send({
248
+ scope: { sessionID: 's', directory: '/d' },
249
+ messageID: 'msg_fail',
250
+ parts: [{ type: 'text', text: 'nope' }],
251
+ sendConfig: { providerID: 'p', modelID: 'm', agent: 'a' },
252
+ });
253
+ expect(result).toMatchObject({ ok: false, kind: 'failed', code: 'config_switch_failed', status: 400 });
254
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt'))).toBe(false);
255
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/agent'))).toBe(true);
256
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/model'))).toBe(true);
257
+ vi.unstubAllGlobals();
258
+ });
259
+
260
+ it('treats session busy on switch as retry without prompt', async () => {
261
+ const fetchMock = vi.fn(async (url) => {
262
+ if (String(url).includes('/agent')) return { ok: false, status: 409, json: async () => ({ _tag: 'SessionBusyError' }) };
263
+ return { ok: true, status: 200, json: async () => ({}) };
264
+ });
265
+ vi.stubGlobal('fetch', fetchMock);
266
+ const adapter = createOpenCodeMessageQueueAdapter({
267
+ buildOpenCodeUrl: () => 'http://open.code/',
268
+ getOpenCodeAuthHeaders: () => ({}),
269
+ readAttachment: () => null,
270
+ });
271
+ const result = await adapter.send({
272
+ scope: { sessionID: 's', directory: '/d' },
273
+ messageID: 'msg_busy',
274
+ parts: [{ type: 'text', text: 'wait' }],
275
+ sendConfig: { providerID: 'p', modelID: 'm', agent: 'a' },
276
+ });
277
+ expect(result).toMatchObject({ ok: false, kind: 'retry', code: 'session_busy' });
278
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt'))).toBe(false);
279
+ vi.unstubAllGlobals();
280
+ });
281
+
282
+ it('maps MessageNotFoundError _tag from projection to found:false', async () => {
283
+ const api = v2Client({ inbox: [] });
284
+ api.session.message.mockRejectedValue({
285
+ _tag: 'MessageNotFoundError',
286
+ sessionID: 's',
287
+ messageID: 'missing',
288
+ message: 'not found',
289
+ });
290
+ makeOpenCodeV2Client.mockReturnValue(api);
291
+ const adapter = createOpenCodeMessageQueueAdapter({
292
+ buildOpenCodeUrl: () => 'http://open.code/',
293
+ getOpenCodeAuthHeaders: () => ({}),
294
+ readAttachment: () => null,
295
+ });
296
+ await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'missing')).resolves.toEqual({ found: false });
297
+ });
298
+
299
+ it('awaits projection rejections so non-404 stays unavailable instead of escaping', async () => {
300
+ const api = v2Client({ inbox: [] });
301
+ api.session.message.mockRejectedValue(Object.assign(new Error('boom'), { status: 500 }));
302
+ makeOpenCodeV2Client.mockReturnValue(api);
303
+ const adapter = createOpenCodeMessageQueueAdapter({
304
+ buildOpenCodeUrl: () => 'http://open.code/',
305
+ getOpenCodeAuthHeaders: () => ({}),
306
+ readAttachment: () => null,
307
+ });
308
+ await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ unavailable: true });
309
+ });
310
+ });
311
+
312
+ describe('intent queue prompt/inbox authority (ticket 12)', () => {
313
+ const jsonResponse = (status, body) => ({
314
+ ok: status >= 200 && status < 300,
315
+ status,
316
+ json: async () => body,
317
+ text: async () => JSON.stringify(body),
318
+ headers: { get: () => 'application/json' },
319
+ });
320
+
321
+ it('sends via v2 prompt/inbox and does not treat local parts as the body of record', async () => {
322
+ const fetchMock = vi.fn(async () => jsonResponse(200, {
323
+ id: 'msg_inbox',
324
+ sessionID: 's',
325
+ delivery: 'steer',
326
+ payload: { text: 'from-projection' },
327
+ }));
328
+ vi.stubGlobal('fetch', fetchMock);
329
+ const adapter = createOpenCodeMessageQueueAdapter({
330
+ buildOpenCodeUrl: (pathname) => `http://open.code${pathname}`,
331
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic secret' }),
332
+ readAttachment: () => ({ type: 'file', url: 'file:///staged' }),
333
+ });
334
+ const localParts = [{ type: 'text', text: 'stale-local-parts' }];
335
+ const result = await adapter.send({
336
+ scope: { sessionID: 's', directory: '/d' },
337
+ messageID: 'msg_inbox',
338
+ sendConfig: { providerID: 'p', modelID: 'm' },
339
+ parts: localParts,
340
+ content: 'stale-local-parts',
341
+ });
342
+ expect(result).toMatchObject({ ok: true, status: 200 });
343
+ const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt'));
344
+ expect(promptCall).toBeTruthy();
345
+ const [url, init] = promptCall;
346
+ expect(String(url)).toContain('/api/session/s/prompt');
347
+ const body = JSON.parse(init.body);
348
+ expect(body.delivery).toMatch(/steer|queue/);
349
+ expect(body.id).toBe('msg_inbox');
350
+ expect(body.model).toBeUndefined();
351
+ expect(init.method).toBe('POST');
352
+ vi.unstubAllGlobals();
353
+ });
354
+
355
+ it('reconciles only against OpenCode inbox and projection, never a local parts scan', async () => {
356
+ const list = vi.fn(async () => ({ data: [{ id: 'wanted', type: 'user', text: 'local-scan' }] }));
357
+ const api = v2Client({
358
+ inbox: [{ id: 'wanted', sessionID: 's', delivery: 'steer', payload: { text: 'inbox' } }],
359
+ message: { wanted: { id: 'wanted', type: 'user' } },
360
+ });
361
+ api.message.list = list;
362
+ makeOpenCodeV2Client.mockReturnValue(api);
363
+ const adapter = createOpenCodeMessageQueueAdapter({
364
+ buildOpenCodeUrl: (pathname) => `http://open.code${pathname}`,
365
+ getOpenCodeAuthHeaders: () => ({}),
366
+ readAttachment: () => null,
367
+ });
368
+ await expect(adapter.findMessage({ sessionID: 's', directory: '/d' }, 'wanted')).resolves.toEqual({ found: true });
369
+ expect(list).not.toHaveBeenCalled();
370
+ expect(api.session.inbox.list).toHaveBeenCalledWith({ sessionID: 's' }, expect.any(Object));
371
+ });
372
+
373
+ it('keeps staged attachment files until prompt is accepted, then projection wins', async () => {
374
+ const fetchMock = vi.fn(async () => jsonResponse(200, {
375
+ id: 'msg_att',
376
+ sessionID: 's',
377
+ delivery: 'steer',
378
+ payload: { text: 'accepted' },
379
+ }));
380
+ vi.stubGlobal('fetch', fetchMock);
381
+ const readAttachment = vi.fn(() => ({ type: 'file', url: 'file:///staged-disk', filename: 'note.txt' }));
382
+ const adapter = createOpenCodeMessageQueueAdapter({
383
+ buildOpenCodeUrl: (pathname) => `http://open.code${pathname}`,
384
+ getOpenCodeAuthHeaders: () => ({}),
385
+ readAttachment,
386
+ });
387
+ const parts = await adapter.materializeAttachments({
388
+ content: 'caption',
389
+ attachments: [{ attachmentID: 'att-1', filename: 'note.txt' }],
390
+ });
391
+ expect(parts).toEqual([
392
+ { type: 'text', text: 'caption' },
393
+ { type: 'file', url: 'file:///staged-disk', filename: 'note.txt' },
394
+ ]);
395
+ const result = await adapter.send({
396
+ scope: { sessionID: 's', directory: '/d' },
397
+ messageID: 'msg_att',
398
+ sendConfig: { providerID: 'p', modelID: 'm' },
399
+ parts,
400
+ });
401
+ expect(result.ok).toBe(true);
402
+ expect(readAttachment).toHaveBeenCalled();
403
+ const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt'));
404
+ const body = JSON.parse(promptCall[1].body);
405
+ expect(body.files?.length || body.parts).toBeTruthy();
406
+ expect(body.model).toBeUndefined();
407
+ vi.unstubAllGlobals();
408
+ });
409
+ });
@@ -4,24 +4,24 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { describe, expect, it, vi } from 'vitest';
6
6
 
7
- vi.mock('@opencode-ai/sdk/v2', () => ({ createOpencodeClient: vi.fn() }));
8
- const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
7
+ vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: vi.fn() }));
8
+ const { makeOpenCodeV2Client } = await import('../opencode/v2-client.js');
9
9
  import { createMessageQueueRuntime } from './runtime.js';
10
10
 
11
11
  describe('message queue runtime ownership', () => {
12
12
  it('keeps the durable queue identity separate from the upstream OpenCode adapter URL', async () => {
13
13
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'message-queue-runtime-'));
14
14
  const uiUrl = 'http://127.0.0.1:57123'; const upstreamUrl = 'http://127.0.0.1:4096/';
15
- const status = vi.fn(() => ({ data: {} })); const messages = vi.fn(() => ({ data: [] }));
16
- createOpencodeClient.mockReturnValue({ session: { status, messages, promptAsync: vi.fn() } });
15
+ const active = vi.fn(() => ({})); const list = vi.fn(() => ({ data: [] }));
16
+ makeOpenCodeV2Client.mockReturnValue({ session: { active, prompt: vi.fn() }, message: { list } });
17
17
  const runtime = createMessageQueueRuntime({ dbPath: path.join(root, 'queue.sqlite'), getRuntimeConfig: () => ({ apiBaseUrl: uiUrl }), getOpenCodeRuntimeConfig: () => ({ apiBaseUrl: upstreamUrl }), buildOpenCodeUrl: () => upstreamUrl, getOpenCodeAuthHeaders: () => ({}) });
18
18
  try {
19
19
  const adapterRuntime = runtime.adapter.captureRuntime();
20
20
  await runtime.adapter.checkEligibility({ directory: '/repo', sessionID: 'session' }, adapterRuntime);
21
21
  expect(runtime.service.getRuntimeKey()).toBe(crypto.createHash('sha256').update(`url:${uiUrl}`).digest('hex'));
22
22
  expect(adapterRuntime.config.apiBaseUrl).toBe(upstreamUrl);
23
- expect(createOpencodeClient).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'http://127.0.0.1:4096' }));
24
- expect(createOpencodeClient.mock.calls.map(([options]) => options.baseUrl)).not.toContain(uiUrl);
23
+ expect(makeOpenCodeV2Client).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: 'http://127.0.0.1:4096' }));
24
+ expect(makeOpenCodeV2Client.mock.calls.map(([options]) => options.baseUrl)).not.toContain(uiUrl);
25
25
  } finally {
26
26
  await runtime.stop(); fs.rmSync(root, { recursive: true, force: true });
27
27
  }
@@ -109,7 +109,13 @@ export const createMessageQueueWorker = ({ service, adapter, workerID, concurren
109
109
  };
110
110
  const run = async () => {
111
111
  if (paused || stopping) return status();
112
- const runtimeKey = service.getRuntimeKey(); const runtime = adapter.captureRuntime(); const authority = service.getAuthority({ runtimeKey });
112
+ const runtimeKey = service.getRuntimeKey();
113
+ // The managed OpenCode service may still be booting when the worker starts;
114
+ // captureRuntime throws until its port is detected. Leave queued work
115
+ // pending and retry on the next tick instead of failing the flight.
116
+ let runtime;
117
+ try { runtime = adapter.captureRuntime(); } catch { return status(); }
118
+ const authority = service.getAuthority({ runtimeKey });
113
119
  if (authority?.authority !== 'active') return status();
114
120
  service.recoverExpiredSending?.({ runtimeKey });
115
121
  // Start dispatch probes before awaiting reconciling work so a hung findMessage
@@ -11,8 +11,10 @@ This module provides OpenCode server integration utilities for the web server ru
11
11
  - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, forces `OPENCHAMBER_RUNTIME=web` unless already `ssh-remote` (so `dev:server` cannot inherit a leftover desktop env and host a relay, while SSH-managed remotes may), parses CLI options, and starts server bootstrap.
12
12
  - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
13
13
  - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). Managed child env inherits the user shell environment unchanged; experimental flags such as `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` follow the user's own environment and are never injected by OpenChamber.
14
+ - `packages/web/server/lib/opencode/v1-migration-gate.js`: pure V1→v2 migration admission gate (`GET /api/experimental/migration/v1` → whether transcript may be fetched).
14
15
  - `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
15
16
  - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
17
+ - `packages/web/server/lib/opencode/ensure-cli.js`: detect local `opencode2` version and install the pinned official V2 into the OpenChamber data dir when missing or too old. Never uses a packaged/bundled binary.
16
18
  - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
17
19
  - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
18
20
  - `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring.
@@ -112,8 +114,27 @@ This module provides OpenCode server integration utilities for the web server ru
112
114
  membership — that remains a UI-side authoritative pull fused with directory
113
115
  `/session/status`.
114
116
 
117
+ ## Public exports (opencode2-pin.js)
118
+ - `PINNED_OPENCODE2_VERSION`: exact desktop/runtime pin (never 1.x).
119
+ - `OPENCODE2_NPM_PACKAGE`: `@opencode/cli` (global install that ships the `opencode2` binary; not 1.x `opencode-ai`).
120
+ - `parseOpenCode2VersionOutput(stdout)` / `compareOpenCode2Versions(left, right)` / `isOpenCode2VersionAtLeast(version, minimum)`: CLI `--version` parse and pin comparison.
121
+ - `openCode2BinaryName(platform)` / `npmPackageForOpenCode2(platform, arch)`: staged binary name `opencode2` and official platform package `@opencode/cli-<os>-<arch>[-baseline]`.
122
+ - `isOpenCode1xVersion(value)` / `isAcceptableOpenCode2HealthVersion(value)` / `evaluateOpenCodeHealthBody(body)`: health admission rejects 1.x and missing/unknown versions even when `healthy: true`.
123
+ - `resolveOpenCode2UpgradeTarget(target)` / `rejectOpenCode1xUpgradeTarget(target)`: upgrade targets default to the pin and refuse 1.x.
124
+
125
+ ## Public exports (ensure-cli.js)
126
+ - `ensurePinnedOpenCode2Cli({ discoveredPath, pin, autoInstall, install, readVersion })`: reuse any discovered acceptable 2.x CLI (official name `opencode`, alias `opencode2`). Install the pin into `~/.config/openchamber/opencode-cli/<pin>/opencode` only when nothing usable is installed. `OPENCHAMBER_OPENCODE2_AUTO_INSTALL=0` disables download (`OPENCODE_CLI_MISSING`).
127
+ - `installPinnedOpenCode2Cli(options)`: download `@opencode/cli-<os>-<arch>@pin`, extract, copy as `opencode`, chmod, verify `--version`.
128
+ - `installedOpenCode2BinaryPath(version)` / `readOpenCode2BinaryVersion(path)` / `isOpenCode2AutoInstallEnabled()`.
129
+
130
+ ## Public exports (v1-migration-gate.js)
131
+ - `OPENCODE_V1_MIGRATION_PATH`: `/api/experimental/migration/v1`.
132
+ - `V1_MIGRATION_USER_NOTICE`: user-visible backfill notes (reuse message ids; in-progress tools become interrupted; V1 subtasks omitted from v2).
133
+ - `evaluateV1MigrationGate(input)`: pure function. Accepts migration JSON (`{ status }`), an HTTP envelope (`{ httpStatus|status, body }`), or a transport failure (`{ error }`). Returns `{ admitTranscript, phase, progress?, error?, userNotice? }`. `required` / `running` / `error` / network failure → `admitTranscript: false`. `completed` or HTTP 404 (no V1 library) → `true`. Unknown or empty bodies fail closed and are never treated as an empty-list success.
134
+ - `fetchV1MigrationGate({ url, headers, signal, fetchImpl })`: GET-only status poll used by lifecycle.
135
+
115
136
  ## Public exports (lifecycle.js)
116
- - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
137
+ - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. Bootstrap reuses a healthy v2 `opencode serve` when present; otherwise starts managed `opencode serve`. Before managed spawn, `ensurePinnedOpenCode2CliEnv()` detects the local CLI version and installs the pinned official V2 when missing or too old (skipped for external skip-start). Startup accepts both `server listening on http://127.0.0.1:PORT` and the legacy `opencode server listening on …` line. Readiness/health probes `GET /api/info` first, then `/global/health`, with Basic auth (username `opencode`). A body is admitted only when `evaluateOpenCodeHealthBody` accepts the version (rejects 1.x and missing/unknown). After health ok, `startOpenCode` / `waitForOpenCodeReady` poll `GET /api/experimental/migration/v1` (no POST; backfill is owned by opencode2). Each readiness attempt uses one abort timer that covers **both** health and migration so a hung migration cannot outrun the per-attempt budget. External instances are a read-only mount: restart/refresh only re-probe health + V1 migration (never kill, spawn, clear port, or flip `isExternalOpenCode`); health monitoring skips them; upgrade routes return 409. After readiness admits, lifecycle warms recent directories via optional `getWarmupDirectories` (sequential best-effort `GET /api/session?directory=&limit=1`). External skip-start / auto-detect attach leave `isOpenCodeReady` false until the gate admits. `isOpenCodeReady` means transcript may be fetched only when the gate admits (`completed`, or no V1 library such as HTTP 404). `required` / `running` / `error` keep the ready gate closed; `error` is retried. The last gate result is stored on `state.v1Migration` and published on the OpenChamber `/health` snapshot so UI can render `phase` and running `progress` (`label` / `numerator` / `denominator`) plus `userNotice` (reuse message ids; in-progress tools become interrupted; V1 subtasks do not appear in v2).
117
138
  - Returned API:
118
139
  - `startOpenCode()`
119
140
  - `restartOpenCode()`
@@ -127,11 +148,13 @@ This module provides OpenCode server integration utilities for the web server ru
127
148
  - `killProcessOnPort(port)`
128
149
 
129
150
  ## Public exports (env-runtime.js)
130
- - `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
151
+ - `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state. Auto-discovery matches master: settings/env, then PATH `opencode` (official v2) with `opencode2` as alias, then known install locations, then a previously installed pin under the OpenChamber data dir. 1.x binaries are skipped by `--version`, not by basename. `resolveOpencodeCliPath()` never prefers a packaged/bundled Electron extraResource. Managed startup calls `ensurePinnedOpenCode2CliEnv()` and installs the pin only when no usable 2.x CLI exists; if pin install fails (or auto-install is disabled), it may last-resort to a 2.x binary under `OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR` or `process.resourcesPath/opencode-cli` (source `bundled`). Explicit `OPENCODE_BINARY` that reports 1.x fails closed with `OPENCODE_BINARY_INVALID`. Bootstrap reuses a healthy v2 `opencode serve` on port 4096 when present; otherwise it starts generic `opencode serve`.
152
+ - VS Code keeps a copied sidecar (`packages/vscode/src/opencode-sidecar.ts`) with the same invariants: discovery order may differ (no Electron bundled fallback), but reject list, listening lines, health path order (`/api/health` then `/global/health`), `{ healthy: true }`, and Basic username `opencode` must stay aligned. Changing one copy requires changing the other.
131
153
  - Returned API:
132
154
  - `applyLoginShellEnvSnapshot()`
133
155
  - `getLoginShellEnvSnapshot()`
134
156
  - `ensureOpencodeCliEnv()`
157
+ - `ensurePinnedOpenCode2CliEnv()`
135
158
  - `applyOpencodeBinaryFromSettings()`
136
159
  - `resolveOpencodeCliPath()`
137
160
  - `resolveManagedOpenCodeLaunchSpec(opencodePath)`: resolves the effective managed OpenCode launch target, unwrapping Windows package-manager shims to a direct native binary or explicit runtime+script when possible.
@@ -175,7 +198,7 @@ This module provides OpenCode server integration utilities for the web server ru
175
198
  ## Public exports (network-runtime.js)
176
199
  - `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns.
177
200
  - Returned API:
178
- - `waitForReady(url, timeoutMs?)`
201
+ - `waitForReady(url, timeoutMs?)`: probes `GET /api/health` then `/global/health` with `getOpenCodeAuthHeaders()`; `{ healthy: true }` is ready.
179
202
  - `normalizeApiPrefix(prefix)`
180
203
  - `setDetectedOpenCodeApiPrefix()`
181
204
  - `buildOpenCodeUrl(path, prefixOverride?)`
@@ -382,9 +405,9 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
382
405
  - SSE forwarders: `GET /api/global/event`, `GET /api/event` (optional `includeReasoning=false` parse-filters reasoning events on the Host send path; param never forwarded upstream; enabled path remains byte passthrough)
383
406
  - Official session.messages list: `GET /api/session/:sessionID/message` (optional `includeReasoning=false` strips reasoning parts; exact message GET is owned by session-turn-pages)
384
407
  - Session message forwarder: `POST /api/session/:sessionId/message`
385
- - Generic `/api/*` forwarding with hop-by-hop header filtering
408
+ - Generic `/api/*` forwarding with hop-by-hop header filtering. v2 upstream routes live under `/api` (`@opencode-ai/protocol`); the proxy restores that prefix after Express strips the `/api` mount. Forwarding `/agent` (v1 root) hits the v2 Web UI HTML fallback.
386
409
  - Windows `/session` merge fallback path behavior
387
- - OpenCode readiness gate for proxied `/api` requests
410
+ - OpenCode readiness gate for proxied `/api` requests: process warmup still uses `OPEN_CODE_READY_GRACE_MS`; `state.v1Migration.admitTranscript !== true` holds with no grace until the gate admits or the client cancels, so a `running` migration past 12s cannot `next()` an empty session list through as success
388
411
 
389
412
  ## Public exports (watcher.js)
390
413
  - `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime backed by the shared upstream SSE reader.