@openchambery/web 1.19.7-beta.8 → 1.19.7
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.
- package/dist/assets/AgentAvatar-CX0a1rKE.js +1 -0
- package/dist/assets/AssistantView-nVF0kdFh.js +2 -0
- package/dist/assets/DiagramView-B2UtQ8mw.js +1 -0
- package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
- package/dist/assets/Icon-BYM02QyU.js +1 -0
- package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
- package/dist/assets/MarkdownRenderer-DT_GN1Ex.js +2 -0
- package/dist/assets/MarkdownRendererImpl-Cslh4XN_.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
- package/dist/assets/MarkstreamRendererImpl-Ymm12x6J.js +1 -0
- package/dist/assets/MobileDetailNavigation-DD29ENgU.js +1 -0
- package/dist/assets/MobileShareBridge-CrTe_QND.js +4 -0
- package/dist/assets/MobileSurface-DjxchpUC.js +2 -0
- package/dist/assets/{MultiRunWindow-D_jo8cqz.js → MultiRunWindow-jG5dCEMH.js} +1 -1
- package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
- package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
- package/dist/assets/SettingsView-UKlEewdq.js +25 -0
- package/dist/assets/{SettingsWindow-CzMoz2fi.js → SettingsWindow-DO1aAplT.js} +1 -1
- package/dist/assets/TerminalView-BG1UPLsz.js +8 -0
- package/dist/assets/ToolOutputDialog-CWr5eVTK.js +10 -0
- package/dist/assets/appThemeRegistry-BI5drVpC.js +1 -0
- package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/context-menu-CAp74NfV.js +1 -0
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
- package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
- package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-BE0vA4zm.js → gitApi-D9hFlE3T.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/insertionBoundaries-D2L7D0Oe.js +4 -0
- package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-BRedOs_V.js +169 -0
- package/dist/assets/main-DlXpJ0Vw.js +2 -0
- package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
- package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
- package/dist/assets/miniChat-CBLbFpfy.js +2 -0
- package/dist/assets/mobile-CoZsWdWk.js +2 -0
- package/dist/assets/multirun-BF-I0r2_.js +4 -0
- package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/projectMeta-2t4L5_4a.js +3 -0
- package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-fl7O3iHy.js +2 -0
- package/dist/assets/renderMobileApp-CKYDwf4U.js +28 -0
- package/dist/assets/runtimeConfig-B4eb4ZiU.js +2 -0
- package/dist/assets/runtimeEndpointReset-BHuTYyzc.js +147 -0
- package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
- package/dist/assets/sessionLookup-DNc0ZeR3.js +1 -0
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
- package/dist/assets/src-DasvfkEg.css +2 -0
- package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-947l7Ra3.js +1 -0
- package/dist/assets/textarea-Cgo_nWUU.js +1 -0
- package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-Pk6tQ1EZ.js +1 -0
- package/dist/assets/{ChatContainer-D3onaUkL.js → useAppFontEffects-Dle8xs5h.js} +44 -39
- package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-_QAwKcQn.js +1 -0
- package/dist/assets/useEffectiveDirectory-BSyuAgz6.js +1 -0
- package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
- package/dist/assets/useMobileNavigationStore-f8xHJk7C.js +413 -0
- package/dist/assets/useSessionAutoCleanup-BHiXaW0J.js +1 -0
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-DuvXTSm_.js +7 -0
- package/dist/assets/utils-BiK3tBGM.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +76 -3
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +761 -0
- package/server/lib/assistants/contact-tools.test.js +328 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +551 -0
- package/server/lib/assistants/harness.test.js +607 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/directory-ws-bridge.js +11 -2
- package/server/lib/event-stream/global-ws-bridge.js +19 -3
- package/server/lib/event-stream/protocol.js +11 -1
- package/server/lib/event-stream/reasoning-projection.js +425 -0
- package/server/lib/event-stream/reasoning-projection.test.js +300 -0
- package/server/lib/event-stream/runtime.js +10 -0
- package/server/lib/event-stream/runtime.test.js +53 -0
- package/server/lib/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +19 -13
- package/server/lib/notifications/DOCUMENTATION.md +4 -3
- package/server/lib/notifications/runtime.js +153 -38
- package/server/lib/notifications/runtime.test.js +122 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/routes.js +22 -3
- package/server/lib/session-turn-pages/routes.test.js +43 -0
- package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
- package/server/lib/transcript-cache/routes.js +9 -2
- package/server/lib/transcript-cache/routes.test.js +32 -0
- package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
- package/dist/assets/AssistantView-DYdXsGDh.js +0 -1
- package/dist/assets/ContextPanel-Cj6RVUAe.js +0 -285
- package/dist/assets/DiagramView-BoRTG67Z.js +0 -1
- package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DwUKY9k4.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-BRvprELo.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-Ca7RATbs.css +0 -1
- package/dist/assets/MobileDetailNavigation-C0Z8beRL.js +0 -1
- package/dist/assets/MobileShareBridge-DTvmm9vz.js +0 -4
- package/dist/assets/MobileSurface-CfrI-5uf.js +0 -2
- package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
- package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
- package/dist/assets/SettingsView-BOT158gD.js +0 -25
- package/dist/assets/TerminalView-sf_LfP6Z.js +0 -8
- package/dist/assets/ToolOutputDialog-BI5vwuaT.js +0 -10
- package/dist/assets/appThemeRegistry-G_zwgqAH.js +0 -2
- package/dist/assets/clipboard-D35XSwID.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
- package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
- package/dist/assets/insertionBoundaries-CJksyugt.js +0 -4
- package/dist/assets/main-BTeHOAU_.js +0 -29
- package/dist/assets/main-BUBx-y1E.js +0 -2
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
- package/dist/assets/miniChat-Btnf42TH.js +0 -2
- package/dist/assets/mobile-CAjwV6ly.js +0 -2
- package/dist/assets/multirun-Rpz1_-fz.js +0 -4
- package/dist/assets/persistence-w-AiX2i4.js +0 -3
- package/dist/assets/platform-4eCmsdol.js +0 -2
- package/dist/assets/projectMeta-CvUuy-j5.js +0 -3
- package/dist/assets/radio-D_a28OlM.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-DCzBQk2w.js +0 -2
- package/dist/assets/renderMobileApp-U5sclQRp.js +0 -28
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-6inIl99b.js +0 -2
- package/dist/assets/runtimeEndpointReset-B_rUFDT5.js +0 -4
- package/dist/assets/shikiHighlight-CMpSAuTl.js +0 -1
- package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
- package/dist/assets/src-DZt1nWf5.css +0 -2
- package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects-3xHRVJcf.js +0 -7
- package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-QJlddPRn.js +0 -1
- package/dist/assets/useEffectiveDirectory-Bcj1wP95.js +0 -1
- package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
- package/dist/assets/useMobileNavigationStore-CD0GRjaV.js +0 -412
- package/dist/assets/useSessionAutoCleanup-CN_6ABvx.js +0 -1
- package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DX9znDoW.js +0 -7
- package/dist/assets/utils-CTL16t2f.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
|
+
}
|
|
@@ -16,18 +16,22 @@ registered them — so a leaked device token alone can't be used to push.
|
|
|
16
16
|
3. On a trigger (ready/error/question/permission/goal_*), the server composes **generic,
|
|
17
17
|
content-free** text — a **locale-specific** scenario title (from `apns-titles.js`, keyed off
|
|
18
18
|
the locale stored with each device token at `POST /api/push/apns-token`) + the **session name**
|
|
19
|
-
as the body, no model/project/message content — plus a **`badge`** count (see below).
|
|
20
|
-
are
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
as the body, no model/project/message content — plus a **`badge`** count (see below).
|
|
20
|
+
**Contact turns are the exception:** APNs keeps the assistant nickname as the title and the
|
|
21
|
+
spoken message as the body (`preserveAlert`), and forwards `assistantID` so a tap opens that
|
|
22
|
+
conversation. Tokens are grouped by locale (relay signatures cover `title`, so mixed locales
|
|
23
|
+
cannot share one batch) and each group POSTs `{ tokens, title, body, badge, env,
|
|
24
|
+
data:{sessionId|assistantID}, publicKeyJwk, ts, sig }` to `POST /v1/push/send`
|
|
25
|
+
(`apns-runtime.js` → `sendViaRelay`). It does **not** gate on UI visibility (see below).
|
|
26
|
+
Legacy tokens without a locale fall back to English.
|
|
24
27
|
4. The **Push Relay** (`packages/relay-server/src/push/`, process `openchamber-push-relay`)
|
|
25
28
|
verifies the signature + `ts` freshness, derives `serverId`, and only delivers to tokens bound
|
|
26
29
|
to that server. It holds the project APNs `.p8` key, signs an ES256 JWT with Node crypto, and
|
|
27
30
|
sends each token to APNs over HTTP/2, returning per-token results; the server drops tokens
|
|
28
31
|
flagged `drop` (410 / BadDeviceToken). The Push database stores `token → serverId` bindings,
|
|
29
32
|
not application plaintext. Layer 1 (`openchamber-relay`) never sees these secrets.
|
|
30
|
-
5. Tapping a push deep-links
|
|
33
|
+
5. Tapping a session push deep-links via the forwarded `sessionId`. Tapping a contact push
|
|
34
|
+
opens that assistant via `assistantID` / `openchamber://assistant/<id>`.
|
|
31
35
|
|
|
32
36
|
## Live Activity (update / end)
|
|
33
37
|
|
|
@@ -167,13 +171,15 @@ Who can read the alert text:
|
|
|
167
171
|
- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push.
|
|
168
172
|
- **The device:** displays it.
|
|
169
173
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
Ordinary session triggers stay **content-free**: a fixed scenario title (localized per device) +
|
|
175
|
+
the session name only — no model, project, or message content (`runtime.js` →
|
|
176
|
+
`toApnsGenericPayload` → `localizeApnsPayload`). The session name is the single semi-personal
|
|
177
|
+
field that crosses the relay/Apple on that path.
|
|
178
|
+
|
|
179
|
+
Contact-turn alerts are an intentional exception: nickname + spoken message cross the relay so
|
|
180
|
+
the banner reads like a normal contact. To hide even that from Apple would require an end-to-end
|
|
181
|
+
**encrypted payload** (`mutable-content` + a Notification Service Extension that decrypts
|
|
182
|
+
on-device with a key never sent to the relay) — not implemented.
|
|
177
183
|
|
|
178
184
|
## Android (FCM) note
|
|
179
185
|
|
|
@@ -47,9 +47,10 @@ This module provides notification message preparation utilities for the web serv
|
|
|
47
47
|
- Returned API:
|
|
48
48
|
- `maybeSendPushForTrigger(payload)`
|
|
49
49
|
- Owns:
|
|
50
|
-
- top-level completion/question/permission trigger routing; completion is the sole task-status notification
|
|
51
|
-
-
|
|
52
|
-
-
|
|
50
|
+
- top-level completion/question/permission trigger routing; completion is the sole task-status notification. Ordinary push is limited to sidebar-visible root sessions. Child/subagent sessions, Assistant bindings (`openchamber.assistant.assistantID` unless `assigned.from === 'contact'`), scheduled-task sessions, small-model system sessions (`metadata.openchamber.smallModel.purpose`), and `smartfetch-secondary` titles are suppressed. Contact-assigned workers stay visible and still notify.
|
|
51
|
+
- contact-turn notifications (`sendContactTurnNotification`) are a separate SMS-style path: title is the assistant nickname, body is the spoken message text, on desktop / UI SSE / web-push / APNs. APNs does **not** rewrite these into the generic "Task completed" scenario title.
|
|
52
|
+
- top-level completion/error Live Activity `end` (`sendLiveActivityEnd`), independent of ordinary push settings and UI visibility, with the same hidden-session suppression; duplicate terminal events are idempotent at the token store
|
|
53
|
+
- session meta cache for sidebar-visibility suppression
|
|
53
54
|
- template resolution and fallback behavior
|
|
54
55
|
- native notification fanout and web push payload fanout
|
|
55
56
|
- push always fans out to every subscribed surface; another client's visibility never suppresses delivery
|