@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
|
@@ -1175,3 +1175,131 @@ describe('apns live activity host-relay compatibility', () => {
|
|
|
1175
1175
|
expect(apnsCalls).toHaveLength(0);
|
|
1176
1176
|
});
|
|
1177
1177
|
});
|
|
1178
|
+
|
|
1179
|
+
describe('apns live activity refresh', () => {
|
|
1180
|
+
it('sends nothing when no snapshot changed', async () => {
|
|
1181
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
1182
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1183
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
1184
|
+
const deps = makeDeps();
|
|
1185
|
+
const runtime = createApnsRuntime(deps);
|
|
1186
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
|
|
1187
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
1188
|
+
]);
|
|
1189
|
+
|
|
1190
|
+
fetchMock.mockClear();
|
|
1191
|
+
await runtime.refreshLiveActivityTokens({ computeSnapshot: async () => null });
|
|
1192
|
+
expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(0);
|
|
1193
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
1194
|
+
expect(store.liveActivityTokensBySession.s1).toHaveLength(1);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it('pushes an update with the recomputed snapshot and bumps the event version monotonically', async () => {
|
|
1198
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
1199
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1200
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
1201
|
+
const deps = makeDeps();
|
|
1202
|
+
const runtime = createApnsRuntime(deps);
|
|
1203
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
|
|
1204
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
1205
|
+
]);
|
|
1206
|
+
|
|
1207
|
+
fetchMock.mockClear();
|
|
1208
|
+
await runtime.refreshLiveActivityTokens({
|
|
1209
|
+
computeSnapshot: async () => ({
|
|
1210
|
+
items: [{ sessionId: 'ses_1', title: 'One', status: 'retry', startedAt: 1 }],
|
|
1211
|
+
}),
|
|
1212
|
+
});
|
|
1213
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
1214
|
+
expect(sent.tokens).toEqual(['la-token']);
|
|
1215
|
+
expect(sent.event).toBe('update');
|
|
1216
|
+
expect(sent.contentState.status).toBe('retry');
|
|
1217
|
+
expect(sent.contentState.workingCount).toBe(1);
|
|
1218
|
+
expect(sent.contentState.items[0]).toMatchObject({ sessionID: 'ses_1', status: 'retry' });
|
|
1219
|
+
expect(sent.staleDate).toBeGreaterThan(0);
|
|
1220
|
+
expect(sent.dismissalDate).toBeUndefined();
|
|
1221
|
+
|
|
1222
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
1223
|
+
expect(store.liveActivityTokensBySession.s1[0].snapshot[0].status).toBe('retry');
|
|
1224
|
+
const firstVersion = store.liveActivityEventVersions.live;
|
|
1225
|
+
|
|
1226
|
+
fetchMock.mockClear();
|
|
1227
|
+
await runtime.refreshLiveActivityTokens({
|
|
1228
|
+
computeSnapshot: async () => ({
|
|
1229
|
+
items: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
1230
|
+
}),
|
|
1231
|
+
});
|
|
1232
|
+
const second = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
1233
|
+
expect(second.contentState.eventVersion).toBeGreaterThan(firstVersion);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it('ends and clears the token when nothing is working anymore', async () => {
|
|
1237
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
1238
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1239
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
1240
|
+
const deps = makeDeps();
|
|
1241
|
+
const runtime = createApnsRuntime(deps);
|
|
1242
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
|
|
1243
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
1244
|
+
]);
|
|
1245
|
+
|
|
1246
|
+
fetchMock.mockClear();
|
|
1247
|
+
await runtime.refreshLiveActivityTokens({
|
|
1248
|
+
computeSnapshot: async () => ({
|
|
1249
|
+
items: [{ sessionId: 'ses_1', title: 'One', status: 'complete', startedAt: 1, endedAt: 5 }],
|
|
1250
|
+
}),
|
|
1251
|
+
});
|
|
1252
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
1253
|
+
expect(sent.event).toBe('end');
|
|
1254
|
+
expect(sent.contentState.status).toBe('complete');
|
|
1255
|
+
expect(sent.dismissalDate).toBeGreaterThan(0);
|
|
1256
|
+
expect(sent.staleDate).toBeUndefined();
|
|
1257
|
+
|
|
1258
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
1259
|
+
expect(store.liveActivityTokensBySession.s1).toBeUndefined();
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('groups entries with identical snapshots into one send and keeps distinct snapshots separate', async () => {
|
|
1263
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
1264
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1265
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
1266
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
1267
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-a', 'act-1', 'live', [
|
|
1268
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
1269
|
+
]);
|
|
1270
|
+
await runtime.addOrUpdateLiveActivityToken('s2', 'la-token-b', 'act-2', 'live', [
|
|
1271
|
+
{ sessionId: 'ses_2', title: 'Two', status: 'working', startedAt: 2 },
|
|
1272
|
+
]);
|
|
1273
|
+
|
|
1274
|
+
fetchMock.mockClear();
|
|
1275
|
+
await runtime.refreshLiveActivityTokens({
|
|
1276
|
+
computeSnapshot: async (entry) => {
|
|
1277
|
+
if (entry.token === 'la-token-a') {
|
|
1278
|
+
return { items: [{ sessionId: 'ses_1', title: 'One', status: 'retry', startedAt: 1 }] };
|
|
1279
|
+
}
|
|
1280
|
+
return { items: [{ sessionId: 'ses_2', title: 'Two', status: 'retry', startedAt: 2 }] };
|
|
1281
|
+
},
|
|
1282
|
+
});
|
|
1283
|
+
const sends = fetchMock.mock.calls.filter(isLiveActivitySend).map((call) => JSON.parse(call[1].body));
|
|
1284
|
+
expect(sends).toHaveLength(2);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it('treats a computeSnapshot rejection as skip, not failure', async () => {
|
|
1288
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
1289
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1290
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
1291
|
+
const deps = makeDeps();
|
|
1292
|
+
const runtime = createApnsRuntime(deps);
|
|
1293
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
|
|
1294
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
1295
|
+
]);
|
|
1296
|
+
|
|
1297
|
+
fetchMock.mockClear();
|
|
1298
|
+
await expect(runtime.refreshLiveActivityTokens({
|
|
1299
|
+
computeSnapshot: async () => {
|
|
1300
|
+
throw new Error('boom');
|
|
1301
|
+
},
|
|
1302
|
+
})).resolves.toBeUndefined();
|
|
1303
|
+
expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(0);
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
@@ -16,7 +16,7 @@ export const APNS_LOCALES = Object.freeze([
|
|
|
16
16
|
'ja',
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
/** @typedef {'ready'|'error'|'question'|'permission'|'goal_complete'|'goal_blocked'|'goal_budget'|'update'} ApnsTitleType */
|
|
19
|
+
/** @typedef {'ready'|'error'|'question'|'permission'|'goal_complete'|'goal_blocked'|'goal_budget'|'task_complete'|'task_error'|'update'} ApnsTitleType */
|
|
20
20
|
|
|
21
21
|
const TITLES = Object.freeze({
|
|
22
22
|
en: Object.freeze({
|
|
@@ -27,6 +27,8 @@ const TITLES = Object.freeze({
|
|
|
27
27
|
goal_complete: 'Goal completed',
|
|
28
28
|
goal_blocked: 'Goal blocked',
|
|
29
29
|
goal_budget: 'Token budget reached',
|
|
30
|
+
task_complete: 'Scheduled task completed',
|
|
31
|
+
task_error: 'Scheduled task failed',
|
|
30
32
|
update: 'Update',
|
|
31
33
|
session: 'Session',
|
|
32
34
|
}),
|
|
@@ -38,6 +40,8 @@ const TITLES = Object.freeze({
|
|
|
38
40
|
goal_complete: 'Objectif atteint',
|
|
39
41
|
goal_blocked: 'Objectif bloqué',
|
|
40
42
|
goal_budget: 'Budget de tokens atteint',
|
|
43
|
+
task_complete: 'Tâche planifiée terminée',
|
|
44
|
+
task_error: 'Échec de la tâche planifiée',
|
|
41
45
|
update: 'Mise à jour',
|
|
42
46
|
session: 'Session',
|
|
43
47
|
}),
|
|
@@ -49,6 +53,8 @@ const TITLES = Object.freeze({
|
|
|
49
53
|
goal_complete: '目标已完成',
|
|
50
54
|
goal_blocked: '目标受阻',
|
|
51
55
|
goal_budget: '已达用量上限',
|
|
56
|
+
task_complete: '定时任务已完成',
|
|
57
|
+
task_error: '定时任务失败',
|
|
52
58
|
update: '有新进展',
|
|
53
59
|
session: '会话',
|
|
54
60
|
}),
|
|
@@ -60,6 +66,8 @@ const TITLES = Object.freeze({
|
|
|
60
66
|
goal_complete: '目標已完成',
|
|
61
67
|
goal_blocked: '目標受阻',
|
|
62
68
|
goal_budget: '已達用量上限',
|
|
69
|
+
task_complete: '排程任務已完成',
|
|
70
|
+
task_error: '排程任務失敗',
|
|
63
71
|
update: '有新進展',
|
|
64
72
|
session: '工作階段',
|
|
65
73
|
}),
|
|
@@ -71,6 +79,8 @@ const TITLES = Object.freeze({
|
|
|
71
79
|
goal_complete: 'Цілі досягнуто',
|
|
72
80
|
goal_blocked: 'Цілі заблоковано',
|
|
73
81
|
goal_budget: 'Досягнуто ліміт токенів',
|
|
82
|
+
task_complete: 'Заплановане завдання виконано',
|
|
83
|
+
task_error: 'Заплановане завдання не вдалося',
|
|
74
84
|
update: 'Оновлення',
|
|
75
85
|
session: 'Сесія',
|
|
76
86
|
}),
|
|
@@ -82,6 +92,8 @@ const TITLES = Object.freeze({
|
|
|
82
92
|
goal_complete: 'Objetivo completado',
|
|
83
93
|
goal_blocked: 'Objetivo bloqueado',
|
|
84
94
|
goal_budget: 'Presupuesto de tokens alcanzado',
|
|
95
|
+
task_complete: 'Tarea programada completada',
|
|
96
|
+
task_error: 'La tarea programada falló',
|
|
85
97
|
update: 'Actualización',
|
|
86
98
|
session: 'Sesión',
|
|
87
99
|
}),
|
|
@@ -93,6 +105,8 @@ const TITLES = Object.freeze({
|
|
|
93
105
|
goal_complete: 'Objetivo concluído',
|
|
94
106
|
goal_blocked: 'Objetivo bloqueado',
|
|
95
107
|
goal_budget: 'Orçamento de tokens atingido',
|
|
108
|
+
task_complete: 'Tarefa agendada concluída',
|
|
109
|
+
task_error: 'A tarefa agendada falhou',
|
|
96
110
|
update: 'Atualização',
|
|
97
111
|
session: 'Sessão',
|
|
98
112
|
}),
|
|
@@ -104,6 +118,8 @@ const TITLES = Object.freeze({
|
|
|
104
118
|
goal_complete: '목표 완료',
|
|
105
119
|
goal_blocked: '목표 차단됨',
|
|
106
120
|
goal_budget: '토큰 한도 도달',
|
|
121
|
+
task_complete: '예약 작업 완료',
|
|
122
|
+
task_error: '예약 작업 실패',
|
|
107
123
|
update: '업데이트',
|
|
108
124
|
session: '세션',
|
|
109
125
|
}),
|
|
@@ -115,6 +131,8 @@ const TITLES = Object.freeze({
|
|
|
115
131
|
goal_complete: 'Cel ukończony',
|
|
116
132
|
goal_blocked: 'Cel zablokowany',
|
|
117
133
|
goal_budget: 'Osiągnięto limit tokenów',
|
|
134
|
+
task_complete: 'Zadanie zaplanowane ukończone',
|
|
135
|
+
task_error: 'Zadanie zaplanowane nie powiodło się',
|
|
118
136
|
update: 'Aktualizacja',
|
|
119
137
|
session: 'Sesja',
|
|
120
138
|
}),
|
|
@@ -126,6 +144,8 @@ const TITLES = Object.freeze({
|
|
|
126
144
|
goal_complete: '目標完了',
|
|
127
145
|
goal_blocked: '目標がブロックされました',
|
|
128
146
|
goal_budget: 'トークン上限に達しました',
|
|
147
|
+
task_complete: 'スケジュールタスクが完了しました',
|
|
148
|
+
task_error: 'スケジュールタスクに失敗しました',
|
|
129
149
|
update: '更新',
|
|
130
150
|
session: 'セッション',
|
|
131
151
|
}),
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Periodic Live Activity snapshot refresh for iOS devices that registered a
|
|
2
|
+
// Live Activity push token. While the app is foreground/connected the UI keeps
|
|
3
|
+
// its own ActivityKit state fresh; once iOS suspends or kills the app, only
|
|
4
|
+
// APNs can move the on-screen activity. Completion/error/title already push
|
|
5
|
+
// event-driven (runtime.js → sendLiveActivityEnd); this runtime covers the
|
|
6
|
+
// busy phase: every interval it recomputes each persisted token's snapshot
|
|
7
|
+
// from the authoritative session-status snapshot and pushes an update only
|
|
8
|
+
// when something actually changed (APNs budgets Live Activity updates per
|
|
9
|
+
// hour, so unchanged snapshots never send).
|
|
10
|
+
//
|
|
11
|
+
// Interval: default 30s, override with OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS
|
|
12
|
+
// (milliseconds; `0` disables the timer). Snapshots use unix seconds, matching
|
|
13
|
+
// the persisted token store (apns-runtime.js parseLiveActivitySnapshot).
|
|
14
|
+
|
|
15
|
+
export const LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS = 30_000;
|
|
16
|
+
export const LIVE_ACTIVITY_REFRESH_ITEM_LIMIT = 4; // mirrors the UI catalog cap
|
|
17
|
+
export const LIVE_ACTIVITY_REFRESH_TITLE_MAX = 80;
|
|
18
|
+
|
|
19
|
+
export const resolveLiveActivityRefreshIntervalMs = (env = process.env) => {
|
|
20
|
+
const raw = env?.OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS;
|
|
21
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
22
|
+
return LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS;
|
|
23
|
+
}
|
|
24
|
+
const parsed = Number(raw);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed < 0) return LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS;
|
|
26
|
+
return Math.floor(parsed);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const liveActivityItemIsWorking = (status) => status !== 'complete' && status !== 'error';
|
|
30
|
+
|
|
31
|
+
// Session-side sub-states the server has no authoritative source for. The UI
|
|
32
|
+
// derives them from live permission/question/message-delta channels; once it is
|
|
33
|
+
// gone we keep the last row state instead of flickering back to `working`.
|
|
34
|
+
const LIVE_ACTIVITY_PRESERVED_ROW_STATUSES = new Set(['permission', 'input', 'tool']);
|
|
35
|
+
|
|
36
|
+
const mapSessionStatusToRowStatus = (sessionStatus) => {
|
|
37
|
+
if (sessionStatus === 'retry') return 'retry';
|
|
38
|
+
if (sessionStatus === 'busy') return 'working';
|
|
39
|
+
return null; // idle / unknown → terminal
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const nextRowStatus = (currentRowStatus, sessionStatus) => {
|
|
43
|
+
if (sessionStatus === 'busy') {
|
|
44
|
+
if (LIVE_ACTIVITY_PRESERVED_ROW_STATUSES.has(currentRowStatus)) return currentRowStatus;
|
|
45
|
+
return 'working'; // including stale → working (server connection is authoritative here)
|
|
46
|
+
}
|
|
47
|
+
if (sessionStatus === 'retry') return 'retry';
|
|
48
|
+
return 'complete';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const capLiveActivityItems = (items) => {
|
|
52
|
+
if (items.length <= LIVE_ACTIVITY_REFRESH_ITEM_LIMIT) return items;
|
|
53
|
+
const working = items.filter((item) => liveActivityItemIsWorking(item.status));
|
|
54
|
+
const settled = items
|
|
55
|
+
.filter((item) => !liveActivityItemIsWorking(item.status))
|
|
56
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
|
|
57
|
+
const next = [...working];
|
|
58
|
+
for (const item of settled) {
|
|
59
|
+
if (next.length >= LIVE_ACTIVITY_REFRESH_ITEM_LIMIT) break;
|
|
60
|
+
next.push(item);
|
|
61
|
+
}
|
|
62
|
+
return next.slice(0, LIVE_ACTIVITY_REFRESH_ITEM_LIMIT);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const liveActivityItemsSignature = (items) => items
|
|
66
|
+
.map((item) => `${item.sessionId}\0${item.title}\0${item.status}\0${item.endedAt ?? ''}`)
|
|
67
|
+
.join('\n');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Recompute one entry's snapshot from the authoritative session-state snapshot.
|
|
71
|
+
* Returns `{ items }` (unix seconds) when something changed, `null` to skip.
|
|
72
|
+
* Rows for sessions with no live server state are preserved as-is (never guess
|
|
73
|
+
* terminal from missing data); new busy/retry sessions join only when the
|
|
74
|
+
* injected resolver confirms sidebar visibility.
|
|
75
|
+
*/
|
|
76
|
+
export const computeLiveActivityRefreshSnapshot = async (input) => {
|
|
77
|
+
const { entry, sessionStates, resolveSession, nowMs } = input;
|
|
78
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
79
|
+
const previous = Array.isArray(entry.snapshot) ? entry.snapshot : [];
|
|
80
|
+
const items = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
|
|
83
|
+
for (const item of previous) {
|
|
84
|
+
if (!item || typeof item.sessionId !== 'string') continue;
|
|
85
|
+
seen.add(item.sessionId);
|
|
86
|
+
const state = sessionStates[item.sessionId];
|
|
87
|
+
if (!state) {
|
|
88
|
+
items.push(item); // no authoritative state → keep the last known row
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const rowStatus = nextRowStatus(item.status, state.status);
|
|
92
|
+
if (rowStatus === item.status) {
|
|
93
|
+
items.push(item);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (rowStatus === 'complete') {
|
|
97
|
+
items.push({
|
|
98
|
+
...item,
|
|
99
|
+
status: 'complete',
|
|
100
|
+
endedAt: item.endedAt ?? nowSeconds,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
items.push({ ...item, status: rowStatus });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const additions = [];
|
|
108
|
+
for (const [sessionId, state] of Object.entries(sessionStates || {})) {
|
|
109
|
+
if (seen.has(sessionId)) continue;
|
|
110
|
+
const mapped = mapSessionStatusToRowStatus(state?.status);
|
|
111
|
+
if (mapped === null || mapped === 'complete') continue;
|
|
112
|
+
let candidate = null;
|
|
113
|
+
try {
|
|
114
|
+
candidate = await resolveSession(sessionId);
|
|
115
|
+
} catch {
|
|
116
|
+
candidate = null;
|
|
117
|
+
}
|
|
118
|
+
if (!candidate || candidate.visible !== true) continue;
|
|
119
|
+
additions.push({
|
|
120
|
+
sessionId,
|
|
121
|
+
title: typeof candidate.title === 'string' ? candidate.title.slice(0, LIVE_ACTIVITY_REFRESH_TITLE_MAX) : '',
|
|
122
|
+
status: mapped,
|
|
123
|
+
startedAt: nowSeconds,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const merged = capLiveActivityItems([...items, ...additions]);
|
|
128
|
+
if (liveActivityItemsSignature(merged) === liveActivityItemsSignature(previous)) return null;
|
|
129
|
+
return { items: merged };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Owns the refresh interval. One tick snapshots the authoritative session
|
|
134
|
+
* states once, then hands every persisted Live Activity entry to
|
|
135
|
+
* `refreshLiveActivityTokens` (apns-runtime.js), which skips entries whose
|
|
136
|
+
* computed snapshot is unchanged. Overlapping ticks are dropped, not queued.
|
|
137
|
+
*/
|
|
138
|
+
export const createLiveActivityRefreshRuntime = (deps) => {
|
|
139
|
+
const {
|
|
140
|
+
intervalMs,
|
|
141
|
+
refreshLiveActivityTokens,
|
|
142
|
+
getSessionStateSnapshot,
|
|
143
|
+
resolveSession,
|
|
144
|
+
} = deps;
|
|
145
|
+
let timer = null;
|
|
146
|
+
let ticking = false;
|
|
147
|
+
|
|
148
|
+
const tick = async () => {
|
|
149
|
+
if (ticking) return;
|
|
150
|
+
ticking = true;
|
|
151
|
+
try {
|
|
152
|
+
const sessionStates = getSessionStateSnapshot();
|
|
153
|
+
const nowMs = Date.now();
|
|
154
|
+
await refreshLiveActivityTokens({
|
|
155
|
+
computeSnapshot: (entry) => computeLiveActivityRefreshSnapshot({
|
|
156
|
+
entry,
|
|
157
|
+
sessionStates,
|
|
158
|
+
resolveSession,
|
|
159
|
+
nowMs,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.warn('[Live Activity refresh] tick failed:', error?.message ?? error);
|
|
164
|
+
} finally {
|
|
165
|
+
ticking = false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const start = () => {
|
|
170
|
+
if (timer || !(intervalMs > 0)) return;
|
|
171
|
+
timer = setInterval(() => {
|
|
172
|
+
void tick();
|
|
173
|
+
}, intervalMs);
|
|
174
|
+
timer.unref?.();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const dispose = () => {
|
|
178
|
+
if (timer) {
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
timer = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return { start, dispose, tick };
|
|
185
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS,
|
|
5
|
+
computeLiveActivityRefreshSnapshot,
|
|
6
|
+
createLiveActivityRefreshRuntime,
|
|
7
|
+
resolveLiveActivityRefreshIntervalMs,
|
|
8
|
+
} from './live-activity-refresh-runtime.js';
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('resolveLiveActivityRefreshIntervalMs', () => {
|
|
16
|
+
it('defaults to 30s', () => {
|
|
17
|
+
expect(resolveLiveActivityRefreshIntervalMs({})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
18
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
19
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: ' ',
|
|
20
|
+
})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('honours explicit overrides including disable', () => {
|
|
24
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
25
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '45000',
|
|
26
|
+
})).toBe(45_000);
|
|
27
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
28
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '0',
|
|
29
|
+
})).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back to the default for invalid values', () => {
|
|
33
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
34
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: 'abc',
|
|
35
|
+
})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
36
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
37
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '-5',
|
|
38
|
+
})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('computeLiveActivityRefreshSnapshot', () => {
|
|
43
|
+
const baseInput = (overrides = {}) => ({
|
|
44
|
+
entry: { token: 't', sessionId: 'live', snapshot: [] },
|
|
45
|
+
sessionStates: {},
|
|
46
|
+
resolveSession: async () => null,
|
|
47
|
+
nowMs: 1_700_000_000_000,
|
|
48
|
+
...overrides,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps idle to complete with endedAt and marks the snapshot changed', async () => {
|
|
52
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
53
|
+
entry: {
|
|
54
|
+
token: 't',
|
|
55
|
+
sessionId: 'live',
|
|
56
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
57
|
+
},
|
|
58
|
+
sessionStates: { ses_1: { status: 'idle' } },
|
|
59
|
+
}));
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
items: [{
|
|
62
|
+
sessionId: 'ses_1',
|
|
63
|
+
title: 'One',
|
|
64
|
+
status: 'complete',
|
|
65
|
+
startedAt: 1,
|
|
66
|
+
endedAt: Math.floor(1_700_000_000_000 / 1000),
|
|
67
|
+
}],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps server-side-only sub-states while the session stays busy', async () => {
|
|
72
|
+
const snapshot = [{ sessionId: 'ses_1', title: 'One', status: 'permission', startedAt: 1 }];
|
|
73
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
74
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
75
|
+
sessionStates: { ses_1: { status: 'busy' } },
|
|
76
|
+
}));
|
|
77
|
+
expect(result).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('recovers stale rows to working while busy', async () => {
|
|
81
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
82
|
+
entry: {
|
|
83
|
+
token: 't',
|
|
84
|
+
sessionId: 'live',
|
|
85
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'stale', startedAt: 1 }],
|
|
86
|
+
},
|
|
87
|
+
sessionStates: { ses_1: { status: 'busy' } },
|
|
88
|
+
}));
|
|
89
|
+
expect(result?.items[0].status).toBe('working');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('maps retry sessions to the retry row status', async () => {
|
|
93
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
94
|
+
entry: {
|
|
95
|
+
token: 't',
|
|
96
|
+
sessionId: 'live',
|
|
97
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
98
|
+
},
|
|
99
|
+
sessionStates: { ses_1: { status: 'retry' } },
|
|
100
|
+
}));
|
|
101
|
+
expect(result?.items[0].status).toBe('retry');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('preserves rows whose session has no authoritative server state', async () => {
|
|
105
|
+
const snapshot = [{ sessionId: 'ses_gone', title: 'Gone', status: 'working', startedAt: 1 }];
|
|
106
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
107
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
108
|
+
sessionStates: {},
|
|
109
|
+
}));
|
|
110
|
+
expect(result).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('adds new busy sessions only when the resolver marks them visible', async () => {
|
|
114
|
+
const resolveSession = vi.fn(async (sessionId) => (
|
|
115
|
+
sessionId === 'ses_child'
|
|
116
|
+
? { title: 'Child', visible: false }
|
|
117
|
+
: { title: 'New Task', visible: true }
|
|
118
|
+
));
|
|
119
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
120
|
+
entry: { token: 't', sessionId: 'live', snapshot: [] },
|
|
121
|
+
sessionStates: {
|
|
122
|
+
ses_new: { status: 'busy' },
|
|
123
|
+
ses_child: { status: 'busy' },
|
|
124
|
+
ses_idle: { status: 'idle' },
|
|
125
|
+
},
|
|
126
|
+
resolveSession,
|
|
127
|
+
}));
|
|
128
|
+
expect(result?.items).toHaveLength(1);
|
|
129
|
+
expect(result?.items[0]).toMatchObject({ sessionId: 'ses_new', title: 'New Task', status: 'working' });
|
|
130
|
+
expect(resolveSession).not.toHaveBeenCalledWith('ses_idle');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('skips new sessions when the resolver throws or resolves empty', async () => {
|
|
134
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
135
|
+
entry: { token: 't', sessionId: 'live', snapshot: [] },
|
|
136
|
+
sessionStates: { ses_new: { status: 'busy' } },
|
|
137
|
+
resolveSession: async () => {
|
|
138
|
+
throw new Error('boom');
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
expect(result).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('caps merged rows at the catalog limit, working rows first', async () => {
|
|
145
|
+
const snapshot = [1, 2, 3, 4, 5].map((n) => ({
|
|
146
|
+
sessionId: `ses_${n}`,
|
|
147
|
+
title: `S${n}`,
|
|
148
|
+
status: 'complete',
|
|
149
|
+
startedAt: n,
|
|
150
|
+
endedAt: n,
|
|
151
|
+
}));
|
|
152
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
153
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
154
|
+
sessionStates: { ses_new: { status: 'busy' } },
|
|
155
|
+
resolveSession: async () => ({ title: 'New', visible: true }),
|
|
156
|
+
}));
|
|
157
|
+
expect(result?.items).toHaveLength(4);
|
|
158
|
+
expect(result?.items.some((item) => item.sessionId === 'ses_new')).toBe(true);
|
|
159
|
+
// Most recently settled rows survive the cap.
|
|
160
|
+
expect(result?.items.some((item) => item.sessionId === 'ses_5')).toBe(true);
|
|
161
|
+
expect(result?.items.some((item) => item.sessionId === 'ses_1')).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns null when nothing changed', async () => {
|
|
165
|
+
const snapshot = [
|
|
166
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
167
|
+
{ sessionId: 'ses_2', title: 'Two', status: 'complete', startedAt: 2, endedAt: 3 },
|
|
168
|
+
];
|
|
169
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
170
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
171
|
+
sessionStates: { ses_1: { status: 'busy' } },
|
|
172
|
+
}));
|
|
173
|
+
expect(result).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('createLiveActivityRefreshRuntime', () => {
|
|
178
|
+
it('ticks the refresh with one session-state snapshot for all entries', async () => {
|
|
179
|
+
const refreshLiveActivityTokens = vi.fn(async () => {});
|
|
180
|
+
const runtime = createLiveActivityRefreshRuntime({
|
|
181
|
+
intervalMs: LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS,
|
|
182
|
+
refreshLiveActivityTokens,
|
|
183
|
+
getSessionStateSnapshot: () => ({ ses_1: { status: 'busy' } }),
|
|
184
|
+
resolveSession: async () => null,
|
|
185
|
+
});
|
|
186
|
+
await runtime.tick();
|
|
187
|
+
|
|
188
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
189
|
+
const arg = refreshLiveActivityTokens.mock.calls[0][0];
|
|
190
|
+
expect(typeof arg.computeSnapshot).toBe('function');
|
|
191
|
+
const result = await arg.computeSnapshot({
|
|
192
|
+
token: 't',
|
|
193
|
+
sessionId: 'live',
|
|
194
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
195
|
+
});
|
|
196
|
+
expect(result).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('drops overlapping ticks instead of queueing them', async () => {
|
|
200
|
+
let release;
|
|
201
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
202
|
+
const refreshLiveActivityTokens = vi.fn(async () => { await gate; });
|
|
203
|
+
const runtime = createLiveActivityRefreshRuntime({
|
|
204
|
+
intervalMs: 1000,
|
|
205
|
+
refreshLiveActivityTokens,
|
|
206
|
+
getSessionStateSnapshot: () => ({}),
|
|
207
|
+
resolveSession: async () => null,
|
|
208
|
+
});
|
|
209
|
+
const first = runtime.tick();
|
|
210
|
+
await runtime.tick(); // overlapping → dropped
|
|
211
|
+
release();
|
|
212
|
+
await first;
|
|
213
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('never schedules a timer when disabled (interval 0) and disposes cleanly', async () => {
|
|
217
|
+
vi.useFakeTimers();
|
|
218
|
+
const refreshLiveActivityTokens = vi.fn(async () => {});
|
|
219
|
+
const runtime = createLiveActivityRefreshRuntime({
|
|
220
|
+
intervalMs: 0,
|
|
221
|
+
refreshLiveActivityTokens,
|
|
222
|
+
getSessionStateSnapshot: () => ({}),
|
|
223
|
+
resolveSession: async () => null,
|
|
224
|
+
});
|
|
225
|
+
runtime.start();
|
|
226
|
+
await vi.advanceTimersByTimeAsync(120_000);
|
|
227
|
+
expect(refreshLiveActivityTokens).not.toHaveBeenCalled();
|
|
228
|
+
|
|
229
|
+
const enabled = createLiveActivityRefreshRuntime({
|
|
230
|
+
intervalMs: 30_000,
|
|
231
|
+
refreshLiveActivityTokens,
|
|
232
|
+
getSessionStateSnapshot: () => ({}),
|
|
233
|
+
resolveSession: async () => null,
|
|
234
|
+
});
|
|
235
|
+
enabled.start();
|
|
236
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
237
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
238
|
+
enabled.dispose();
|
|
239
|
+
await vi.advanceTimersByTimeAsync(120_000);
|
|
240
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
});
|