@openchambery/web 1.19.12 → 1.19.13-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.
- package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
- package/dist/assets/AssistantView-YmeyxehG.js +2 -0
- package/dist/assets/{DiagramView-BeUgp_Y0.js → DiagramView-BpZb-G9P.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-DB4Q3kGa.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
- package/dist/assets/{MobileShareBridge-UiOLx0-2.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
- package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
- package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
- package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
- package/dist/assets/{SettingsView-BXfKsz0J.js → SettingsView-BwQpbIZl.js} +11 -11
- package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-DfEGfyr8.js} +1 -1
- package/dist/assets/{TerminalView-BiKCueth.js → TerminalView-8402vpcO.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BC2F_Fdl.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
- package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-CIZ-shMD.js} +1 -1
- package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
- package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
- package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
- package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
- package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
- package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
- package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
- package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-_P0FsoXe.js} +1 -1
- package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
- package/dist/assets/{insertionBoundaries-CCQpZ5zn.js → insertionBoundaries-CWoioTdE.js} +1 -1
- package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
- package/dist/assets/{main-Dkg31fRY.js → main-CLj_WS1o.js} +2 -2
- package/dist/assets/{main-C_PUoT3E.js → main-CnSZ89pD.js} +4 -4
- package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
- package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
- package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
- package/dist/assets/miniChat-B9c3y2pD.js +2 -0
- package/dist/assets/{mobile-Z_X7CbzB.js → mobile-DvbXUfNS.js} +2 -2
- package/dist/assets/{multirun-B0V3hdnD.js → multirun-CxhTsmRq.js} +1 -1
- package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
- package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
- package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
- package/dist/assets/{projectMeta-DRW9MQ4z.js → projectMeta-Dl5iPCB6.js} +1 -1
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-Dn1Su1dd.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
- package/dist/assets/{runtimeConfig-C6matIEB.js → runtimeConfig-Bsk2kWsW.js} +1 -1
- package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
- package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-C8VZn0gJ.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
- package/dist/assets/src-BpHDMgud.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
- package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-BIFw36SA.js} +5 -5
- package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-DuZ6URPR.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
- package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-C8_J-qTp.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
- package/dist/assets/{useSessionAutoCleanup-FOQeu1_T.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
- package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-GBqKnki1.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
- package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistants/DOCUMENTATION.md +17 -4
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-store.js +64 -0
- package/server/lib/assistants/contact-store.test.js +12 -0
- package/server/lib/assistants/contact-tools.js +108 -9
- package/server/lib/assistants/contact-tools.test.js +111 -0
- package/server/lib/assistants/contracts.js +2 -2
- package/server/lib/assistants/harness.js +4 -1
- package/server/lib/assistants/service.js +320 -27
- package/server/lib/assistants/service.test.js +538 -9
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-DxIghtun.js +0 -2
- package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
- package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
- package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
- package/dist/assets/miniChat-C9QMj8T8.js +0 -2
- package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
- package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
- package/dist/assets/src-B-P5qIBc.css +0 -2
|
@@ -5,8 +5,19 @@ const stringID = (value) => (typeof value === 'string' && value.trim() ? value.t
|
|
|
5
5
|
const listIncludesImage = (value) => Array.isArray(value)
|
|
6
6
|
&& value.some((item) => String(item).toLowerCase() === 'image');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Vision capability projection.
|
|
10
|
+
* Prefer authoritative OpenCode SDK Model.capabilities (`input.image` / `attachment`),
|
|
11
|
+
* then fall back to legacy modalities/input/attachment shapes used by older fixtures.
|
|
12
|
+
*/
|
|
13
|
+
export function modelAcceptsImages(model) {
|
|
9
14
|
if (!isRecord(model)) return false;
|
|
15
|
+
if (isRecord(model.capabilities)) {
|
|
16
|
+
if (isRecord(model.capabilities.input) && typeof model.capabilities.input.image === 'boolean') {
|
|
17
|
+
return model.capabilities.input.image === true;
|
|
18
|
+
}
|
|
19
|
+
if (model.capabilities.attachment === true) return true;
|
|
20
|
+
}
|
|
10
21
|
if (isRecord(model.modalities) && listIncludesImage(model.modalities.input)) return true;
|
|
11
22
|
if (listIncludesImage(model.input)) return true;
|
|
12
23
|
return model.attachment === true;
|
|
@@ -40,7 +51,12 @@ export function projectConnectedModels(source) {
|
|
|
40
51
|
if (!isRecord(model)) continue;
|
|
41
52
|
const modelID = stringID(model.id);
|
|
42
53
|
if (!modelID) continue;
|
|
43
|
-
const entry = {
|
|
54
|
+
const entry = {
|
|
55
|
+
providerID,
|
|
56
|
+
modelID,
|
|
57
|
+
name: stringID(model.name) || modelID,
|
|
58
|
+
acceptsImages: modelAcceptsImages(model),
|
|
59
|
+
};
|
|
44
60
|
models.push(entry);
|
|
45
61
|
providerModels.push({ id: modelID, name: entry.name });
|
|
46
62
|
}
|
|
@@ -76,25 +92,84 @@ export function isConnectedModel(catalog, providerID, modelID) {
|
|
|
76
92
|
return catalog.models.some((entry) => entry.providerID === providerID && entry.modelID === modelID);
|
|
77
93
|
}
|
|
78
94
|
|
|
95
|
+
/** Bound provider/config catalog loads so a pending SDK call cannot stall a contact lane. */
|
|
96
|
+
export const CONNECTED_CATALOG_TIMEOUT_MS = 8_000;
|
|
97
|
+
|
|
98
|
+
const isAbortError = (error) => (
|
|
99
|
+
error?.name === 'AbortError'
|
|
100
|
+
|| error?.code === 'ABORT_ERR'
|
|
101
|
+
|| (typeof error?.message === 'string' && /aborted|timed out/i.test(error.message))
|
|
102
|
+
);
|
|
103
|
+
|
|
79
104
|
/**
|
|
80
105
|
* Load the connected catalog from an OpenCode SDK client.
|
|
81
106
|
* Failure is distinct from a successful empty catalog.
|
|
107
|
+
* Uses a bounded AbortSignal (default 8s) on provider.list + config.providers.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} client
|
|
110
|
+
* @param {{ signal?: AbortSignal, timeoutMs?: number }} [options]
|
|
82
111
|
*/
|
|
83
|
-
export async function loadConnectedCatalog(client) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
export async function loadConnectedCatalog(client, options = {}) {
|
|
113
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
114
|
+
? Math.trunc(options.timeoutMs)
|
|
115
|
+
: CONNECTED_CATALOG_TIMEOUT_MS;
|
|
116
|
+
const parentSignal = options.signal;
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const onParentAbort = () => {
|
|
119
|
+
try {
|
|
120
|
+
controller.abort(parentSignal?.reason instanceof Error
|
|
121
|
+
? parentSignal.reason
|
|
122
|
+
: new Error('Connected catalog aborted'));
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
if (parentSignal) {
|
|
128
|
+
if (parentSignal.aborted) onParentAbort();
|
|
129
|
+
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
|
|
89
130
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
try {
|
|
133
|
+
controller.abort(new Error(`Connected catalog timed out after ${timeoutMs}ms`));
|
|
134
|
+
} catch {
|
|
135
|
+
// ignore
|
|
136
|
+
}
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
timer.unref?.();
|
|
139
|
+
|
|
140
|
+
const requestOptions = { signal: controller.signal };
|
|
141
|
+
try {
|
|
142
|
+
const listed = await client.provider.list({}, requestOptions);
|
|
143
|
+
if (listed?.error || !isRecord(listed?.data)) {
|
|
144
|
+
const error = new Error('OpenCode provider list is unavailable');
|
|
145
|
+
error.code = 'upstream_error';
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
const configured = await client.config.providers({}, requestOptions);
|
|
149
|
+
if (configured?.error || !isRecord(configured?.data)) {
|
|
150
|
+
const error = new Error('OpenCode provider catalog is unavailable');
|
|
151
|
+
error.code = 'upstream_error';
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return projectConnectedModels({
|
|
155
|
+
connected: listed.data.connected,
|
|
156
|
+
providers: configured.data.providers,
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (controller.signal.aborted || isAbortError(error)) {
|
|
160
|
+
const timedOut = typeof error?.message === 'string' && /timed out/i.test(error.message)
|
|
161
|
+
|| (controller.signal.reason instanceof Error && /timed out/i.test(controller.signal.reason.message));
|
|
162
|
+
const next = new Error(
|
|
163
|
+
timedOut
|
|
164
|
+
? `Connected catalog timed out after ${timeoutMs}ms`
|
|
165
|
+
: 'Connected catalog request was aborted',
|
|
166
|
+
);
|
|
167
|
+
next.code = 'upstream_error';
|
|
168
|
+
throw next;
|
|
169
|
+
}
|
|
94
170
|
throw error;
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
parentSignal?.removeEventListener?.('abort', onParentAbort);
|
|
95
174
|
}
|
|
96
|
-
return projectConnectedModels({
|
|
97
|
-
connected: listed.data.connected,
|
|
98
|
-
providers: configured.data.providers,
|
|
99
|
-
});
|
|
100
175
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CONNECTED_CATALOG_TIMEOUT_MS,
|
|
4
|
+
loadConnectedCatalog,
|
|
5
|
+
modelAcceptsImages,
|
|
6
|
+
parseModelRef,
|
|
7
|
+
projectConnectedModels,
|
|
8
|
+
} from './catalog.js'
|
|
3
9
|
|
|
4
10
|
describe('parseModelRef', () => {
|
|
5
11
|
it('splits provider/model refs', () => {
|
|
@@ -22,6 +28,56 @@ describe('parseModelRef', () => {
|
|
|
22
28
|
})
|
|
23
29
|
})
|
|
24
30
|
|
|
31
|
+
describe('modelAcceptsImages', () => {
|
|
32
|
+
it('prefers authoritative SDK capabilities.input.image (Grok-shaped)', () => {
|
|
33
|
+
// Real OpenCode Model shape from types.gen Model.capabilities
|
|
34
|
+
expect(modelAcceptsImages({
|
|
35
|
+
id: 'grok-4.6',
|
|
36
|
+
name: 'Grok 4.6',
|
|
37
|
+
capabilities: {
|
|
38
|
+
temperature: true,
|
|
39
|
+
reasoning: true,
|
|
40
|
+
attachment: true,
|
|
41
|
+
toolcall: true,
|
|
42
|
+
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
|
43
|
+
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
44
|
+
interleaved: false,
|
|
45
|
+
},
|
|
46
|
+
})).toBe(true)
|
|
47
|
+
|
|
48
|
+
expect(modelAcceptsImages({
|
|
49
|
+
id: 'deepseek-v4-flash',
|
|
50
|
+
capabilities: {
|
|
51
|
+
temperature: false,
|
|
52
|
+
reasoning: false,
|
|
53
|
+
attachment: false,
|
|
54
|
+
toolcall: true,
|
|
55
|
+
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
56
|
+
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
57
|
+
interleaved: false,
|
|
58
|
+
},
|
|
59
|
+
})).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not let legacy attachment override explicit capabilities.input.image=false', () => {
|
|
63
|
+
expect(modelAcceptsImages({
|
|
64
|
+
attachment: true,
|
|
65
|
+
modalities: { input: ['text', 'image'] },
|
|
66
|
+
capabilities: {
|
|
67
|
+
attachment: false,
|
|
68
|
+
input: { text: true, image: false },
|
|
69
|
+
},
|
|
70
|
+
})).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('falls back to legacy modalities/attachment when capabilities are absent', () => {
|
|
74
|
+
expect(modelAcceptsImages({ modalities: { input: ['text', 'image'] } })).toBe(true)
|
|
75
|
+
expect(modelAcceptsImages({ input: ['image'] })).toBe(true)
|
|
76
|
+
expect(modelAcceptsImages({ attachment: true })).toBe(true)
|
|
77
|
+
expect(modelAcceptsImages({ id: 'plain' })).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
25
81
|
describe('projectConnectedModels', () => {
|
|
26
82
|
it('projects only connected providers', () => {
|
|
27
83
|
const catalog = projectConnectedModels({
|
|
@@ -46,21 +102,44 @@ describe('projectConnectedModels', () => {
|
|
|
46
102
|
])
|
|
47
103
|
})
|
|
48
104
|
|
|
49
|
-
it('marks vision
|
|
105
|
+
it('marks vision from SDK capabilities and legacy modalities', () => {
|
|
50
106
|
const catalog = projectConnectedModels({
|
|
51
|
-
connected: ['openai'],
|
|
52
|
-
providers: [
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
107
|
+
connected: ['openai', 'xai'],
|
|
108
|
+
providers: [
|
|
109
|
+
{
|
|
110
|
+
id: 'openai',
|
|
111
|
+
name: 'OpenAI',
|
|
112
|
+
models: {
|
|
113
|
+
'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
|
|
114
|
+
'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
|
115
|
+
},
|
|
58
116
|
},
|
|
59
|
-
|
|
117
|
+
{
|
|
118
|
+
id: 'xai',
|
|
119
|
+
name: 'xAI',
|
|
120
|
+
models: {
|
|
121
|
+
'grok-4.6': {
|
|
122
|
+
id: 'grok-4.6',
|
|
123
|
+
name: 'Grok 4.6',
|
|
124
|
+
// Authoritative SDK shape — no modalities.input array
|
|
125
|
+
capabilities: {
|
|
126
|
+
temperature: true,
|
|
127
|
+
reasoning: true,
|
|
128
|
+
attachment: true,
|
|
129
|
+
toolcall: true,
|
|
130
|
+
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
|
131
|
+
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
132
|
+
interleaved: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
60
138
|
})
|
|
61
139
|
expect(catalog.models.find((model) => model.modelID === 'gpt-4o')?.acceptsImages).toBe(true)
|
|
140
|
+
expect(catalog.models.find((model) => model.modelID === 'grok-4.6')?.acceptsImages).toBe(true)
|
|
62
141
|
expect(catalog.models.find((model) => model.modelID === 'deepseek-v4-flash')?.acceptsImages).toBe(false)
|
|
63
|
-
expect(catalog.connected).toEqual(['openai'])
|
|
142
|
+
expect(catalog.connected).toEqual(['openai', 'xai'])
|
|
64
143
|
})
|
|
65
144
|
})
|
|
66
145
|
|
|
@@ -75,4 +154,66 @@ describe('loadConnectedCatalog', () => {
|
|
|
75
154
|
config: { providers: async () => ({ error: { status: 500 } }) },
|
|
76
155
|
})).rejects.toThrow(/provider catalog/)
|
|
77
156
|
})
|
|
157
|
+
|
|
158
|
+
it('passes AbortSignal to provider.list and config.providers', async () => {
|
|
159
|
+
const signals = []
|
|
160
|
+
const catalog = await loadConnectedCatalog({
|
|
161
|
+
provider: {
|
|
162
|
+
list: async (_params, options) => {
|
|
163
|
+
signals.push(options?.signal)
|
|
164
|
+
return { data: { connected: ['xai'] } }
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
config: {
|
|
168
|
+
providers: async (_params, options) => {
|
|
169
|
+
signals.push(options?.signal)
|
|
170
|
+
return {
|
|
171
|
+
data: {
|
|
172
|
+
providers: [{
|
|
173
|
+
id: 'xai',
|
|
174
|
+
name: 'xAI',
|
|
175
|
+
models: {
|
|
176
|
+
'grok-4.6': {
|
|
177
|
+
id: 'grok-4.6',
|
|
178
|
+
name: 'Grok 4.6',
|
|
179
|
+
capabilities: {
|
|
180
|
+
input: { text: true, image: true },
|
|
181
|
+
attachment: true,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
}],
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
expect(signals).toHaveLength(2)
|
|
192
|
+
expect(signals[0]).toBeInstanceOf(AbortSignal)
|
|
193
|
+
expect(signals[1]).toBe(signals[0])
|
|
194
|
+
expect(catalog.models).toEqual([
|
|
195
|
+
expect.objectContaining({
|
|
196
|
+
providerID: 'xai',
|
|
197
|
+
modelID: 'grok-4.6',
|
|
198
|
+
acceptsImages: true,
|
|
199
|
+
}),
|
|
200
|
+
])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('fails closed when the catalog load exceeds the deadline', async () => {
|
|
204
|
+
await expect(loadConnectedCatalog({
|
|
205
|
+
provider: {
|
|
206
|
+
list: async (_params, options) => new Promise((resolve, reject) => {
|
|
207
|
+
const onAbort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
208
|
+
if (options?.signal?.aborted) onAbort()
|
|
209
|
+
else options?.signal?.addEventListener('abort', onAbort, { once: true })
|
|
210
|
+
}),
|
|
211
|
+
},
|
|
212
|
+
config: { providers: async () => ({ data: { providers: [] } }) },
|
|
213
|
+
}, { timeoutMs: 30 })).rejects.toMatchObject({
|
|
214
|
+
code: 'upstream_error',
|
|
215
|
+
message: expect.stringMatching(/timed out|aborted/i),
|
|
216
|
+
})
|
|
217
|
+
expect(CONNECTED_CATALOG_TIMEOUT_MS).toBeGreaterThan(0)
|
|
218
|
+
})
|
|
78
219
|
})
|
|
@@ -13,10 +13,12 @@ registered them — so a leaked device token alone can't be used to push.
|
|
|
13
13
|
`POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key
|
|
14
14
|
(`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records
|
|
15
15
|
`token → serverId` where `serverId = SHA-256(publicKey)`.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
3. On a trigger (ready/error/question/permission/goal_*), the server composes **generic,
|
|
17
|
+
content-free** text — a **locale-specific** scenario title (from `apns-titles.js`, keyed off
|
|
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
|
+
Scheduled-task runs add `task_complete` / `task_error` scenario titles (task name as the
|
|
21
|
+
body, error text never crosses), gated by their own settings toggle.
|
|
20
22
|
**Contact turns are the exception:** APNs keeps the assistant nickname as the title and the
|
|
21
23
|
spoken message as the body (`preserveAlert`), and forwards `assistantID` so a tap opens that
|
|
22
24
|
conversation. Tokens are grouped by locale (relay signatures cover `title`, so mixed locales
|
|
@@ -60,6 +62,20 @@ dismissalDate?, staleDate?, publicKeyJwk, ts, sig }` signed over
|
|
|
60
62
|
Direct mode uses HTTP/2 token auth, `apns-topic: ${bundleId}.push-type.liveactivity`,
|
|
61
63
|
`apns-push-type: liveactivity`, and no alert.
|
|
62
64
|
|
|
65
|
+
While sessions are still working, a periodic refresh
|
|
66
|
+
(`live-activity-refresh-runtime.js`, default every 30s,
|
|
67
|
+
`OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS`, `0` disables) recomputes each
|
|
68
|
+
persisted Live Activity snapshot from the authoritative server-side session
|
|
69
|
+
states and pushes an **update** only when something changed — a busy→retry
|
|
70
|
+
transition, a newly visible top-level session, a missed idle completion, or a
|
|
71
|
+
stale row recovering to working. Unchanged snapshots never send, because APNs
|
|
72
|
+
budgets Live Activity updates per hour. Update payloads reuse the signed
|
|
73
|
+
live-activity contract above (`staleDate = updatedAt + 20min`, snapshot `items`
|
|
74
|
+
with `sessionID`/`title`/`status` rows — the same shape the foreground app
|
|
75
|
+
writes via ActivityKit); when the recomputed snapshot has no working row left,
|
|
76
|
+
the refresh sends `end` and clears accepted tokens locally. A tick is a no-op
|
|
77
|
+
(one token-store read) when no device registered a Live Activity token.
|
|
78
|
+
|
|
63
79
|
The APNs / relay payload **must not** carry `sessionId` or user/session content.
|
|
64
80
|
`contentState` is only `{ status, eventVersion, updatedAt, endedAt }`.
|
|
65
81
|
`complete` uses a 15-minute dismissal and `error` a 60-minute dismissal (same as
|
|
@@ -10,6 +10,7 @@ This module provides notification message preparation utilities for the web serv
|
|
|
10
10
|
- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery, plus a separate Live Activity token store and `end` send path. Two modes: **relay** (default — sign + POST tokens + generic text to the isolated Push Relay in `packages/relay-server/src/push/`, which holds the project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the Host maps the effective Relay `wss`/`ws` URL to the same host as `https`/`http` `/v1/push/send`. Before each relay send, the runtime checks an in-memory registration success cache (keyed by current register URL + token + platform) and only POSTs `/v1/push/register-token` for cache misses; only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so persisted tokens re-bind to the new origin on the next send or `reRegisterAllTokens()`. Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`. Live Activity tokens are persisted beside alert tokens in the same file (`version` 2, `liveActivityTokensBySession`) without mixing the two token types; v1 files remain readable. Live Activity relay bind/send uses `/v1/push/register-live-activity-token`, `/v1/push/unregister-live-activity-token`, and `/v1/push/live-activity`.
|
|
11
11
|
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
|
|
12
12
|
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
|
|
13
|
+
- `packages/web/server/lib/notifications/live-activity-refresh-runtime.js`: periodic Live Activity snapshot refresh (default 30s, `OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS` overrides, `0` disables). Each tick snapshots the authoritative session states once, recomputes every persisted Live Activity token's snapshot, and pushes updates only for real changes — busy→retry transitions, newly visible top-level sessions, idle completions the event path missed, and stale-row recovery. Entries whose recomputed snapshot is unchanged never send (APNs budgets Live Activity updates per hour). Rows for sessions without authoritative server state are preserved as-is; server-side-only sub-states (`permission`/`input`/`tool`) keep their last value while the session stays busy. When no row is working anymore the refresh sends `end` and clears accepted tokens.
|
|
13
14
|
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
|
|
14
15
|
- `packages/web/server/lib/notifications/message.js`: helper implementation module.
|
|
15
16
|
- `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers.
|
|
@@ -46,8 +47,11 @@ This module provides notification message preparation utilities for the web serv
|
|
|
46
47
|
- `createNotificationTriggerRuntime(dependencies)`: creates runtime-owned debounced trigger handling for OpenCode events.
|
|
47
48
|
- Returned API:
|
|
48
49
|
- `maybeSendPushForTrigger(payload)`
|
|
50
|
+
- `resolveLiveActivitySessionCandidate(sessionId)` — sidebar candidate for a new Live Activity refresh row: `{ title, visible }` under the same suppression rules as ordinary push. Cache-first across any cached directory, then the directory-less OpenCode session endpoint; a missing meta resolves as not-visible.
|
|
51
|
+
- `sendScheduledTaskRunNotification({ projectID, taskID, taskName, status, sessionId?, reason, errorMessage? })` — scheduled-tasks runtime callback when a scheduled (non-manual) run settles. Gated by `notifyOnScheduledTasks`; manual runs always skip. Desktop/UI SSE carry the task name (plus error text on failure); push follows the generic contract (`task_complete`/`task_error` scenario titles, task name as APNs body, `scheduled-<projectID>-<taskID>` collapse tag).
|
|
49
52
|
- Owns:
|
|
50
53
|
- 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`), LLM gateway throwaway sessions (`metadata.openchamber.llm.purpose`), and `smartfetch-secondary` titles are suppressed. Contact-assigned workers stay visible and still notify.
|
|
54
|
+
- per-event settings gates: `notifyOnCompletion` (ready), `notifyOnQuestion` (question), `notifyOnPermission` (permission — independent of the question toggle), `notifyOnAssistants` (contact turns), `notifyOnGoals` (goal settle push), `notifyOnScheduledTasks` (scheduled task runs); all default to enabled via the settings migration.
|
|
51
55
|
- 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
56
|
- 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
57
|
- session meta cache for sidebar-visibility suppression
|
|
@@ -77,6 +81,7 @@ This module provides notification message preparation utilities for the web serv
|
|
|
77
81
|
- `addOrUpdateLiveActivityToken(uiSessionToken, token, activityId, sessionId)` — idempotent upsert bound to the UI session + OpenCode `sessionId` + ActivityKit `activityId`. Token refresh replaces that activity's previous token. Caps per UI session and lazily expires stale entries. Binds on the relay (`/v1/push/register-live-activity-token`, `kind: "liveactivity"`). Does not log tokens or session ids.
|
|
78
82
|
- `removeLiveActivityToken(uiSessionToken, token)` — owning UI session only; unregisters from the relay when no local copies remain.
|
|
79
83
|
- `sendLiveActivityEnd({ sessionId, status, eventVersion?, endedAt? })` — looks up Live Activity tokens for that OpenCode session and sends `event: "end"` with `contentState` `{ status, eventVersion, updatedAt, endedAt }` only. `complete` dismisses after 15 minutes, `error` after 60 minutes (same as native). `staleDate` (`updatedAt + 20min`) is update-only and omitted on end. `eventVersion` is monotonic per session using current milliseconds. Relay body follows the signed live-activity contract; direct mode uses token auth, topic `${bundleId}.push-type.liveactivity`, `apns-push-type: liveactivity`, and no alert. APNs payloads must not carry `sessionId` or user content. Successfully accepted tokens are cleared locally; failures do not drop alert tokens or block ordinary push.
|
|
84
|
+
- `refreshLiveActivityTokens({ computeSnapshot })` — periodic refresh entrypoint for `live-activity-refresh-runtime.js`. The async `computeSnapshot(entry)` returns `{ items }` (unix seconds) when that entry's snapshot changed or `null` to skip; unchanged entries never send. Changed snapshots bump the aggregate `liveActivityEventVersions` entry, persist, and deliver an `update` (with `staleDate`) grouped by identical snapshot — or an `end` plus local token cleanup once no row is working. Delivery shares the same relay/direct path as `sendLiveActivityEnd`.
|
|
80
85
|
- `sendApnsToAllUiSessions(payload)` — localizes scenario titles per stored token locale (`apns-titles.js`), groups tokens by locale, then signs + sends (no UI-visibility gate; iOS suppresses the foreground banner). Before a relay send, each token is checked against the in-memory registration success cache for the current register URL; cache hits skip `/v1/push/register-token`, misses are registered with bounded concurrency, and only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so the next send re-binds persisted tokens to the new origin. No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`. Never sends Live Activity tokens.
|
|
81
86
|
- `reRegisterAllTokens()` — binds every persisted device token to the current Push origin (same success cache and URL-switch invalidation as send).
|
|
82
87
|
- `resolveApnsConfig()`
|
|
@@ -1120,6 +1120,140 @@ export const createApnsRuntime = (deps) => {
|
|
|
1120
1120
|
return entries;
|
|
1121
1121
|
};
|
|
1122
1122
|
|
|
1123
|
+
// Shared Live Activity delivery path (relay register-then-send, or direct APNs).
|
|
1124
|
+
// Returns the tokens APNs accepted; callers decide what an accepted `end` means.
|
|
1125
|
+
const deliverLiveActivityPayload = async (tokens, payload) => {
|
|
1126
|
+
const accepted = [];
|
|
1127
|
+
const relay = await resolveRelayConfig();
|
|
1128
|
+
if (relay) {
|
|
1129
|
+
rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
|
|
1130
|
+
const outcomes = await mapWithBoundedConcurrency(
|
|
1131
|
+
tokens,
|
|
1132
|
+
RELAY_REGISTER_CONCURRENCY,
|
|
1133
|
+
async (token) => {
|
|
1134
|
+
const registered = await registerLiveActivityTokenWithRelay(token, relay);
|
|
1135
|
+
return registered ? token : null;
|
|
1136
|
+
},
|
|
1137
|
+
);
|
|
1138
|
+
const readyTokens = outcomes.filter((token) => typeof token === 'string');
|
|
1139
|
+
if (readyTokens.length > 0) {
|
|
1140
|
+
accepted.push(...await sendLiveActivityViaRelay(readyTokens, payload, relay));
|
|
1141
|
+
}
|
|
1142
|
+
} else {
|
|
1143
|
+
accepted.push(...await sendLiveActivityViaDirectApns(tokens, payload));
|
|
1144
|
+
}
|
|
1145
|
+
return accepted;
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
// Periodic refresh entrypoint (see live-activity-refresh-runtime.js). For every
|
|
1149
|
+
// persisted Live Activity entry the async `computeSnapshot(entry)` returns the
|
|
1150
|
+
// next snapshot items (unix seconds) or null to skip; unchanged snapshots send
|
|
1151
|
+
// nothing. Changed snapshots bump the aggregate event version, persist, and
|
|
1152
|
+
// deliver an `update` — or an `end` (clearing accepted tokens) once no row is
|
|
1153
|
+
// working anymore.
|
|
1154
|
+
const refreshLiveActivityTokens = async ({ computeSnapshot } = {}) => {
|
|
1155
|
+
if (typeof computeSnapshot !== 'function') return;
|
|
1156
|
+
const now = Date.now();
|
|
1157
|
+
const store = await readTokensFromDisk();
|
|
1158
|
+
const pruned = pruneLiveActivityTokensBySession(store.liveActivityTokensBySession, now);
|
|
1159
|
+
const entries = [];
|
|
1160
|
+
const seen = new Set();
|
|
1161
|
+
for (const record of Object.values(pruned)) {
|
|
1162
|
+
for (const entry of record) {
|
|
1163
|
+
if (seen.has(entry.token)) continue;
|
|
1164
|
+
seen.add(entry.token);
|
|
1165
|
+
entries.push(entry);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (entries.length === 0) return;
|
|
1169
|
+
|
|
1170
|
+
const nextItemsByToken = new Map();
|
|
1171
|
+
for (const entry of entries) {
|
|
1172
|
+
let next = null;
|
|
1173
|
+
try {
|
|
1174
|
+
next = await computeSnapshot(entry);
|
|
1175
|
+
} catch {
|
|
1176
|
+
next = null;
|
|
1177
|
+
}
|
|
1178
|
+
if (next && Array.isArray(next.items)) nextItemsByToken.set(entry.token, next.items);
|
|
1179
|
+
}
|
|
1180
|
+
if (nextItemsByToken.size === 0) return;
|
|
1181
|
+
|
|
1182
|
+
const planned = [];
|
|
1183
|
+
let nextVersion = 0;
|
|
1184
|
+
await persistTokenUpdate((current) => {
|
|
1185
|
+
const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
|
|
1186
|
+
current.liveActivityTokensBySession,
|
|
1187
|
+
now,
|
|
1188
|
+
);
|
|
1189
|
+
const previous = current.liveActivityEventVersions?.[LIVE_ACTIVITY_AGGREGATE_SESSION_ID];
|
|
1190
|
+
nextVersion = nextLiveActivityEventVersion(now, previous);
|
|
1191
|
+
let plannedInRun = [];
|
|
1192
|
+
for (const [uiSessionToken, record] of Object.entries(liveActivityTokensBySession)) {
|
|
1193
|
+
const nextRecord = record.map((entry) => {
|
|
1194
|
+
const items = nextItemsByToken.get(entry.token);
|
|
1195
|
+
if (!items) return entry;
|
|
1196
|
+
plannedInRun.push({ token: entry.token, items });
|
|
1197
|
+
return { ...entry, snapshot: items };
|
|
1198
|
+
});
|
|
1199
|
+
liveActivityTokensBySession[uiSessionToken] = nextRecord;
|
|
1200
|
+
}
|
|
1201
|
+
planned.push(...plannedInRun);
|
|
1202
|
+
return {
|
|
1203
|
+
...current,
|
|
1204
|
+
liveActivityTokensBySession,
|
|
1205
|
+
liveActivityEventVersions: {
|
|
1206
|
+
...(current.liveActivityEventVersions || {}),
|
|
1207
|
+
[LIVE_ACTIVITY_AGGREGATE_SESSION_ID]: nextVersion,
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1211
|
+
if (planned.length === 0) return;
|
|
1212
|
+
|
|
1213
|
+
// Entries with identical snapshots share one APNs payload.
|
|
1214
|
+
const signatureOf = (items) => items
|
|
1215
|
+
.map((item) => `${item.sessionId}\0${item.title}\0${item.status}\0${item.endedAt ?? ''}`)
|
|
1216
|
+
.join('\n');
|
|
1217
|
+
const groups = new Map();
|
|
1218
|
+
for (const plan of planned) {
|
|
1219
|
+
const working = plan.items.filter((item) => liveActivityItemIsWorking(item.status));
|
|
1220
|
+
const key = signatureOf(plan.items);
|
|
1221
|
+
const group = groups.get(key) ?? { items: plan.items, tokens: [] };
|
|
1222
|
+
group.tokens.push(plan.token);
|
|
1223
|
+
groups.set(key, group);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const updatedAtSeconds = now / 1000;
|
|
1227
|
+
const tokensToEnd = [];
|
|
1228
|
+
for (const group of groups.values()) {
|
|
1229
|
+
const working = group.items.filter((item) => liveActivityItemIsWorking(item.status));
|
|
1230
|
+
const isEnd = working.length === 0;
|
|
1231
|
+
const status = isEnd
|
|
1232
|
+
? (group.items.some((item) => item.status === 'error') ? 'error' : 'complete')
|
|
1233
|
+
: (working[0]?.status || 'working');
|
|
1234
|
+
const payload = {
|
|
1235
|
+
event: isEnd ? 'end' : 'update',
|
|
1236
|
+
contentState: contentStateFromSnapshot(
|
|
1237
|
+
group.items,
|
|
1238
|
+
status,
|
|
1239
|
+
nextVersion,
|
|
1240
|
+
updatedAtSeconds,
|
|
1241
|
+
isEnd ? updatedAtSeconds : undefined,
|
|
1242
|
+
),
|
|
1243
|
+
...liveActivityPushDates(isEnd ? 'end' : 'update', status, updatedAtSeconds, now),
|
|
1244
|
+
};
|
|
1245
|
+
let accepted = [];
|
|
1246
|
+
try {
|
|
1247
|
+
accepted = await deliverLiveActivityPayload(group.tokens, payload);
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
console.warn('[Live Activity] refresh failed:', error?.message ?? error);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (isEnd && accepted.length > 0) tokensToEnd.push(...accepted);
|
|
1253
|
+
}
|
|
1254
|
+
if (tokensToEnd.length > 0) await removeLiveActivityTokens(tokensToEnd);
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1123
1257
|
const sendLiveActivityEnd = async ({ sessionId, status, eventVersion, endedAt, title } = {}) => {
|
|
1124
1258
|
const trimmedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
1125
1259
|
if (!isLimitedId(trimmedSessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
|
|
@@ -1217,24 +1351,7 @@ export const createApnsRuntime = (deps) => {
|
|
|
1217
1351
|
const tokens = pendingEntries.map((entry) => entry.token);
|
|
1218
1352
|
let accepted = [];
|
|
1219
1353
|
try {
|
|
1220
|
-
|
|
1221
|
-
if (relay) {
|
|
1222
|
-
rememberLiveActivityRelayRegisterUrl(relay.liveActivityRegisterUrl);
|
|
1223
|
-
const outcomes = await mapWithBoundedConcurrency(
|
|
1224
|
-
tokens,
|
|
1225
|
-
RELAY_REGISTER_CONCURRENCY,
|
|
1226
|
-
async (token) => {
|
|
1227
|
-
const registered = await registerLiveActivityTokenWithRelay(token, relay);
|
|
1228
|
-
return registered ? token : null;
|
|
1229
|
-
},
|
|
1230
|
-
);
|
|
1231
|
-
const readyTokens = outcomes.filter((token) => typeof token === 'string');
|
|
1232
|
-
if (readyTokens.length > 0) {
|
|
1233
|
-
accepted = await sendLiveActivityViaRelay(readyTokens, payload, relay);
|
|
1234
|
-
}
|
|
1235
|
-
} else {
|
|
1236
|
-
accepted = await sendLiveActivityViaDirectApns(tokens, payload);
|
|
1237
|
-
}
|
|
1354
|
+
accepted = await deliverLiveActivityPayload(tokens, payload);
|
|
1238
1355
|
} catch (error) {
|
|
1239
1356
|
console.warn('[Live Activity] end failed:', error?.message ?? error);
|
|
1240
1357
|
return;
|
|
@@ -1332,6 +1449,7 @@ export const createApnsRuntime = (deps) => {
|
|
|
1332
1449
|
addOrUpdateLiveActivityToken,
|
|
1333
1450
|
removeLiveActivityToken,
|
|
1334
1451
|
sendLiveActivityEnd,
|
|
1452
|
+
refreshLiveActivityTokens,
|
|
1335
1453
|
sendApnsToAllUiSessions,
|
|
1336
1454
|
reRegisterAllTokens,
|
|
1337
1455
|
resolveApnsConfig,
|