@openchambery/web 1.19.7-beta.13 → 1.19.7-beta.14

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 (118) hide show
  1. package/dist/assets/{AgentAvatar-Dp_hbVcD.js → AgentAvatar-BsU82CEP.js} +1 -1
  2. package/dist/assets/AssistantView-CKNR7FFf.js +2 -0
  3. package/dist/assets/{DiagramView-iX3nhWaN.js → DiagramView-Db1VvSIR.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-tDry9J76.js → MarkdownRendererImpl-NBG7O4aX.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BDVGi72n.js → MarkstreamRendererImpl-Bs829ng-.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-CooM1BN7.js +1 -0
  7. package/dist/assets/{MobileShareBridge-BQ79cuPX.js → MobileShareBridge-CcG4lLaD.js} +3 -3
  8. package/dist/assets/MobileSurface-c_7RkYa2.js +2 -0
  9. package/dist/assets/{MultiRunWindow-CzMbzYD3.js → MultiRunWindow-Ds8nyikm.js} +1 -1
  10. package/dist/assets/{OnboardingScreen-CuvjeXSo.js → OnboardingScreen-BNEtPmvw.js} +1 -1
  11. package/dist/assets/SettingsView-Dac1dDOs.js +25 -0
  12. package/dist/assets/{SettingsWindow-DqwoBYvU.js → SettingsWindow-CHnMwzd6.js} +1 -1
  13. package/dist/assets/{TerminalView-4pFqXfvj.js → TerminalView-DfGueSOI.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-GmD4kIoH.js → ToolOutputDialog-BrR9iqiW.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-BlEYFhXM.js → appThemeRegistry-BEG7NRFm.js} +2 -2
  16. package/dist/assets/{clipboard-Bu0lpyX1.js → clipboard-BXxLshVq.js} +1 -1
  17. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  18. package/dist/assets/{dialog-D21YndEx.js → dialog-BkhHBTd4.js} +1 -1
  19. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  20. package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
  21. package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
  22. package/dist/assets/{gitApi-CciB055B.js → gitApi-Br5EdS29.js} +3 -3
  23. package/dist/assets/insertionBoundaries-ncyYkgWy.js +4 -0
  24. package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
  25. package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
  26. package/dist/assets/main-DX_9iX64.js +169 -0
  27. package/dist/assets/{main-DYFky6Uj.js → main-Dl-XWJxN.js} +2 -2
  28. package/dist/assets/miniChat-Cho-Pywx.js +2 -0
  29. package/dist/assets/mobile-1_JD1EGE.js +2 -0
  30. package/dist/assets/multirun-ByFn3xhO.js +4 -0
  31. package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
  32. package/dist/assets/projectMeta-BHw8kRhQ.js +3 -0
  33. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  34. package/dist/assets/renderElectronMiniChatApp-CvIxnUQY.js +2 -0
  35. package/dist/assets/renderMobileApp-DscicBzZ.js +28 -0
  36. package/dist/assets/{runtimeConfig-DNsnUkyp.js → runtimeConfig-Dkt3p8CZ.js} +1 -1
  37. package/dist/assets/runtimeEndpointReset-hBFsO83_.js +147 -0
  38. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  39. package/dist/assets/sessionLookup-jCEot_fX.js +1 -0
  40. package/dist/assets/{sortable-tabs-strip-Biow4Fqu.js → sortable-tabs-strip-C5gGnikj.js} +1 -1
  41. package/dist/assets/src-KcdsQIuP.css +2 -0
  42. package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
  43. package/dist/assets/textarea-CIO34TZz.js +1 -0
  44. package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
  45. package/dist/assets/{ChatContainer-C04WuLo8.js → useAppFontEffects-kJR0NlNH.js} +44 -39
  46. package/dist/assets/useDesktopWindowControlsLayout-BmbOASBQ.js +1 -0
  47. package/dist/assets/useEffectiveDirectory-DOToiRZd.js +1 -0
  48. package/dist/assets/useMobileNavigationStore-CEmn-d1X.js +412 -0
  49. package/dist/assets/useSessionAutoCleanup-DcmXMo-p.js +1 -0
  50. package/dist/assets/{useWorkerHighlightedLines-BaNK-wuY.js → useWorkerHighlightedLines-r5Ww4sY7.js} +1 -1
  51. package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
  52. package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
  53. package/dist/index.html +3 -3
  54. package/dist/mini-chat.html +3 -3
  55. package/dist/mobile.html +3 -3
  56. package/package.json +2 -1
  57. package/server/index.js +2 -0
  58. package/server/lib/assistant-delivery-parts.js +3 -1
  59. package/server/lib/assistant-delivery-parts.test.js +11 -0
  60. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  61. package/server/lib/assistants/assign.js +225 -0
  62. package/server/lib/assistants/assign.test.js +118 -0
  63. package/server/lib/assistants/bubbles.js +45 -0
  64. package/server/lib/assistants/bubbles.test.js +36 -0
  65. package/server/lib/assistants/cards.js +148 -0
  66. package/server/lib/assistants/cards.test.js +116 -0
  67. package/server/lib/assistants/contact-store.js +284 -0
  68. package/server/lib/assistants/contact-store.test.js +131 -0
  69. package/server/lib/assistants/contact-tools.js +761 -0
  70. package/server/lib/assistants/contact-tools.test.js +328 -0
  71. package/server/lib/assistants/contracts.js +20 -1
  72. package/server/lib/assistants/harness.js +551 -0
  73. package/server/lib/assistants/harness.test.js +607 -0
  74. package/server/lib/assistants/routes.js +44 -4
  75. package/server/lib/assistants/service.js +930 -17
  76. package/server/lib/assistants/service.test.js +1163 -45
  77. package/server/lib/llm/DOCUMENTATION.md +84 -0
  78. package/server/lib/llm/catalog.js +100 -0
  79. package/server/lib/llm/catalog.test.js +78 -0
  80. package/server/lib/llm/completions.js +151 -0
  81. package/server/lib/llm/completions.test.js +127 -0
  82. package/server/lib/llm/generate.js +570 -0
  83. package/server/lib/llm/generate.test.js +459 -0
  84. package/server/lib/llm/routes.js +47 -0
  85. package/server/lib/llm/temp-directory.js +37 -0
  86. package/server/lib/notifications/runtime.js +52 -0
  87. package/server/lib/notifications/runtime.test.js +39 -0
  88. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  89. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  90. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  91. package/server/lib/session-goal/runtime.js +20 -0
  92. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  93. package/server/lib/session-index/service.js +10 -4
  94. package/server/lib/session-index/service.test.js +37 -0
  95. package/server/lib/session-title/runtime.js +1 -0
  96. package/server/lib/session-title/runtime.test.js +11 -0
  97. package/dist/assets/AssistantView-CQHey6je.js +0 -1
  98. package/dist/assets/ContextPanel-BJEFzgz7.js +0 -285
  99. package/dist/assets/MobileDetailNavigation-Dcl7Upvc.js +0 -1
  100. package/dist/assets/MobileSurface-CpAbBAQ5.js +0 -2
  101. package/dist/assets/SettingsView-DRiEDi23.js +0 -25
  102. package/dist/assets/insertionBoundaries-DcS10A9Q.js +0 -4
  103. package/dist/assets/main-Coj1Z4LN.js +0 -29
  104. package/dist/assets/miniChat-DK-d_3-7.js +0 -2
  105. package/dist/assets/mobile-8QzXy5wz.js +0 -2
  106. package/dist/assets/multirun-BFHWrDOR.js +0 -4
  107. package/dist/assets/projectMeta-CEGa4Czs.js +0 -3
  108. package/dist/assets/radio-Bw11T40N.js +0 -1
  109. package/dist/assets/renderElectronMiniChatApp-_fchgm-8.js +0 -2
  110. package/dist/assets/renderMobileApp-B1j1BUXB.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-C-Sm4wpj.js +0 -4
  112. package/dist/assets/shikiHighlight-GaNLBDUk.js +0 -1
  113. package/dist/assets/src-BGao3JmJ.css +0 -2
  114. package/dist/assets/useAppFontEffects-D0UWTJui.js +0 -7
  115. package/dist/assets/useDesktopWindowControlsLayout-_5M7NHJ8.js +0 -1
  116. package/dist/assets/useEffectiveDirectory-DqOLKISv.js +0 -1
  117. package/dist/assets/useMobileNavigationStore-DxH1DjU-.js +0 -412
  118. package/dist/assets/useSessionAutoCleanup-Dpp0-zw2.js +0 -1
@@ -0,0 +1,459 @@
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() },
8
+ },
9
+ parts: [{ type: 'text', text }],
10
+ })
11
+
12
+ describe('detectSessionlessGenerate', () => {
13
+ it('returns unavailable when the probe 404s', async () => {
14
+ const fetchImpl = vi.fn(async () => new Response('missing', { status: 404 }))
15
+ await expect(detectSessionlessGenerate({
16
+ fetchImpl,
17
+ baseUrl: 'http://127.0.0.1:4096',
18
+ headers: {},
19
+ })).resolves.toEqual({ available: false, mode: 'throwaway-session' })
20
+ })
21
+
22
+ it('returns available when the probe returns JSON', async () => {
23
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
24
+ status: 200,
25
+ headers: { 'Content-Type': 'application/json' },
26
+ }))
27
+ await expect(detectSessionlessGenerate({
28
+ fetchImpl,
29
+ baseUrl: 'http://127.0.0.1:4096',
30
+ headers: {},
31
+ })).resolves.toMatchObject({ available: true, mode: 'http' })
32
+ })
33
+
34
+ it('treats HTML 200 on /generate as unavailable', async () => {
35
+ const fetchImpl = vi.fn(async (url) => {
36
+ expect(String(url)).toMatch(/\/generate$/)
37
+ return new Response('<!doctype html><html><body>OpenChamber</body></html>', {
38
+ status: 200,
39
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
40
+ })
41
+ })
42
+ await expect(detectSessionlessGenerate({
43
+ fetchImpl,
44
+ baseUrl: 'http://127.0.0.1:4096',
45
+ headers: {},
46
+ })).resolves.toEqual({ available: false, mode: 'throwaway-session' })
47
+ expect(fetchImpl).toHaveBeenCalled()
48
+ })
49
+ })
50
+
51
+ describe('generateOpenCodeText', () => {
52
+ it('uses promptAsync with model+parts, waits for idle messages, and never calls v2 session.prompt', async () => {
53
+ const create = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
54
+ const update = vi.fn(async () => ({ data: { id: 'ses_tmp' } }))
55
+ const ids = vi.fn(async () => ({ data: ['bash', 'edit'] }))
56
+ const prompt = vi.fn(async () => {
57
+ throw new Error('v2 session.prompt must not be used')
58
+ })
59
+ const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
60
+ const status = vi.fn(async () => ({ data: { ses_tmp: { type: 'idle' } } }))
61
+ const messages = vi.fn(async () => ({ data: [completedAssistant('reply')] }))
62
+ const remove = vi.fn(async () => ({ data: true }))
63
+ const createOpencodeClient = vi.fn(() => ({
64
+ session: { create, update, prompt, promptAsync, status, messages, delete: remove },
65
+ tool: { ids },
66
+ }))
67
+
68
+ const result = await generateOpenCodeText({
69
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
70
+ getOpenCodeAuthHeaders: () => ({}),
71
+ providerID: 'opencode',
72
+ modelID: 'gpt-5-nano',
73
+ messages: [{ role: 'user', content: 'hi' }],
74
+ clientFactory: createOpencodeClient,
75
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
76
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
77
+ })
78
+
79
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({
80
+ title: expect.stringContaining('[openchamber-llm]'),
81
+ }), expect.anything())
82
+ expect(update).toHaveBeenCalled()
83
+ expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({
84
+ sessionID: 'ses_tmp',
85
+ agent: 'openchamber-llm',
86
+ model: { providerID: 'opencode', modelID: 'gpt-5-nano' },
87
+ tools: { bash: false, edit: false },
88
+ parts: [{ type: 'text', text: 'User: hi', synthetic: false }],
89
+ }), expect.anything())
90
+ expect(prompt).not.toHaveBeenCalled()
91
+ expect(status).toHaveBeenCalled()
92
+ expect(messages).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_tmp' }), expect.anything())
93
+ expect(remove).toHaveBeenCalled()
94
+ expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
95
+ })
96
+
97
+ it('forwards contact file parts on promptAsync so the model can see images', async () => {
98
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
99
+ const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
100
+ const flattened = _test.flattenMessages([
101
+ { role: 'user', content: 'look', parts: [image, file] },
102
+ ])
103
+ expect(flattened.prompt).toContain('User: look')
104
+ expect(flattened.prompt).toContain('[image: shot.png (image/png)]')
105
+ expect(flattened.prompt).toContain('[file: notes.txt (text/plain)]')
106
+ expect(flattened.files).toEqual([image, file])
107
+
108
+ const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
109
+ const createOpencodeClient = vi.fn(() => ({
110
+ session: {
111
+ create: async () => ({ data: { id: 'ses_tmp' } }),
112
+ update: async () => ({ data: { id: 'ses_tmp' } }),
113
+ prompt: vi.fn(),
114
+ promptAsync,
115
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
116
+ messages: async () => ({ data: [completedAssistant('saw it')] }),
117
+ delete: async () => ({ data: true }),
118
+ },
119
+ tool: { ids: async () => ({ data: [] }) },
120
+ }))
121
+ await generateOpenCodeText({
122
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
123
+ getOpenCodeAuthHeaders: () => ({}),
124
+ providerID: 'opencode',
125
+ modelID: 'gpt-5-nano',
126
+ messages: [{ role: 'user', content: 'look', parts: [image, file] }],
127
+ clientFactory: createOpencodeClient,
128
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
129
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
130
+ forwardImageParts: true,
131
+ })
132
+ expect(promptAsync.mock.calls[0][0].parts).toEqual([
133
+ expect.objectContaining({ type: 'text', synthetic: false }),
134
+ image,
135
+ file,
136
+ ])
137
+ })
138
+
139
+ it('skips image bytes for non-vision generate and keeps text files plus the image description', async () => {
140
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
141
+ const file = { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==', filename: 'notes.txt' }
142
+ expect(_test.filesForPrompt([image, file], false)).toEqual([file])
143
+ const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
144
+ const createOpencodeClient = vi.fn(() => ({
145
+ session: {
146
+ create: async () => ({ data: { id: 'ses_tmp' } }),
147
+ update: async () => ({ data: { id: 'ses_tmp' } }),
148
+ prompt: vi.fn(),
149
+ promptAsync,
150
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
151
+ messages: async () => ({ data: [completedAssistant('cannot see images')] }),
152
+ delete: async () => ({ data: true }),
153
+ },
154
+ tool: { ids: async () => ({ data: [] }) },
155
+ }))
156
+ await generateOpenCodeText({
157
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
158
+ getOpenCodeAuthHeaders: () => ({}),
159
+ providerID: 'opencode',
160
+ modelID: 'deepseek-v4-flash',
161
+ messages: [{ role: 'user', content: 'look', parts: [image, file] }],
162
+ clientFactory: createOpencodeClient,
163
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
164
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
165
+ })
166
+ expect(promptAsync.mock.calls[0][0].parts).toEqual([
167
+ expect.objectContaining({
168
+ type: 'text',
169
+ text: expect.stringContaining('[image: shot.png (image/png)]'),
170
+ }),
171
+ file,
172
+ ])
173
+ expect(promptAsync.mock.calls[0][0].parts.some((part) => part.type === 'file' && part.mime.startsWith('image/'))).toBe(false)
174
+ })
175
+
176
+ it('surfaces the OpenCode assistant error string after promptAsync 204', async () => {
177
+ const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
178
+ const prompt = vi.fn()
179
+ const createOpencodeClient = vi.fn(() => ({
180
+ session: {
181
+ create: async () => ({ data: { id: 'ses_tmp' } }),
182
+ update: async () => ({ data: { id: 'ses_tmp' } }),
183
+ prompt,
184
+ promptAsync,
185
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
186
+ messages: async () => ({
187
+ data: [{
188
+ info: { role: 'assistant', error: { message: 'model refused the request' }, time: { completed: Date.now() } },
189
+ parts: [],
190
+ }],
191
+ }),
192
+ delete: async () => ({ data: true }),
193
+ },
194
+ tool: { ids: async () => ({ data: [] }) },
195
+ }))
196
+
197
+ await expect(generateOpenCodeText({
198
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
199
+ getOpenCodeAuthHeaders: () => ({}),
200
+ providerID: 'opencode',
201
+ modelID: 'gpt-5-nano',
202
+ messages: [{ role: 'user', content: 'hi' }],
203
+ clientFactory: createOpencodeClient,
204
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
205
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
206
+ })).rejects.toMatchObject({
207
+ code: 'upstream_error',
208
+ message: 'model refused the request',
209
+ })
210
+ expect(prompt).not.toHaveBeenCalled()
211
+ })
212
+
213
+ it('falls through to promptAsync when the /generate probe returns HTML 200', async () => {
214
+ const fetchImpl = vi.fn(async () => new Response('<!doctype html>', {
215
+ status: 200,
216
+ headers: { 'Content-Type': 'text/html' },
217
+ }))
218
+ const promptAsync = vi.fn(async () => ({ response: { status: 204 } }))
219
+ const prompt = vi.fn()
220
+ const result = await generateOpenCodeText({
221
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
222
+ getOpenCodeAuthHeaders: () => ({}),
223
+ providerID: 'opencode',
224
+ modelID: 'glm-5.3-flash',
225
+ messages: [{ role: 'user', content: 'hi' }],
226
+ fetchImpl,
227
+ clientFactory: () => ({
228
+ session: {
229
+ create: async () => ({ data: { id: 'ses_tmp' } }),
230
+ update: async () => ({ data: { id: 'ses_tmp' } }),
231
+ prompt,
232
+ promptAsync,
233
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
234
+ messages: async () => ({ data: [completedAssistant('reply')] }),
235
+ delete: async () => ({ data: true }),
236
+ },
237
+ tool: { ids: async () => ({ data: [] }) },
238
+ }),
239
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
240
+ })
241
+ expect(result).toEqual({ text: 'reply', source: 'throwaway-session' })
242
+ expect(promptAsync).toHaveBeenCalled()
243
+ expect(prompt).not.toHaveBeenCalled()
244
+ })
245
+
246
+ it('forwards filtered throwaway session text deltas via onTextDelta and still returns full text', async () => {
247
+ const subscribers = new Set()
248
+ const globalEventHub = {
249
+ subscribeEvent(fn) {
250
+ subscribers.add(fn)
251
+ return () => { subscribers.delete(fn) }
252
+ },
253
+ emit(event) {
254
+ for (const fn of Array.from(subscribers)) fn(event)
255
+ },
256
+ }
257
+ const deltas = []
258
+ const onTextDelta = vi.fn((text) => { deltas.push(text) })
259
+
260
+ const promptAsync = vi.fn(async () => {
261
+ // Same-session text deltas
262
+ globalEventHub.emit({
263
+ payload: {
264
+ type: 'message.part.delta',
265
+ properties: {
266
+ sessionID: 'ses_tmp',
267
+ messageID: 'msg_asst',
268
+ partID: 'prt_1',
269
+ field: 'text',
270
+ delta: 'Hel',
271
+ },
272
+ },
273
+ })
274
+ globalEventHub.emit({
275
+ payload: {
276
+ type: 'message.part.delta',
277
+ properties: {
278
+ sessionID: 'ses_tmp',
279
+ messageID: 'msg_asst',
280
+ partID: 'prt_1',
281
+ field: 'text',
282
+ delta: 'lo',
283
+ },
284
+ },
285
+ })
286
+ // Other session — must be ignored
287
+ globalEventHub.emit({
288
+ payload: {
289
+ type: 'message.part.delta',
290
+ properties: {
291
+ sessionID: 'ses_other',
292
+ messageID: 'msg_other',
293
+ partID: 'prt_x',
294
+ field: 'text',
295
+ delta: 'NOPE',
296
+ },
297
+ },
298
+ })
299
+ // Same session, different messageID after lock — ignored
300
+ globalEventHub.emit({
301
+ payload: {
302
+ type: 'message.part.delta',
303
+ properties: {
304
+ sessionID: 'ses_tmp',
305
+ messageID: 'msg_other_asst',
306
+ partID: 'prt_2',
307
+ field: 'text',
308
+ delta: 'SKIP',
309
+ },
310
+ },
311
+ })
312
+ // Non-text field — ignored
313
+ globalEventHub.emit({
314
+ payload: {
315
+ type: 'message.part.delta',
316
+ properties: {
317
+ sessionID: 'ses_tmp',
318
+ messageID: 'msg_asst',
319
+ partID: 'prt_1',
320
+ field: 'reasoning',
321
+ delta: 'think',
322
+ },
323
+ },
324
+ })
325
+ return { response: { status: 204 } }
326
+ })
327
+
328
+ const result = await generateOpenCodeText({
329
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
330
+ getOpenCodeAuthHeaders: () => ({}),
331
+ providerID: 'opencode',
332
+ modelID: 'gpt-5-nano',
333
+ messages: [{ role: 'user', content: 'hi' }],
334
+ clientFactory: () => ({
335
+ session: {
336
+ create: async () => ({ data: { id: 'ses_tmp' } }),
337
+ update: async () => ({ data: { id: 'ses_tmp' } }),
338
+ prompt: vi.fn(),
339
+ promptAsync,
340
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
341
+ messages: async () => ({ data: [completedAssistant('Hello')] }),
342
+ delete: async () => ({ data: true }),
343
+ },
344
+ tool: { ids: async () => ({ data: [] }) },
345
+ }),
346
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
347
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
348
+ onTextDelta,
349
+ globalEventHub,
350
+ })
351
+
352
+ expect(result).toEqual({ text: 'Hello', source: 'throwaway-session' })
353
+ expect(deltas).toEqual(['Hel', 'lo'])
354
+ expect(onTextDelta).toHaveBeenCalledTimes(2)
355
+ // Listener removed after complete — further emits must not reach onTextDelta
356
+ expect(subscribers.size).toBe(0)
357
+ globalEventHub.emit({
358
+ payload: {
359
+ type: 'message.part.delta',
360
+ properties: {
361
+ sessionID: 'ses_tmp',
362
+ messageID: 'msg_asst',
363
+ partID: 'prt_1',
364
+ field: 'text',
365
+ delta: 'late',
366
+ },
367
+ },
368
+ })
369
+ expect(onTextDelta).toHaveBeenCalledTimes(2)
370
+ })
371
+
372
+ it('removes the hub listener when generate fails after subscribe', async () => {
373
+ const subscribers = new Set()
374
+ const globalEventHub = {
375
+ subscribeEvent(fn) {
376
+ subscribers.add(fn)
377
+ return () => { subscribers.delete(fn) }
378
+ },
379
+ }
380
+ const onTextDelta = vi.fn()
381
+
382
+ await expect(generateOpenCodeText({
383
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
384
+ getOpenCodeAuthHeaders: () => ({}),
385
+ providerID: 'opencode',
386
+ modelID: 'gpt-5-nano',
387
+ messages: [{ role: 'user', content: 'hi' }],
388
+ clientFactory: () => ({
389
+ session: {
390
+ create: async () => ({ data: { id: 'ses_tmp' } }),
391
+ update: async () => ({ data: { id: 'ses_tmp' } }),
392
+ prompt: vi.fn(),
393
+ promptAsync: async () => ({ response: { status: 204 } }),
394
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
395
+ messages: async () => ({
396
+ data: [{
397
+ info: { role: 'assistant', error: { message: 'boom' }, time: { completed: Date.now() } },
398
+ parts: [],
399
+ }],
400
+ }),
401
+ delete: async () => ({ data: true }),
402
+ },
403
+ tool: { ids: async () => ({ data: [] }) },
404
+ }),
405
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
406
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
407
+ onTextDelta,
408
+ globalEventHub,
409
+ })).rejects.toMatchObject({ code: 'upstream_error', message: 'boom' })
410
+
411
+ expect(subscribers.size).toBe(0)
412
+ })
413
+
414
+ it('does not invent deltas on the sessionless /generate JSON path', async () => {
415
+ const onTextDelta = vi.fn()
416
+ const subscribers = new Set()
417
+ const globalEventHub = {
418
+ subscribeEvent(fn) {
419
+ subscribers.add(fn)
420
+ return () => { subscribers.delete(fn) }
421
+ },
422
+ }
423
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ text: 'full reply' }), {
424
+ status: 200,
425
+ headers: { 'Content-Type': 'application/json' },
426
+ }))
427
+
428
+ const result = await generateOpenCodeText({
429
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
430
+ getOpenCodeAuthHeaders: () => ({}),
431
+ providerID: 'opencode',
432
+ modelID: 'gpt-5-nano',
433
+ messages: [{ role: 'user', content: 'hi' }],
434
+ fetchImpl,
435
+ detect: async () => ({ available: true, mode: 'http', url: 'http://127.0.0.1:4096/generate' }),
436
+ onTextDelta,
437
+ globalEventHub,
438
+ })
439
+
440
+ expect(result).toEqual({ text: 'full reply', source: 'generate' })
441
+ expect(onTextDelta).not.toHaveBeenCalled()
442
+ expect(subscribers.size).toBe(0)
443
+ })
444
+ })
445
+
446
+ describe('subscribeThrowawayTextDeltas', () => {
447
+ it('returns null without a callback or hub', () => {
448
+ expect(_test.subscribeThrowawayTextDeltas({
449
+ sessionID: 'ses_1',
450
+ onTextDelta: null,
451
+ globalEventHub: { subscribeEvent: () => () => {} },
452
+ })).toBeNull()
453
+ expect(_test.subscribeThrowawayTextDeltas({
454
+ sessionID: 'ses_1',
455
+ onTextDelta: () => {},
456
+ globalEventHub: null,
457
+ })).toBeNull()
458
+ })
459
+ })
@@ -0,0 +1,47 @@
1
+ import { createChatCompletion, LlmError } from './completions.js';
2
+ import { loadConnectedCatalog } from './catalog.js';
3
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
4
+ import { ensureLlmTempDirectory } from './temp-directory.js';
5
+
6
+ const fail = (res, error) => {
7
+ const code = error instanceof LlmError ? error.code : error?.code || 'internal_error';
8
+ const status = error instanceof LlmError
9
+ ? error.statusCode
10
+ : code === 'no_provider'
11
+ ? 400
12
+ : code === 'validation_error'
13
+ ? 400
14
+ : code === 'upstream_error'
15
+ ? 502
16
+ : 500;
17
+ res.status(status).json({ ok: false, error: code, message: error?.message || code });
18
+ };
19
+
20
+ export const registerLlmRoutes = (app, dependencies) => {
21
+ const client = () => createOpencodeClient({
22
+ baseUrl: dependencies.buildOpenCodeUrl('/', '').replace(/\/$/, ''),
23
+ headers: dependencies.getOpenCodeAuthHeaders(),
24
+ });
25
+
26
+ app.get('/api/openchamber/llm/models', (_req, res) => {
27
+ Promise.resolve()
28
+ .then(() => loadConnectedCatalog(client()))
29
+ .then((catalog) => res.json({ ok: true, ...catalog }))
30
+ .catch((error) => fail(res, error));
31
+ });
32
+
33
+ app.post('/api/openchamber/llm/chat/completions', (req, res) => {
34
+ Promise.resolve()
35
+ .then(() => createChatCompletion({
36
+ body: req.body,
37
+ buildOpenCodeUrl: dependencies.buildOpenCodeUrl,
38
+ getOpenCodeAuthHeaders: dependencies.getOpenCodeAuthHeaders,
39
+ clientFactory: client,
40
+ ensureTempDirectory: ensureLlmTempDirectory,
41
+ }))
42
+ .then(({ completion }) => {
43
+ res.status(200).json(completion);
44
+ })
45
+ .catch((error) => fail(res, error));
46
+ });
47
+ };
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /** @type {string | null} */
6
+ let tempDirectory = null;
7
+ /** @type {Promise<string> | null} */
8
+ let inflight = null;
9
+
10
+ export async function ensureLlmTempDirectory({ agentName, agentMarkdown }) {
11
+ if (tempDirectory) return tempDirectory;
12
+ if (!inflight) {
13
+ inflight = (async () => {
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-llm-'));
15
+ const agentDir = path.join(root, '.opencode', 'agent');
16
+ await fs.mkdir(agentDir, { recursive: true });
17
+ await fs.writeFile(path.join(agentDir, `${agentName}.md`), agentMarkdown, 'utf8');
18
+ tempDirectory = root;
19
+ return root;
20
+ })().finally(() => {
21
+ inflight = null;
22
+ });
23
+ }
24
+ return inflight;
25
+ }
26
+
27
+ export async function stopLlmTempDirectory() {
28
+ const dir = tempDirectory;
29
+ tempDirectory = null;
30
+ inflight = null;
31
+ if (!dir) return;
32
+ try {
33
+ await fs.rm(dir, { recursive: true, force: true });
34
+ } catch {
35
+ // Best-effort cleanup of the throwaway generator workspace.
36
+ }
37
+ }
@@ -693,6 +693,57 @@ export const createNotificationTriggerRuntime = (deps) => {
693
693
  );
694
694
  };
695
695
 
696
+ const contactNotificationTitle = (name) => {
697
+ if (typeof name !== 'string' || !name.trim()) return 'Assistant';
698
+ const stripped = name.replace(/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\uFE0F\u200D])+\s*/u, '').trim();
699
+ return stripped || name.trim();
700
+ };
701
+
702
+ const sendContactTurnNotification = async ({ assistantID, name, body, status } = {}) => {
703
+ if (typeof assistantID !== 'string' || !assistantID.trim()) return;
704
+ const settings = await readSettingsFromDisk();
705
+ if (settings.notifyOnCompletion === false) return;
706
+
707
+ const title = contactNotificationTitle(name);
708
+ const rawBody = typeof body === 'string' ? body : '';
709
+ let text = '';
710
+ try {
711
+ text = await prepareNotificationLastMessage({ message: rawBody, settings });
712
+ } catch {
713
+ text = '';
714
+ }
715
+ if (!text) text = status === 'error' ? 'Something went wrong' : 'New message';
716
+ const tag = `contact-${assistantID.trim()}`;
717
+
718
+ if (settings.nativeNotificationsEnabled) {
719
+ const notificationPayload = {
720
+ title,
721
+ body: text,
722
+ tag,
723
+ kind: 'ready',
724
+ assistantID: assistantID.trim(),
725
+ requireHidden: false,
726
+ };
727
+ const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
728
+ broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
729
+ }
730
+
731
+ await fanoutPush(
732
+ {
733
+ title,
734
+ body: text,
735
+ tag,
736
+ data: {
737
+ url: `/?assistant=${encodeURIComponent(assistantID.trim())}`,
738
+ assistantID: assistantID.trim(),
739
+ sessionName: title,
740
+ type: 'ready',
741
+ },
742
+ },
743
+ { requireNoSse: true },
744
+ );
745
+ };
746
+
696
747
  return {
697
748
  maybeSendPushForTrigger,
698
749
  setAutoAcceptSession,
@@ -700,5 +751,6 @@ export const createNotificationTriggerRuntime = (deps) => {
700
751
  setGetIsSessionAutoAccepting,
701
752
  clearPendingPushBadge,
702
753
  sendGoalSettlePush,
754
+ sendContactTurnNotification,
703
755
  };
704
756
  };
@@ -199,6 +199,45 @@ const completionPayload = (sessionId = 'ses_root', finish = 'stop') => ({
199
199
  },
200
200
  });
201
201
 
202
+ describe('contact turn notifications', () => {
203
+ it('sends an SMS-style title and body when a contact turn completes', async () => {
204
+ const { runtime, emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions } = createRuntime();
205
+ await runtime.sendContactTurnNotification({
206
+ assistantID: 'asst_1',
207
+ name: '大小白',
208
+ body: '我去找一下',
209
+ status: 'complete',
210
+ });
211
+ expect(emitDesktopNotification).toHaveBeenCalledWith(expect.objectContaining({
212
+ title: '大小白',
213
+ body: '我去找一下',
214
+ tag: 'contact-asst_1',
215
+ kind: 'ready',
216
+ assistantID: 'asst_1',
217
+ }));
218
+ expect(broadcastUiNotification).toHaveBeenCalled();
219
+ expect(sendPushToAllUiSessions).toHaveBeenCalledWith(expect.objectContaining({
220
+ title: '大小白',
221
+ body: '我去找一下',
222
+ data: expect.objectContaining({ assistantID: 'asst_1', type: 'ready' }),
223
+ }), { requireNoSse: true });
224
+ });
225
+
226
+ it('skips contact notifications when completion notices are disabled', async () => {
227
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime({
228
+ readSettingsFromDisk: vi.fn(async () => ({ ...defaultSettings, notifyOnCompletion: false })),
229
+ });
230
+ await runtime.sendContactTurnNotification({
231
+ assistantID: 'asst_1',
232
+ name: '大小白',
233
+ body: 'hi',
234
+ status: 'complete',
235
+ });
236
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
237
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
238
+ });
239
+ });
240
+
202
241
  describe('notification trigger live activity end', () => {
203
242
  it('ends the live activity on top-level completion and error', async () => {
204
243
  vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));