@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,347 +1,398 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { detectSessionlessGenerate, generateOpenCodeText, _test } from './generate.js'
3
-
4
- const completedAssistant = (text) => ({
5
- info: {
6
- role: 'assistant',
7
- time: { completed: Date.now() },
2
+ import { generateOpenCodeText, _test } from './generate.js'
3
+
4
+ const denyAllAgent = {
5
+ location: { directory: '/tmp/openchamber-llm' },
6
+ data: {
7
+ id: 'openchamber-llm',
8
+ name: 'openchamber-llm',
9
+ mode: 'primary',
10
+ hidden: true,
11
+ permissions: [
12
+ { action: '*', resource: '*', effect: 'deny' },
13
+ ],
8
14
  },
9
- parts: [{ type: 'text', text }],
15
+ }
16
+
17
+ const completedAssistant = (text, extras = {}) => ({
18
+ id: 'msg_asst',
19
+ type: 'assistant',
20
+ time: { created: 1, completed: Date.now() },
21
+ agent: 'openchamber-llm',
22
+ model: { id: 'gpt-5-nano', providerID: 'opencode' },
23
+ content: [{ type: 'text', text }],
24
+ finish: 'stop',
25
+ ...extras,
10
26
  })
11
27
 
12
-
13
- const throwawayGenerate = (client, extra = {}) => generateOpenCodeText({
14
- providerID: 'p',
15
- modelID: 'm',
16
- messages: [{ role: 'user', content: 'work' }],
17
- buildOpenCodeUrl: () => 'http://localhost:1',
18
- getOpenCodeAuthHeaders: () => ({}),
19
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
20
- clientFactory: () => client,
21
- ensureTempDirectory: async () => '/tmp/timeout-fixture',
22
- ...extra,
23
- })
24
-
25
- const throwawayClient = ({ status, messages, id = 'ses_tmp', remove = vi.fn(async () => ({ data: true })) }) => ({
26
- tool: { ids: async () => ({ data: [] }) },
27
- session: {
28
- create: async () => ({ data: { id } }),
29
- update: async () => ({ data: { id } }),
30
- promptAsync: async () => ({ response: { status: 204 } }),
31
- status,
32
- messages,
33
- delete: remove,
28
+ const jsonResponse = (status, body) => ({
29
+ ok: status >= 200 && status < 300,
30
+ status,
31
+ headers: {
32
+ get: (name) => (String(name).toLowerCase() === 'content-type' ? 'application/json' : null),
34
33
  },
34
+ json: async () => body,
35
+ text: async () => JSON.stringify(body ?? null),
36
+ arrayBuffer: async () => new TextEncoder().encode(JSON.stringify(body ?? null)).buffer,
35
37
  })
36
38
 
37
39
  describe('generate cancellation', () => {
38
- it('cancels throwaway polling and still deletes only its temporary session', async () => {
40
+ it('cancels attachment wait and still removes only its temporary session', async () => {
39
41
  const controller = new AbortController()
40
- const remove = vi.fn(async () => ({ data: true }))
41
- const client = {
42
- tool: { ids: async () => ({ data: [] }) },
43
- session: {
44
- create: async () => ({ data: { id: 'ses_abort_fixture' } }),
45
- update: async () => ({ data: { id: 'ses_abort_fixture' } }),
46
- promptAsync: async () => ({ response: { status: 204 } }),
47
- status: async (_args, { signal }) => { controller.abort(); signal.throwIfAborted() },
48
- delete: remove,
49
- },
50
- }
42
+ const remove = vi.fn(async () => undefined)
43
+ const interrupt = vi.fn(async () => undefined)
44
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
45
+
51
46
  await expect(generateOpenCodeText({
52
- providerID: 'p', modelID: 'm', messages: [{ role: 'user', content: 'work' }],
53
- buildOpenCodeUrl: () => 'http://localhost:1', getOpenCodeAuthHeaders: () => ({}),
54
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
55
- clientFactory: () => client, ensureTempDirectory: async () => '/tmp/abort-fixture',
47
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
48
+ getOpenCodeAuthHeaders: () => ({}),
49
+ providerID: 'opencode',
50
+ modelID: 'gpt-4o',
51
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
52
+ clientFactory: () => ({
53
+ agent: { get: async () => denyAllAgent },
54
+ session: {
55
+ create: async () => ({ id: 'ses_abort_fixture' }),
56
+ prompt: async () => ({ id: 'inbox' }),
57
+ wait: async (_args, { signal }) => {
58
+ controller.abort()
59
+ signal.throwIfAborted()
60
+ },
61
+ interrupt,
62
+ remove,
63
+ instructions: { entry: { put: async () => {} } },
64
+ },
65
+ message: { list: vi.fn() },
66
+ }),
67
+ ensureTempDirectory: async () => '/tmp/abort-fixture',
68
+ forwardImageParts: true,
56
69
  signal: controller.signal,
57
70
  })).rejects.toMatchObject({ name: 'AbortError' })
58
- expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_abort_fixture', directory: '/tmp/abort-fixture' })
71
+ expect(remove).toHaveBeenCalledExactlyOnceWith({ sessionID: 'ses_abort_fixture' })
72
+ expect(interrupt).toHaveBeenCalledWith({ sessionID: 'ses_abort_fixture', continue: false })
59
73
  })
60
- it('aborts an in-flight model request when the contact continuation is cancelled', async () => {
74
+
75
+ it('aborts an in-flight generate.text when the contact continuation is cancelled', async () => {
61
76
  const controller = new AbortController()
62
- const fetchImpl = vi.fn(async (_url, { signal }) => {
77
+ const text = vi.fn(async (_args, { signal }) => {
63
78
  controller.abort()
64
79
  signal.throwIfAborted()
65
80
  })
66
81
  await expect(generateOpenCodeText({
67
- providerID: 'p', modelID: 'm', messages: [{ role: 'user', content: 'work' }],
68
- buildOpenCodeUrl: () => 'http://localhost:1', getOpenCodeAuthHeaders: () => ({}),
69
- detect: async () => ({ available: true, mode: 'http', url: 'http://localhost:1/generate' }),
70
- fetchImpl, signal: controller.signal,
82
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
83
+ getOpenCodeAuthHeaders: () => ({}),
84
+ providerID: 'opencode',
85
+ modelID: 'gpt-5-nano',
86
+ messages: [{ role: 'user', content: 'work' }],
87
+ clientFactory: () => ({ generate: { text } }),
88
+ signal: controller.signal,
71
89
  })).rejects.toMatchObject({ name: 'AbortError' })
72
- expect(fetchImpl).toHaveBeenCalledTimes(1)
73
- })
74
- })
75
-
76
- describe('detectSessionlessGenerate', () => {
77
- it('returns unavailable when the probe 404s', async () => {
78
- const fetchImpl = vi.fn(async () => new Response('missing', { status: 404 }))
79
- await expect(detectSessionlessGenerate({
80
- fetchImpl,
81
- baseUrl: 'http://127.0.0.1:4096',
82
- headers: {},
83
- })).resolves.toEqual({ available: false, mode: 'throwaway-session' })
84
- })
85
-
86
- it('returns available when the probe returns JSON', async () => {
87
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
88
- status: 200,
89
- headers: { 'Content-Type': 'application/json' },
90
- }))
91
- await expect(detectSessionlessGenerate({
92
- fetchImpl,
93
- baseUrl: 'http://127.0.0.1:4096',
94
- headers: {},
95
- })).resolves.toMatchObject({ available: true, mode: 'http' })
96
- })
97
-
98
- it('treats HTML 200 on /generate as unavailable', async () => {
99
- const fetchImpl = vi.fn(async (url) => {
100
- expect(String(url)).toMatch(/\/generate$/)
101
- return new Response('<!doctype html><html><body>OpenChamber</body></html>', {
102
- status: 200,
103
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
104
- })
105
- })
106
- await expect(detectSessionlessGenerate({
107
- fetchImpl,
108
- baseUrl: 'http://127.0.0.1:4096',
109
- headers: {},
110
- })).resolves.toEqual({ available: false, mode: 'throwaway-session' })
111
- expect(fetchImpl).toHaveBeenCalled()
90
+ expect(text).toHaveBeenCalledTimes(1)
112
91
  })
113
92
  })
114
93
 
115
- describe('generateOpenCodeText', () => {
116
- it.each(['high', undefined])('passes variant %s to the upstream prompt and returns only text parts', async (variant) => {
117
- const client = throwawayClient({
118
- status: async () => ({ data: {} }),
119
- messages: async () => ({ data: [{
120
- ...completedAssistant('public reply'),
121
- parts: [{ type: 'reasoning', text: 'private reasoning' }, { type: 'text', text: 'public reply' }],
122
- }] }),
123
- });
124
- client.session.promptAsync = vi.fn(async () => ({ response: { status: 204 } }));
125
- const result = await throwawayGenerate(client, { variant });
126
- expect(client.session.promptAsync.mock.calls[0][0].variant).toBe(variant);
127
- expect(result.text).toBe('public reply');
128
- });
129
-
130
- it('passes variant to sessionless generation', async () => {
131
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'reply' })));
132
- await throwawayGenerate({}, {
133
- variant: 'high', fetchImpl,
134
- detect: async () => ({ available: true, mode: 'http', url: 'http://localhost:1/generate' }),
135
- });
136
- expect(JSON.parse(fetchImpl.mock.calls[0][1].body).variant).toBe('high');
137
- });
138
-
139
- it('uses promptAsync with model+parts, waits for idle messages, and never calls v2 session.prompt', async () => {
140
- const create = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
141
- const update = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
142
- const ids = vi.fn(async () => ({ data: ['bash', 'edit'] }))
143
- const prompt = vi.fn(async () => {
144
- throw new Error('v2 session.prompt must not be used')
94
+ describe('generateOpenCodeText — text path', () => {
95
+ it('uses generate.text with model id/providerID and never opens a session', async () => {
96
+ const text = vi.fn(async () => ({ text: 'reply' }))
97
+ const create = vi.fn()
98
+ const prompt = vi.fn()
99
+ const clientFactory = () => ({
100
+ generate: { text },
101
+ session: { create, prompt },
102
+ agent: { get: vi.fn() },
145
103
  })
146
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
147
- const status = vi.fn(async () => ({ data: { ses_tmp: { type: 'idle' } } }))
148
- const messages = vi.fn(async () => ({ data: [completedAssistant('reply')] }))
149
- const remove = vi.fn(async () => ({ data: true }))
150
- const createOpencodeClient = vi.fn(() => ({
151
- session: { create, update, prompt, promptAsync, status, messages, delete: remove },
152
- tool: { ids },
153
- }))
154
104
 
155
105
  const result = await generateOpenCodeText({
156
106
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
157
107
  getOpenCodeAuthHeaders: () => ({}),
158
108
  providerID: 'opencode',
159
109
  modelID: 'gpt-5-nano',
160
- messages: [{ role: 'user', content: 'hi' }],
161
- clientFactory: createOpencodeClient,
162
- ensureTempDirectory: async ({ agentMarkdown }) => {
163
- expect(agentMarkdown).toContain('openchamber-tool JSON fences')
164
- expect(agentMarkdown).toMatch(/"\*"\s*:\s*deny/)
165
- expect(agentMarkdown).toContain('permission:')
166
- expect(agentMarkdown).not.toContain('effect: deny')
167
- expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
168
- expect(agentMarkdown).toContain('Native permissions are denied')
169
- return '/tmp/openchamber-llm'
170
- },
171
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
110
+ messages: [
111
+ { role: 'system', content: 'Be brief' },
112
+ { role: 'user', content: 'hi' },
113
+ ],
114
+ clientFactory,
172
115
  })
173
116
 
174
- expect(create).toHaveBeenCalledWith(expect.objectContaining({
175
- title: expect.stringContaining('[openchamber-llm]'),
176
- agent: 'openchamber-llm',
177
- permission: [{ permission: '*', pattern: '*', action: 'deny' }],
178
- metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
179
- }), expect.anything())
180
- expect(update).toHaveBeenCalled()
181
- expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({
182
- sessionID: 'ses_tmp',
183
- agent: 'openchamber-llm',
184
- model: { providerID: 'opencode', modelID: 'gpt-5-nano' },
185
- tools: { bash: false, edit: false },
186
- parts: [{ type: 'text', text: 'User: hi', synthetic: false }],
117
+ expect(result).toEqual({ text: 'reply', source: 'generate.text' })
118
+ expect(text).toHaveBeenCalledWith(expect.objectContaining({
119
+ prompt: expect.stringContaining('Be brief'),
120
+ model: { id: 'gpt-5-nano', providerID: 'opencode' },
187
121
  }), expect.anything())
122
+ expect(text.mock.calls[0][0].prompt).toContain('User: hi')
123
+ expect(create).not.toHaveBeenCalled()
188
124
  expect(prompt).not.toHaveBeenCalled()
189
- expect(status).toHaveBeenCalled()
190
- expect(messages).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_tmp' }), expect.anything())
191
- expect(remove).toHaveBeenCalled()
192
- expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
193
125
  })
194
126
 
195
- it('forwards contact file parts on promptAsync so the model can see images', async () => {
196
- const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
197
- const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
198
- const flattened = _test.flattenMessages([
199
- { role: 'user', content: 'look', parts: [image, file] },
200
- ])
201
- expect(flattened.prompt).toContain('User: look')
202
- expect(flattened.prompt).toContain('[image: shot.png (image/png)]')
203
- expect(flattened.prompt).toContain('[file: notes.txt (text/plain)]')
204
- expect(flattened.files).toEqual([image, file])
205
-
206
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
207
- const createOpencodeClient = vi.fn(() => ({
208
- session: {
209
- create: async () => ({ data: { id: 'ses_tmp' } }),
210
- update: async () => ({ data: { id: 'ses_tmp' } }),
211
- prompt: vi.fn(),
212
- promptAsync,
213
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
214
- messages: async () => ({ data: [completedAssistant('saw it')] }),
215
- delete: async () => ({ data: true }),
127
+ it.each(['high', undefined])('passes variant %s to generate.text and returns only text', async (variant) => {
128
+ const text = vi.fn(async () => ({ text: 'public reply' }))
129
+ const result = await generateOpenCodeText({
130
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
131
+ getOpenCodeAuthHeaders: () => ({}),
132
+ providerID: 'opencode',
133
+ modelID: 'gpt-5-nano',
134
+ messages: [{ role: 'user', content: 'hi' }],
135
+ variant,
136
+ clientFactory: () => ({ generate: { text } }),
137
+ })
138
+ expect(result.text).toBe('public reply')
139
+ const model = text.mock.calls[0][0].model
140
+ if (variant) {
141
+ expect(model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode', variant })
142
+ } else {
143
+ expect(model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode' })
144
+ }
145
+ })
146
+
147
+ it('does not invent deltas on the generate.text path', async () => {
148
+ const onTextDelta = vi.fn()
149
+ const subscribers = new Set()
150
+ const globalEventHub = {
151
+ subscribeEvent(fn) {
152
+ subscribers.add(fn)
153
+ return () => { subscribers.delete(fn) }
216
154
  },
217
- tool: { ids: async () => ({ data: [] }) },
218
- }))
219
- await generateOpenCodeText({
155
+ }
156
+ const result = await generateOpenCodeText({
220
157
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
221
158
  getOpenCodeAuthHeaders: () => ({}),
222
159
  providerID: 'opencode',
223
160
  modelID: 'gpt-5-nano',
224
- messages: [{ role: 'user', content: 'look', parts: [image, file] }],
225
- clientFactory: createOpencodeClient,
226
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
227
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
228
- forwardImageParts: true,
161
+ messages: [{ role: 'user', content: 'hi' }],
162
+ clientFactory: () => ({
163
+ generate: { text: async () => ({ text: 'full reply' }) },
164
+ }),
165
+ onTextDelta,
166
+ globalEventHub,
229
167
  })
230
- expect(promptAsync.mock.calls[0][0].parts).toEqual([
231
- expect.objectContaining({ type: 'text', synthetic: false }),
232
- image,
233
- file,
234
- ])
168
+ expect(result).toEqual({ text: 'full reply', source: 'generate.text' })
169
+ expect(onTextDelta).not.toHaveBeenCalled()
170
+ expect(subscribers.size).toBe(0)
235
171
  })
236
172
 
237
- it('skips image bytes for non-vision generate and keeps text files plus the image description', async () => {
173
+ it('keeps image descriptions for non-vision generate and stays on text path', async () => {
238
174
  const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
239
175
  const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
240
176
  expect(_test.filesForPrompt([image, file], false)).toEqual([file])
241
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
242
- const createOpencodeClient = vi.fn(() => ({
243
- session: {
244
- create: async () => ({ data: { id: 'ses_tmp' } }),
245
- update: async () => ({ data: { id: 'ses_tmp' } }),
246
- prompt: vi.fn(),
247
- promptAsync,
248
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
249
- messages: async () => ({ data: [completedAssistant('cannot see images')] }),
250
- delete: async () => ({ data: true }),
251
- },
252
- tool: { ids: async () => ({ data: [] }) },
253
- }))
177
+ expect(_test.imageFilesForSession([image, file], false)).toEqual([])
178
+
179
+ const text = vi.fn(async () => ({ text: 'cannot see images' }))
180
+ const create = vi.fn()
254
181
  await generateOpenCodeText({
255
182
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
256
183
  getOpenCodeAuthHeaders: () => ({}),
257
184
  providerID: 'opencode',
258
185
  modelID: 'deepseek-v4-flash',
259
186
  messages: [{ role: 'user', content: 'look', parts: [image, file] }],
260
- clientFactory: createOpencodeClient,
261
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
262
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
187
+ clientFactory: () => ({
188
+ generate: { text },
189
+ session: { create },
190
+ }),
191
+ forwardImageParts: false,
263
192
  })
264
- expect(promptAsync.mock.calls[0][0].parts).toEqual([
265
- expect.objectContaining({
266
- type: 'text',
267
- text: expect.stringContaining('[image: shot.png (image/png)]'),
193
+ expect(create).not.toHaveBeenCalled()
194
+ expect(text.mock.calls[0][0].prompt).toContain('[image: shot.png (image/png)]')
195
+ expect(text.mock.calls[0][0].prompt).toContain('[file: notes.txt (text/plain)]')
196
+ })
197
+ })
198
+
199
+ describe('generateOpenCodeText — attachment session path', () => {
200
+ it('verifies deny-all before prompt, prompts with files, waits, lists, and removes', async () => {
201
+ const agentGet = vi.fn(async () => denyAllAgent)
202
+ const create = vi.fn(async () => ({ id: 'ses_tmp' }))
203
+ const put = vi.fn(async () => undefined)
204
+ const prompt = vi.fn(async () => ({ id: 'inbox_1', type: 'user' }))
205
+ const wait = vi.fn(async () => undefined)
206
+ const list = vi.fn(async () => ({ data: [completedAssistant('saw it')], cursor: {} }))
207
+ const remove = vi.fn(async () => undefined)
208
+ const interrupt = vi.fn(async () => undefined)
209
+ const text = vi.fn()
210
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
211
+
212
+ const result = await generateOpenCodeText({
213
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
214
+ getOpenCodeAuthHeaders: () => ({}),
215
+ providerID: 'opencode',
216
+ modelID: 'gpt-4o',
217
+ messages: [
218
+ { role: 'system', content: 'Be careful' },
219
+ { role: 'user', content: 'look', parts: [image] },
220
+ ],
221
+ variant: 'high',
222
+ clientFactory: () => ({
223
+ generate: { text },
224
+ agent: { get: agentGet },
225
+ session: {
226
+ create,
227
+ prompt,
228
+ wait,
229
+ interrupt,
230
+ remove,
231
+ instructions: { entry: { put } },
232
+ },
233
+ message: { list },
268
234
  }),
269
- file,
270
- ])
271
- expect(promptAsync.mock.calls[0][0].parts.some((part) => part.type === 'file' && part.mime.startsWith('image/'))).toBe(false)
235
+ ensureTempDirectory: async ({ agentMarkdown }) => {
236
+ expect(agentMarkdown).toContain('openchamber-tool JSON fences')
237
+ expect(agentMarkdown).toMatch(/action:\s*"\*"/)
238
+ expect(agentMarkdown).toMatch(/effect:\s*deny/)
239
+ return '/tmp/openchamber-llm'
240
+ },
241
+ forwardImageParts: true,
242
+ })
243
+
244
+ expect(result).toEqual({ text: 'saw it', source: 'attachment-session' })
245
+ expect(agentGet).toHaveBeenCalledWith(expect.objectContaining({
246
+ agentID: 'openchamber-llm',
247
+ location: { directory: '/tmp/openchamber-llm' },
248
+ }), expect.anything())
249
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({
250
+ agent: 'openchamber-llm',
251
+ model: { id: 'gpt-4o', providerID: 'opencode', variant: 'high' },
252
+ location: { directory: '/tmp/openchamber-llm' },
253
+ }), expect.anything())
254
+ expect(put).toHaveBeenCalledWith(expect.objectContaining({
255
+ sessionID: 'ses_tmp',
256
+ key: 'system',
257
+ value: 'Be careful',
258
+ }), expect.anything())
259
+ // agent.get must complete before prompt
260
+ expect(agentGet.mock.invocationCallOrder[0]).toBeLessThan(prompt.mock.invocationCallOrder[0])
261
+ expect(prompt).toHaveBeenCalledWith(expect.objectContaining({
262
+ sessionID: 'ses_tmp',
263
+ text: expect.stringContaining('User: look'),
264
+ files: [{ uri: image.url, name: 'shot.png' }],
265
+ delivery: 'steer',
266
+ }), expect.anything())
267
+ expect(wait).toHaveBeenCalledWith({ sessionID: 'ses_tmp' }, expect.anything())
268
+ expect(list).toHaveBeenCalledWith(expect.objectContaining({
269
+ sessionID: 'ses_tmp',
270
+ order: 'desc',
271
+ }), expect.anything())
272
+ expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_tmp' })
273
+ expect(text).not.toHaveBeenCalled()
272
274
  })
273
275
 
274
- it('surfaces the OpenCode assistant error string after promptAsync 204', async () => {
275
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
276
+ it('fails with llm_attachment_generation_unavailable before prompt when deny-all is missing', async () => {
276
277
  const prompt = vi.fn()
277
- const createOpencodeClient = vi.fn(() => ({
278
- session: {
279
- create: async () => ({ data: { id: 'ses_tmp' } }),
280
- update: async () => ({ data: { id: 'ses_tmp' } }),
281
- prompt,
282
- promptAsync,
283
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
284
- messages: async () => ({
285
- data: [{
286
- info: { role: 'assistant', error: { message: 'model refused the request' }, time: { completed: Date.now() } },
287
- parts: [],
288
- }],
289
- }),
290
- delete: async () => ({ data: true }),
278
+ const remove = vi.fn()
279
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
280
+
281
+ await expect(generateOpenCodeText({
282
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
283
+ getOpenCodeAuthHeaders: () => ({}),
284
+ providerID: 'opencode',
285
+ modelID: 'gpt-4o',
286
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
287
+ clientFactory: () => ({
288
+ agent: {
289
+ get: async () => ({
290
+ data: {
291
+ id: 'openchamber-llm',
292
+ permissions: [
293
+ { action: 'read', resource: 'file', effect: 'allow' },
294
+ ],
295
+ },
296
+ }),
297
+ },
298
+ session: {
299
+ create: async () => ({ id: 'ses_tmp' }),
300
+ prompt,
301
+ remove,
302
+ },
303
+ }),
304
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
305
+ forwardImageParts: true,
306
+ })).rejects.toMatchObject({
307
+ code: 'llm_attachment_generation_unavailable',
308
+ })
309
+ expect(prompt).not.toHaveBeenCalled()
310
+ })
311
+
312
+ it('removes the session when wait fails and still cleans hub listeners', async () => {
313
+ const subscribers = new Set()
314
+ const globalEventHub = {
315
+ subscribeEvent(fn) {
316
+ subscribers.add(fn)
317
+ return () => { subscribers.delete(fn) }
291
318
  },
292
- tool: { ids: async () => ({ data: [] }) },
293
- }))
319
+ }
320
+ const remove = vi.fn(async () => undefined)
321
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
294
322
 
295
323
  await expect(generateOpenCodeText({
296
324
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
297
325
  getOpenCodeAuthHeaders: () => ({}),
298
326
  providerID: 'opencode',
299
- modelID: 'gpt-5-nano',
300
- messages: [{ role: 'user', content: 'hi' }],
301
- clientFactory: createOpencodeClient,
327
+ modelID: 'gpt-4o',
328
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
329
+ clientFactory: () => ({
330
+ agent: { get: async () => denyAllAgent },
331
+ session: {
332
+ create: async () => ({ id: 'ses_tmp' }),
333
+ prompt: async () => ({ id: 'inbox' }),
334
+ wait: async () => {
335
+ throw new Error('wait boom')
336
+ },
337
+ remove,
338
+ interrupt: vi.fn(),
339
+ instructions: { entry: { put: async () => {} } },
340
+ },
341
+ message: { list: vi.fn() },
342
+ }),
302
343
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
303
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
344
+ forwardImageParts: true,
345
+ onTextDelta: vi.fn(),
346
+ globalEventHub,
304
347
  })).rejects.toMatchObject({
305
348
  code: 'upstream_error',
306
- message: 'model refused the request',
349
+ message: 'wait boom',
307
350
  })
308
- expect(prompt).not.toHaveBeenCalled()
351
+ expect(remove).toHaveBeenCalledWith({ sessionID: 'ses_tmp' })
352
+ expect(subscribers.size).toBe(0)
309
353
  })
310
354
 
311
- it('falls through to promptAsync when the /generate probe returns HTML 200', async () => {
312
- const fetchImpl = vi.fn(async () => new Response('<!doctype html>', {
313
- status: 200,
314
- headers: { 'Content-Type': 'text/html' },
315
- }))
316
- const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
317
- const prompt = vi.fn()
318
- const result = await generateOpenCodeText({
355
+ it('surfaces assistant error from message.list and cleans up', async () => {
356
+ const remove = vi.fn(async () => undefined)
357
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
358
+
359
+ await expect(generateOpenCodeText({
319
360
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
320
361
  getOpenCodeAuthHeaders: () => ({}),
321
362
  providerID: 'opencode',
322
- modelID: 'glm-5.3-flash',
323
- messages: [{ role: 'user', content: 'hi' }],
324
- fetchImpl,
363
+ modelID: 'gpt-4o',
364
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
325
365
  clientFactory: () => ({
366
+ agent: { get: async () => denyAllAgent },
326
367
  session: {
327
- create: async () => ({ data: { id: 'ses_tmp' } }),
328
- update: async () => ({ data: { id: 'ses_tmp' } }),
329
- prompt,
330
- promptAsync,
331
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
332
- messages: async () => ({ data: [completedAssistant('reply')] }),
333
- delete: async () => ({ data: true }),
368
+ create: async () => ({ id: 'ses_tmp' }),
369
+ prompt: async () => ({ id: 'inbox' }),
370
+ wait: async () => undefined,
371
+ remove,
372
+ interrupt: vi.fn(),
373
+ instructions: { entry: { put: async () => {} } },
374
+ },
375
+ message: {
376
+ list: async () => ({
377
+ data: [completedAssistant('', {
378
+ finish: 'error',
379
+ error: { message: 'model refused the request' },
380
+ content: [],
381
+ })],
382
+ cursor: {},
383
+ }),
334
384
  },
335
- tool: { ids: async () => ({ data: [] }) },
336
385
  }),
337
386
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
387
+ forwardImageParts: true,
388
+ })).rejects.toMatchObject({
389
+ code: 'upstream_error',
390
+ message: 'model refused the request',
338
391
  })
339
- expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
340
- expect(promptAsync).toHaveBeenCalled()
341
- expect(prompt).not.toHaveBeenCalled()
392
+ expect(remove).toHaveBeenCalled()
342
393
  })
343
394
 
344
- it('forwards filtered throwaway session text deltas via onTextDelta and still returns full text', async () => {
395
+ it('forwards filtered session.text.delta tokens via onTextDelta and still returns full text', async () => {
345
396
  const subscribers = new Set()
346
397
  const globalEventHub = {
347
398
  subscribeEvent(fn) {
@@ -354,112 +405,106 @@ describe('generateOpenCodeText', () => {
354
405
  }
355
406
  const deltas = []
356
407
  const onTextDelta = vi.fn((text) => { deltas.push(text) })
408
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
357
409
 
358
- const promptAsync = vi.fn(async () => {
359
- // Same-session text deltas
410
+ const prompt = vi.fn(async () => {
360
411
  globalEventHub.emit({
361
412
  payload: {
362
- type: 'message.part.delta',
363
- properties: {
413
+ type: 'session.text.delta',
414
+ data: {
364
415
  sessionID: 'ses_tmp',
365
- messageID: 'msg_asst',
366
- partID: 'prt_1',
367
- field: 'text',
416
+ assistantMessageID: 'msg_asst',
417
+ ordinal: 0,
368
418
  delta: 'Hel',
369
419
  },
370
420
  },
371
421
  })
372
422
  globalEventHub.emit({
373
423
  payload: {
374
- type: 'message.part.delta',
375
- properties: {
424
+ type: 'session.text.delta',
425
+ data: {
376
426
  sessionID: 'ses_tmp',
377
- messageID: 'msg_asst',
378
- partID: 'prt_1',
379
- field: 'text',
427
+ assistantMessageID: 'msg_asst',
428
+ ordinal: 1,
380
429
  delta: 'lo',
381
430
  },
382
431
  },
383
432
  })
384
- // Other session — must be ignored
433
+ // Other session — ignored
385
434
  globalEventHub.emit({
386
435
  payload: {
387
- type: 'message.part.delta',
388
- properties: {
436
+ type: 'session.text.delta',
437
+ data: {
389
438
  sessionID: 'ses_other',
390
- messageID: 'msg_other',
391
- partID: 'prt_x',
392
- field: 'text',
439
+ assistantMessageID: 'msg_other',
440
+ ordinal: 0,
393
441
  delta: 'NOPE',
394
442
  },
395
443
  },
396
444
  })
397
- // Same session, different messageID after lock — ignored
445
+ // Same session, different assistantMessageID after lock — ignored
398
446
  globalEventHub.emit({
399
447
  payload: {
400
- type: 'message.part.delta',
401
- properties: {
448
+ type: 'session.text.delta',
449
+ data: {
402
450
  sessionID: 'ses_tmp',
403
- messageID: 'msg_other_asst',
404
- partID: 'prt_2',
405
- field: 'text',
451
+ assistantMessageID: 'msg_other_asst',
452
+ ordinal: 2,
406
453
  delta: 'SKIP',
407
454
  },
408
455
  },
409
456
  })
410
- // Non-text field — ignored
457
+ // Stale ordinal — ignored
411
458
  globalEventHub.emit({
412
459
  payload: {
413
- type: 'message.part.delta',
414
- properties: {
460
+ type: 'session.text.delta',
461
+ data: {
415
462
  sessionID: 'ses_tmp',
416
- messageID: 'msg_asst',
417
- partID: 'prt_1',
418
- field: 'reasoning',
419
- delta: 'think',
463
+ assistantMessageID: 'msg_asst',
464
+ ordinal: 1,
465
+ delta: 'DUP',
420
466
  },
421
467
  },
422
468
  })
423
- return { response: { status: 204 } }
469
+ return { id: 'inbox' }
424
470
  })
425
471
 
426
472
  const result = await generateOpenCodeText({
427
473
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
428
474
  getOpenCodeAuthHeaders: () => ({}),
429
475
  providerID: 'opencode',
430
- modelID: 'gpt-5-nano',
431
- messages: [{ role: 'user', content: 'hi' }],
476
+ modelID: 'gpt-4o',
477
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
432
478
  clientFactory: () => ({
479
+ agent: { get: async () => denyAllAgent },
433
480
  session: {
434
- create: async () => ({ data: { id: 'ses_tmp' } }),
435
- update: async () => ({ data: { id: 'ses_tmp' } }),
436
- prompt: vi.fn(),
437
- promptAsync,
438
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
439
- messages: async () => ({ data: [completedAssistant('Hello')] }),
440
- delete: async () => ({ data: true }),
481
+ create: async () => ({ id: 'ses_tmp' }),
482
+ prompt,
483
+ wait: async () => undefined,
484
+ remove: async () => undefined,
485
+ interrupt: vi.fn(),
486
+ instructions: { entry: { put: async () => {} } },
487
+ },
488
+ message: {
489
+ list: async () => ({ data: [completedAssistant('Hello')], cursor: {} }),
441
490
  },
442
- tool: { ids: async () => ({ data: [] }) },
443
491
  }),
444
492
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
445
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
493
+ forwardImageParts: true,
446
494
  onTextDelta,
447
495
  globalEventHub,
448
496
  })
449
497
 
450
- expect(result).toEqual({ text: 'Hello', source: 'throwaway-session' })
498
+ expect(result).toEqual({ text: 'Hello', source: 'attachment-session' })
451
499
  expect(deltas).toEqual(['Hel', 'lo'])
452
- expect(onTextDelta).toHaveBeenCalledTimes(2)
453
- // Listener removed after complete — further emits must not reach onTextDelta
454
500
  expect(subscribers.size).toBe(0)
455
501
  globalEventHub.emit({
456
502
  payload: {
457
- type: 'message.part.delta',
458
- properties: {
503
+ type: 'session.text.delta',
504
+ data: {
459
505
  sessionID: 'ses_tmp',
460
- messageID: 'msg_asst',
461
- partID: 'prt_1',
462
- field: 'text',
506
+ assistantMessageID: 'msg_asst',
507
+ ordinal: 3,
463
508
  delta: 'late',
464
509
  },
465
510
  },
@@ -467,211 +512,232 @@ describe('generateOpenCodeText', () => {
467
512
  expect(onTextDelta).toHaveBeenCalledTimes(2)
468
513
  })
469
514
 
470
- it('removes the hub listener when generate fails after subscribe', async () => {
471
- const subscribers = new Set()
472
- const globalEventHub = {
473
- subscribeEvent(fn) {
474
- subscribers.add(fn)
475
- return () => { subscribers.delete(fn) }
476
- },
477
- }
478
- const onTextDelta = vi.fn()
479
-
480
- await expect(generateOpenCodeText({
481
- buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
482
- getOpenCodeAuthHeaders: () => ({}),
483
- providerID: 'opencode',
484
- modelID: 'gpt-5-nano',
485
- messages: [{ role: 'user', content: 'hi' }],
486
- clientFactory: () => ({
487
- session: {
488
- create: async () => ({ data: { id: 'ses_tmp' } }),
489
- update: async () => ({ data: { id: 'ses_tmp' } }),
490
- prompt: vi.fn(),
491
- promptAsync: async () => ({ response: { status: 204 } }),
492
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
493
- messages: async () => ({
494
- data: [{
495
- info: { role: 'assistant', error: { message: 'boom' }, time: { completed: Date.now() } },
496
- parts: [],
497
- }],
498
- }),
499
- delete: async () => ({ data: true }),
500
- },
501
- tool: { ids: async () => ({ data: [] }) },
502
- }),
503
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
504
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
505
- onTextDelta,
506
- globalEventHub,
507
- })).rejects.toMatchObject({ code: 'upstream_error', message: 'boom' })
508
-
509
- expect(subscribers.size).toBe(0)
510
- })
511
-
512
- it('throws when tool.ids returns an SDK error instead of denying nothing', async () => {
513
- const remove = vi.fn(async () => ({ data: true }))
514
- await expect(generateOpenCodeText({
515
- buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
516
- getOpenCodeAuthHeaders: () => ({}),
517
- providerID: 'opencode',
518
- modelID: 'gpt-5-nano',
519
- messages: [{ role: 'user', content: 'hi' }],
520
- clientFactory: () => ({
521
- session: {
522
- create: vi.fn(async () => ({ data: { id: 'ses_tmp' } })),
523
- update: vi.fn(),
524
- prompt: vi.fn(),
525
- promptAsync: vi.fn(),
526
- status: vi.fn(),
527
- messages: vi.fn(),
528
- delete: remove,
529
- },
530
- tool: { ids: async () => ({ error: { status: 500, message: 'tool catalog unavailable' } }) },
531
- }),
532
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
533
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
534
- })).rejects.toMatchObject({
535
- code: 'upstream_error',
536
- message: expect.stringContaining('tool.ids failed'),
537
- })
538
- expect(remove).not.toHaveBeenCalled()
539
- })
540
-
541
- it('throws when session.messages returns a deterministic 400', async () => {
542
- const remove = vi.fn(async () => ({ data: true }))
543
- await expect(generateOpenCodeText({
544
- buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
545
- getOpenCodeAuthHeaders: () => ({}),
546
- providerID: 'opencode',
547
- modelID: 'gpt-5-nano',
548
- messages: [{ role: 'user', content: 'hi' }],
549
- clientFactory: () => ({
550
- session: {
551
- create: async () => ({ data: { id: 'ses_tmp' } }),
552
- update: async () => ({ data: { id: 'ses_tmp' } }),
553
- prompt: vi.fn(),
554
- promptAsync: async () => ({ response: { status: 204 } }),
555
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
556
- messages: async () => ({ error: { status: 400, message: 'OutputFormatJsonSchema' } }),
557
- delete: remove,
558
- },
559
- tool: { ids: async () => ({ data: ['bash'] }) },
560
- }),
561
- ensureTempDirectory: async () => '/tmp/openchamber-llm',
562
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
563
- })).rejects.toMatchObject({
564
- code: 'upstream_error',
565
- message: expect.stringMatching(/session\.messages failed.*400|OutputFormatJsonSchema/),
566
- })
567
- expect(remove).toHaveBeenCalled()
568
- })
569
-
570
- it('logs SDK delete errors without erasing a successful generate result', async () => {
515
+ it('logs remove errors without erasing a successful generate result', async () => {
571
516
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
517
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
572
518
  try {
573
519
  const result = await generateOpenCodeText({
574
520
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
575
521
  getOpenCodeAuthHeaders: () => ({}),
576
522
  providerID: 'opencode',
577
- modelID: 'gpt-5-nano',
578
- messages: [{ role: 'user', content: 'hi' }],
523
+ modelID: 'gpt-4o',
524
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
579
525
  clientFactory: () => ({
526
+ agent: { get: async () => denyAllAgent },
580
527
  session: {
581
- create: async () => ({ data: { id: 'ses_tmp' } }),
582
- update: async () => ({ data: { id: 'ses_tmp' } }),
583
- prompt: vi.fn(),
584
- promptAsync: async () => ({ response: { status: 204 } }),
585
- status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
586
- messages: async () => ({ data: [completedAssistant('ok')] }),
587
- delete: async () => ({ error: { status: 500, message: 'delete denied' } }),
528
+ create: async () => ({ id: 'ses_tmp' }),
529
+ prompt: async () => ({ id: 'inbox' }),
530
+ wait: async () => undefined,
531
+ remove: async () => {
532
+ throw new Error('delete denied')
533
+ },
534
+ interrupt: vi.fn(),
535
+ instructions: { entry: { put: async () => {} } },
536
+ },
537
+ message: {
538
+ list: async () => ({ data: [completedAssistant('ok')], cursor: {} }),
588
539
  },
589
- tool: { ids: async () => ({ data: [] }) },
590
540
  }),
591
541
  ensureTempDirectory: async () => '/tmp/openchamber-llm',
592
- detect: async () => ({ available: false, mode: 'throwaway-session' }),
542
+ forwardImageParts: true,
593
543
  })
594
- expect(result).toEqual({ text: 'ok', source: 'throwaway-session' })
544
+ expect(result).toEqual({ text: 'ok', source: 'attachment-session' })
595
545
  expect(warn).toHaveBeenCalledWith(
596
- expect.stringContaining('[llm] failed to delete throwaway OpenCode session:'),
546
+ expect.stringContaining('[llm] failed to remove throwaway OpenCode session:'),
597
547
  expect.stringContaining('delete denied'),
598
548
  )
599
549
  } finally {
600
550
  warn.mockRestore()
601
551
  }
602
552
  })
553
+ })
603
554
 
604
- it('does not invent deltas on the sessionless /generate JSON path', async () => {
605
- const onTextDelta = vi.fn()
606
- const subscribers = new Set()
607
- const globalEventHub = {
608
- subscribeEvent(fn) {
609
- subscribers.add(fn)
610
- return () => { subscribers.delete(fn) }
611
- },
612
- }
613
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'full reply' }), {
614
- status: 200,
615
- headers: { 'Content-Type': 'application/json' },
616
- }))
555
+ describe('subscribeThrowawayTextDeltas', () => {
556
+ it('returns null without a callback or hub', () => {
557
+ expect(_test.subscribeThrowawayTextDeltas({
558
+ sessionID: 'ses_1',
559
+ onTextDelta: null,
560
+ globalEventHub: { subscribeEvent: () => () => {} },
561
+ })).toBeNull()
562
+ expect(_test.subscribeThrowawayTextDeltas({
563
+ sessionID: 'ses_1',
564
+ onTextDelta: () => {},
565
+ globalEventHub: null,
566
+ })).toBeNull()
567
+ })
568
+ })
569
+
570
+ describe('data URL validation', () => {
571
+ it('rejects oversized attachments', () => {
572
+ const huge = Buffer.alloc(_test.MAX_ATTACHMENT_BYTES + 1, 1).toString('base64')
573
+ expect(() => _test.parseDataUrl(`data:image/png;base64,${huge}`)).toThrow(/limit/)
574
+ })
575
+
576
+ it('accepts small valid data URLs', () => {
577
+ const parsed = _test.parseDataUrl('data:image/png;base64,aa')
578
+ expect(parsed?.mime).toBe('image/png')
579
+ expect(parsed?.byteLength).toBeGreaterThan(0)
580
+ })
581
+ })
582
+
583
+ describe('generateOpenCodeText — real OpenCode.make + fake HTTP', () => {
584
+ it('catalog-shaped generate.text completion through the real client', async () => {
585
+ const { OpenCode } = await import('@opencode/client')
586
+ const fetchImpl = vi.fn(async (url, init) => {
587
+ const path = String(url)
588
+ if (path.includes('/api/experimental/generate') && init?.method === 'POST') {
589
+ const body = JSON.parse(init.body)
590
+ expect(body.model).toEqual({ id: 'gpt-5-nano', providerID: 'opencode' })
591
+ expect(body.prompt).toContain('User: hi')
592
+ return jsonResponse(200, { data: { text: 'hello from generate' } })
593
+ }
594
+ return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${path}` })
595
+ })
617
596
 
618
597
  const result = await generateOpenCodeText({
619
598
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
620
- getOpenCodeAuthHeaders: () => ({}),
599
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
621
600
  providerID: 'opencode',
622
601
  modelID: 'gpt-5-nano',
623
602
  messages: [{ role: 'user', content: 'hi' }],
624
- fetchImpl,
625
- detect: async () => ({ available: true, mode: 'http', url: 'http://127.0.0.1:4096/generate' }),
626
- onTextDelta,
627
- globalEventHub,
603
+ clientFactory: () => OpenCode.make({
604
+ baseUrl: 'http://127.0.0.1:4096',
605
+ headers: { Authorization: 'Basic test' },
606
+ fetch: fetchImpl,
607
+ }),
628
608
  })
629
609
 
630
- expect(result).toEqual({ text: 'full reply', source: 'generate' })
631
- expect(onTextDelta).not.toHaveBeenCalled()
632
- expect(subscribers.size).toBe(0)
610
+ expect(result).toEqual({ text: 'hello from generate', source: 'generate.text' })
611
+ expect(fetchImpl).toHaveBeenCalled()
633
612
  })
634
- })
635
613
 
636
- describe('generate stall timeout', () => {
637
- it('keeps waiting while the throwaway session stays busy past the stall deadline', async () => {
638
- let polls = 0
639
- const result = await throwawayGenerate(throwawayClient({
640
- status: async () => {
641
- polls += 1
642
- return { data: { ses_tmp: { type: polls < 12 ? 'busy' : 'idle' } } }
643
- },
644
- messages: async () => ({ data: [completedAssistant('late reply')] }),
645
- }), {
646
- timeoutMs: 50,
647
- settlePollMs: 10,
614
+ it('attachment deny-all failure happens before prompt on the real client path shape', async () => {
615
+ const { OpenCode } = await import('@opencode/client')
616
+ let prompted = false
617
+ const fetchImpl = vi.fn(async (url, init) => {
618
+ const path = String(url)
619
+ if (path.includes('/api/agent/openchamber-llm')) {
620
+ return jsonResponse(200, {
621
+ location: { directory: '/tmp/openchamber-llm', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
622
+ data: {
623
+ id: 'openchamber-llm',
624
+ name: 'openchamber-llm',
625
+ mode: 'primary',
626
+ hidden: true,
627
+ permissions: [{ action: 'bash', resource: '*', effect: 'allow' }],
628
+ request: {},
629
+ },
630
+ })
631
+ }
632
+ if (path.includes('/prompt')) {
633
+ prompted = true
634
+ return jsonResponse(200, { data: { id: 'inbox' } })
635
+ }
636
+ if (path.endsWith('/api/session') && init?.method === 'POST') {
637
+ return jsonResponse(200, {
638
+ data: {
639
+ id: 'ses_tmp',
640
+ projectID: 'p',
641
+ cost: 0,
642
+ tokens: {},
643
+ time: { created: 1, updated: 1 },
644
+ location: { directory: '/tmp/openchamber-llm' },
645
+ },
646
+ })
647
+ }
648
+ return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${path}` })
648
649
  })
649
- expect(polls).toBeGreaterThan(5)
650
- expect(result).toEqual({ text: 'late reply', source: 'throwaway-session' })
651
- })
652
650
 
653
- it('times out when throwaway settle makes no progress', async () => {
654
- await expect(throwawayGenerate(throwawayClient({
655
- status: async () => ({ data: {} }),
656
- messages: async () => ({ data: [] }),
657
- }), {
658
- timeoutMs: 40,
659
- settlePollMs: 10,
660
- })).rejects.toThrow(/timed out after 40ms without progress/)
651
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
652
+ await expect(generateOpenCodeText({
653
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
654
+ getOpenCodeAuthHeaders: () => ({}),
655
+ providerID: 'opencode',
656
+ modelID: 'gpt-4o',
657
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
658
+ clientFactory: () => OpenCode.make({
659
+ baseUrl: 'http://127.0.0.1:4096',
660
+ fetch: fetchImpl,
661
+ }),
662
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
663
+ forwardImageParts: true,
664
+ })).rejects.toMatchObject({ code: 'llm_attachment_generation_unavailable' })
665
+ expect(prompted).toBe(false)
661
666
  })
662
- })
663
667
 
664
- describe('subscribeThrowawayTextDeltas', () => {
665
- it('returns null without a callback or hub', () => {
666
- expect(_test.subscribeThrowawayTextDeltas({
667
- sessionID: 'ses_1',
668
- onTextDelta: null,
669
- globalEventHub: { subscribeEvent: () => () => {} },
670
- })).toBeNull()
671
- expect(_test.subscribeThrowawayTextDeltas({
672
- sessionID: 'ses_1',
673
- onTextDelta: () => {},
674
- globalEventHub: null,
675
- })).toBeNull()
668
+ it('attachment success path removes the session after message.list', async () => {
669
+ const { OpenCode } = await import('@opencode/client')
670
+ const calls = []
671
+ const fetchImpl = vi.fn(async (url, init) => {
672
+ const path = String(url)
673
+ const method = init?.method || 'GET'
674
+ calls.push(`${method} ${path.replace(/^https?:\/\/[^/]+/, '')}`)
675
+ if (path.includes('/api/agent/openchamber-llm')) {
676
+ return jsonResponse(200, {
677
+ location: { directory: '/tmp/openchamber-llm', project: { id: 'p', directory: '/tmp', canonical: '/tmp' } },
678
+ data: {
679
+ id: 'openchamber-llm',
680
+ name: 'openchamber-llm',
681
+ mode: 'primary',
682
+ hidden: true,
683
+ permissions: [{ action: '*', resource: '*', effect: 'deny' }],
684
+ request: {},
685
+ },
686
+ })
687
+ }
688
+ if (path.endsWith('/api/session') && method === 'POST') {
689
+ return jsonResponse(200, {
690
+ data: {
691
+ id: 'ses_tmp',
692
+ projectID: 'p',
693
+ cost: 0,
694
+ tokens: {},
695
+ time: { created: 1, updated: 1 },
696
+ location: { directory: '/tmp/openchamber-llm' },
697
+ },
698
+ })
699
+ }
700
+ if (path.includes('/instructions/entries/')) {
701
+ return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
702
+ }
703
+ if (path.includes('/prompt')) {
704
+ return jsonResponse(200, { data: { id: 'inbox_1', type: 'user', text: 'look' } })
705
+ }
706
+ if (path.includes('/wait')) {
707
+ return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
708
+ }
709
+ if (path.includes('/message') && method === 'GET') {
710
+ return jsonResponse(200, {
711
+ data: [completedAssistant('vision ok')],
712
+ cursor: {},
713
+ })
714
+ }
715
+ if (path.includes('/api/session/ses_tmp') && method === 'DELETE') {
716
+ return { ok: true, status: 204, headers: { get: () => null }, text: async () => '', json: async () => null, arrayBuffer: async () => new ArrayBuffer(0) }
717
+ }
718
+ return jsonResponse(500, { _tag: 'UnknownError', message: `unexpected ${method} ${path}` })
719
+ })
720
+
721
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
722
+ const result = await generateOpenCodeText({
723
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
724
+ getOpenCodeAuthHeaders: () => ({}),
725
+ providerID: 'opencode',
726
+ modelID: 'gpt-4o',
727
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
728
+ clientFactory: () => OpenCode.make({
729
+ baseUrl: 'http://127.0.0.1:4096',
730
+ fetch: fetchImpl,
731
+ }),
732
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
733
+ forwardImageParts: true,
734
+ })
735
+
736
+ expect(result).toEqual({ text: 'vision ok', source: 'attachment-session' })
737
+ expect(calls.some((c) => c.startsWith('DELETE ') && c.includes('/api/session/ses_tmp'))).toBe(true)
738
+ const agentIdx = calls.findIndex((c) => c.includes('/api/agent/openchamber-llm'))
739
+ const promptIdx = calls.findIndex((c) => c.includes('/prompt'))
740
+ expect(agentIdx).toBeGreaterThanOrEqual(0)
741
+ expect(promptIdx).toBeGreaterThan(agentIdx)
676
742
  })
677
743
  })