@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21
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-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
- package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
- package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
- package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
- package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
- package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
- package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
- package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
- package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
- package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
- package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
- package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
- package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
- package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
- package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
- package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
- package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
- package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
- package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
- package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
- package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
- package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
- package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
- package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
- package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
- package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
- package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
- package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
- package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
- package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
- package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
- package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
- package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
- package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
- package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
- package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
- package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
- package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
- package/dist/assets/mobile-C4bTWKry.js +2 -0
- package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
- package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
- package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
- package/dist/assets/projectMeta-BunN9pH5.js +3 -0
- package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
- package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
- package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
- package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
- package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
- package/dist/assets/src-8kQFmtjw.css +2 -0
- package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
- package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
- package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
- package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
- package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
- package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
- package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
- package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
- package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
- package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
- package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
- package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
- package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.d.ts +6 -0
- package/server/index.js +13 -0
- package/server/lib/config-sync/DOCUMENTATION.md +70 -0
- package/server/lib/config-sync/config-sync.test.js +197 -0
- package/server/lib/config-sync/constants.js +39 -0
- package/server/lib/config-sync/contract.js +130 -0
- package/server/lib/config-sync/credential-auth.js +39 -0
- package/server/lib/config-sync/engine.js +108 -0
- package/server/lib/config-sync/index.js +67 -0
- package/server/lib/config-sync/local-backup.js +171 -0
- package/server/lib/config-sync/plan.js +318 -0
- package/server/lib/config-sync/receiver.js +330 -0
- package/server/lib/config-sync/routes.js +141 -0
- package/server/lib/config-sync/routes.test.js +154 -0
- package/server/lib/config-sync/scripts.js +264 -0
- package/server/lib/config-sync/selections.js +112 -0
- package/server/lib/config-sync/tar.js +35 -0
- package/server/lib/config-sync/target-id.js +24 -0
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/diff-summary.js +39 -11
- package/server/lib/event-stream/diff-summary.test.js +73 -6
- package/server/lib/event-stream/protocol.test.js +70 -7
- package/server/lib/notifications/DOCUMENTATION.md +2 -2
- package/server/lib/notifications/runtime.js +68 -27
- package/server/lib/notifications/runtime.test.js +176 -0
- package/server/lib/opencode/DOCUMENTATION.md +4 -1
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
- package/server/lib/opencode/openchamber-routes.js +7 -0
- package/server/lib/opencode/settings-runtime.js +82 -51
- package/server/lib/opencode/settings-runtime.test.js +42 -0
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/session-index/DOCUMENTATION.md +12 -6
- package/server/lib/session-index/routes.js +34 -0
- package/server/lib/session-index/routes.test.js +76 -0
- package/server/lib/session-index/service.js +53 -4
- package/server/lib/session-index/service.test.js +72 -2
- package/server/lib/session-title/DOCUMENTATION.md +19 -14
- package/server/lib/session-title/runtime.js +8 -8
- package/server/lib/session-title/runtime.test.js +231 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
- package/server/lib/session-turn-pages/changes.service.js +156 -0
- package/server/lib/session-turn-pages/changes.service.test.js +188 -0
- package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
- package/server/lib/session-turn-pages/reconcile.service.js +3 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
- package/server/lib/session-turn-pages/routes.js +273 -12
- package/server/lib/session-turn-pages/routes.test.js +387 -0
- package/server/lib/session-turn-pages/service.js +31 -7
- package/server/lib/session-turn-pages/service.test.js +40 -0
- package/server/lib/small-model/DOCUMENTATION.md +93 -38
- package/server/lib/small-model/catalog.js +10 -2
- package/server/lib/small-model/catalog.test.js +3 -1
- package/server/lib/small-model/index.js +52 -28
- package/server/lib/small-model/opencode-session.js +339 -0
- package/server/lib/small-model/opencode-session.test.js +177 -0
- package/server/lib/small-model/resolve.js +116 -5
- package/server/lib/small-model/resolve.test.js +76 -10
- package/server/lib/small-model/summary-settings.test.js +60 -3
- package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
- package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
- package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
- package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
- package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
- package/dist/assets/mobile-BktO3_4R.js +0 -2
- package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
- package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
- package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
- package/dist/assets/src-oHrYIqHF.css +0 -2
- package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
- package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
- package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
- package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
|
@@ -5,10 +5,11 @@ conversation's **main subject** (overall feature / goal) with the small model
|
|
|
5
5
|
(`lib/small-model`), then PATCHes `title` plus `metadata.openchamber.titleRefresh`.
|
|
6
6
|
|
|
7
7
|
OpenCode only auto-titles once from the first user message
|
|
8
|
-
(`SessionPrompt.ensureTitle`).
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
(`SessionPrompt.ensureTitle`). This module auto-refreshes sparsely (first idle
|
|
9
|
+
of a new session, first newly-sent reply on a fork) and on explicit smart-title
|
|
10
|
+
requests, naming the durable work being done — not the last wrap-up utterance
|
|
11
|
+
like "commit and push". Background auto refreshes still respect a 5-minute
|
|
12
|
+
throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
|
|
12
13
|
|
|
13
14
|
## Flow
|
|
14
15
|
|
|
@@ -16,19 +17,23 @@ minutes per session.
|
|
|
16
17
|
fan-out (`index.js` → `globalMessageStreamHub.subscribeEvent`), same
|
|
17
18
|
pattern as session-assist. Purely event-driven — dormant sessions never
|
|
18
19
|
generate anything.
|
|
19
|
-
2.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
2. Auto title refresh is intentionally sparse (title stability first). A newly
|
|
21
|
+
observed root `session.created` generates its first title immediately on the
|
|
22
|
+
first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
|
|
23
|
+
newly-created user message; the matching assistant completion triggers an
|
|
24
|
+
immediate title refresh that bypasses inherited title metadata and throttle.
|
|
25
|
+
Ordinary later idle transitions do **not** arm another refresh. Any
|
|
26
|
+
`busy`/`retry` status or a fresh user `message.updated` still clears an
|
|
27
|
+
already-armed timer (so initial/fork timers cancel if the user keeps going).
|
|
28
|
+
A sidebar smart-title request sets `titleRefresh.requestedAt`; its
|
|
29
|
+
`session.updated` event arms the same flow immediately (forced / manual
|
|
30
|
+
refresh is unaffected by the background gate).
|
|
27
31
|
3. On fire:
|
|
28
32
|
- Skip when Settings → Chat → session title refresh is off.
|
|
29
33
|
- Skip system-owned sessions whose metadata carries a non-empty
|
|
30
|
-
`openchamber.assistant.assistantID
|
|
31
|
-
|
|
34
|
+
`openchamber.assistant.assistantID`, `openchamber.scheduledTask.taskID`, or
|
|
35
|
+
`openchamber.smallModel.purpose`
|
|
36
|
+
(fixed Assistant / scheduled / small-model titles must not be rewritten). Early
|
|
32
37
|
`session.created` and smart-title request extraction skip the same
|
|
33
38
|
metadata when present so timers are not armed.
|
|
34
39
|
- Skip sub-agent sessions (`parentID`), multi-run/fusion structural titles,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// Session title refresh:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Session title refresh: regenerate the sidebar title from the conversation's
|
|
2
|
+
// MAIN SUBJECT (overall feature / goal), not just the last wrap-up utterance.
|
|
3
|
+
// OpenCode only titles once from the first user message. Auto refresh is sparse
|
|
4
|
+
// for title stability: first idle of a new session, first newly-sent reply on a
|
|
5
|
+
// fork, plus explicit smart-title / forced refresh. Continuing the same work
|
|
5
6
|
// should keep naming the thing being done — not "commit and push". This
|
|
6
7
|
// watcher throttles to at most one refresh per session every TITLE_THROTTLE_MS
|
|
7
8
|
// and never overwrites a user-renamed title.
|
|
8
9
|
//
|
|
9
|
-
// Purely event-driven: only sessions that
|
|
10
|
-
//
|
|
10
|
+
// Purely event-driven: only sessions that arm a refresh while the server is
|
|
11
|
+
// running ever generate anything. No backfill, no session scans.
|
|
11
12
|
|
|
12
13
|
import fs from 'fs';
|
|
13
14
|
import os from 'os';
|
|
@@ -164,6 +165,7 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
|
|
|
164
165
|
if (!openchamber || typeof openchamber !== 'object') return false;
|
|
165
166
|
if (typeof openchamber.assistant?.assistantID === 'string' && openchamber.assistant.assistantID) return true;
|
|
166
167
|
if (typeof openchamber.scheduledTask?.taskID === 'string' && openchamber.scheduledTask.taskID) return true;
|
|
168
|
+
if (typeof openchamber.smallModel?.purpose === 'string' && openchamber.smallModel.purpose) return true;
|
|
167
169
|
return false;
|
|
168
170
|
};
|
|
169
171
|
|
|
@@ -742,8 +744,6 @@ export const createSessionTitleRuntime = ({
|
|
|
742
744
|
if (newSessions.delete(status.sessionId)) {
|
|
743
745
|
initialRefreshes.add(status.sessionId);
|
|
744
746
|
armTimer(status.sessionId, status.directory || directoryHint, 0);
|
|
745
|
-
} else {
|
|
746
|
-
armTimer(status.sessionId, status.directory || directoryHint, quietMs);
|
|
747
747
|
}
|
|
748
748
|
} else {
|
|
749
749
|
clearTimer(status.sessionId);
|
|
@@ -232,10 +232,16 @@ describe('session-title helpers', () => {
|
|
|
232
232
|
expect(isSystemOwnedSession({
|
|
233
233
|
metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
|
|
234
234
|
})).toBe(true);
|
|
235
|
+
expect(isSystemOwnedSession({
|
|
236
|
+
metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
|
|
237
|
+
})).toBe(true);
|
|
235
238
|
expect(isSystemOwnedSession({
|
|
236
239
|
title: '[Assistant] Looks system',
|
|
237
240
|
metadata: { openchamber: { assistant: { name: 'no-id' } } },
|
|
238
241
|
})).toBe(false);
|
|
242
|
+
expect(isSystemOwnedSession({
|
|
243
|
+
metadata: { openchamber: { smallModel: { purpose: '' } } },
|
|
244
|
+
})).toBe(false);
|
|
239
245
|
expect(isSystemOwnedSession({ title: 'Ordinary' })).toBe(false);
|
|
240
246
|
});
|
|
241
247
|
|
|
@@ -403,4 +409,229 @@ describe('session-title helpers', () => {
|
|
|
403
409
|
globalThis.fetch = originalFetch;
|
|
404
410
|
}
|
|
405
411
|
});
|
|
412
|
+
|
|
413
|
+
it('refreshes a new session on first idle, then ignores later idle transitions', async () => {
|
|
414
|
+
const originalFetch = globalThis.fetch;
|
|
415
|
+
const session = {
|
|
416
|
+
id: 'ses-new',
|
|
417
|
+
title: 'New session - 2026-07-10T12:00:00.000Z',
|
|
418
|
+
metadata: {},
|
|
419
|
+
};
|
|
420
|
+
const messages = [
|
|
421
|
+
{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Add rate limiting' }] },
|
|
422
|
+
{ info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Implemented rate limit' }] },
|
|
423
|
+
];
|
|
424
|
+
let generationCalls = 0;
|
|
425
|
+
let patchedTitle = session.title;
|
|
426
|
+
|
|
427
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
428
|
+
const url = String(input);
|
|
429
|
+
if (url.includes('/message')) {
|
|
430
|
+
return new Response(JSON.stringify(messages), { status: 200 });
|
|
431
|
+
}
|
|
432
|
+
if (init.method === 'PATCH') {
|
|
433
|
+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
|
|
434
|
+
if (typeof body?.title === 'string') {
|
|
435
|
+
patchedTitle = body.title;
|
|
436
|
+
session.title = body.title;
|
|
437
|
+
session.metadata = body.metadata || session.metadata;
|
|
438
|
+
} else if (body?.metadata) {
|
|
439
|
+
session.metadata = body.metadata;
|
|
440
|
+
}
|
|
441
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
442
|
+
}
|
|
443
|
+
return new Response(JSON.stringify({ ...session, title: patchedTitle }), { status: 200 });
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const runtime = createSessionTitleRuntime({
|
|
448
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
449
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
450
|
+
getSmallModelService: async () => ({
|
|
451
|
+
generateSmallModelText: async () => {
|
|
452
|
+
generationCalls += 1;
|
|
453
|
+
return { text: 'Rate limiting', providerID: 'test', modelID: 'test' };
|
|
454
|
+
},
|
|
455
|
+
}),
|
|
456
|
+
now: () => 1_000,
|
|
457
|
+
isTitleRefreshEnabled: () => true,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
runtime.processPayload({
|
|
461
|
+
type: 'session.created',
|
|
462
|
+
properties: {
|
|
463
|
+
directory: '/repo',
|
|
464
|
+
info: { id: 'ses-new', title: session.title, time: { created: 100 } },
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
runtime.processPayload({
|
|
468
|
+
type: 'session.status',
|
|
469
|
+
properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'idle' } },
|
|
470
|
+
});
|
|
471
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
472
|
+
expect(generationCalls).toBe(1);
|
|
473
|
+
|
|
474
|
+
// Later conversation progress + idle must not auto-refresh again.
|
|
475
|
+
messages.push(
|
|
476
|
+
{ info: { id: 'u2', role: 'user' }, parts: [{ type: 'text', text: 'Also add retries' }] },
|
|
477
|
+
{ info: { id: 'a2', role: 'assistant' }, parts: [{ type: 'text', text: 'Retries added' }] },
|
|
478
|
+
);
|
|
479
|
+
runtime.processPayload({
|
|
480
|
+
type: 'message.updated',
|
|
481
|
+
properties: { directory: '/repo', info: { sessionID: 'ses-new', role: 'user', time: { created: 200 } } },
|
|
482
|
+
});
|
|
483
|
+
runtime.processPayload({
|
|
484
|
+
type: 'session.status',
|
|
485
|
+
properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'busy' } },
|
|
486
|
+
});
|
|
487
|
+
runtime.processPayload({
|
|
488
|
+
type: 'session.status',
|
|
489
|
+
properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'idle' } },
|
|
490
|
+
});
|
|
491
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
492
|
+
expect(generationCalls).toBe(1);
|
|
493
|
+
|
|
494
|
+
runtime.stop();
|
|
495
|
+
} finally {
|
|
496
|
+
globalThis.fetch = originalFetch;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('still runs an explicit smart-title forced refresh after initial title', async () => {
|
|
501
|
+
const originalFetch = globalThis.fetch;
|
|
502
|
+
const session = {
|
|
503
|
+
id: 'ses-forced',
|
|
504
|
+
title: 'Rate limiting',
|
|
505
|
+
metadata: {
|
|
506
|
+
openchamber: {
|
|
507
|
+
titleRefresh: {
|
|
508
|
+
lastAutoTitle: 'Rate limiting',
|
|
509
|
+
generatedAt: 500,
|
|
510
|
+
forMessageID: 'a1',
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
const messages = [
|
|
516
|
+
{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Add rate limiting' }] },
|
|
517
|
+
{ info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Implemented rate limit' }] },
|
|
518
|
+
{ info: { id: 'u2', role: 'user' }, parts: [{ type: 'text', text: 'Switch to token bucket' }] },
|
|
519
|
+
{ info: { id: 'a2', role: 'assistant' }, parts: [{ type: 'text', text: 'Token bucket ready' }] },
|
|
520
|
+
];
|
|
521
|
+
let generationCalls = 0;
|
|
522
|
+
|
|
523
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
524
|
+
const url = String(input);
|
|
525
|
+
if (url.includes('/message')) {
|
|
526
|
+
return new Response(JSON.stringify(messages), { status: 200 });
|
|
527
|
+
}
|
|
528
|
+
if (init.method === 'PATCH') {
|
|
529
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
530
|
+
}
|
|
531
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
const runtime = createSessionTitleRuntime({
|
|
536
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
537
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
538
|
+
getSmallModelService: async () => ({
|
|
539
|
+
generateSmallModelText: async () => {
|
|
540
|
+
generationCalls += 1;
|
|
541
|
+
return { text: 'Token bucket rate limiting', providerID: 'test', modelID: 'test' };
|
|
542
|
+
},
|
|
543
|
+
}),
|
|
544
|
+
now: () => 1_000,
|
|
545
|
+
isTitleRefreshEnabled: () => true,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
runtime.processPayload({
|
|
549
|
+
type: 'session.status',
|
|
550
|
+
properties: { sessionID: 'ses-forced', directory: '/repo', status: { type: 'idle' } },
|
|
551
|
+
});
|
|
552
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
553
|
+
expect(generationCalls).toBe(0);
|
|
554
|
+
|
|
555
|
+
runtime.processPayload({
|
|
556
|
+
type: 'session.updated',
|
|
557
|
+
properties: {
|
|
558
|
+
directory: '/repo',
|
|
559
|
+
info: {
|
|
560
|
+
id: 'ses-forced',
|
|
561
|
+
title: session.title,
|
|
562
|
+
metadata: {
|
|
563
|
+
openchamber: {
|
|
564
|
+
titleRefresh: {
|
|
565
|
+
...session.metadata.openchamber.titleRefresh,
|
|
566
|
+
requestedAt: 1_000,
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
574
|
+
expect(generationCalls).toBe(1);
|
|
575
|
+
|
|
576
|
+
runtime.stop();
|
|
577
|
+
} finally {
|
|
578
|
+
globalThis.fetch = originalFetch;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('skips automatic initial refresh when session title refresh is disabled', async () => {
|
|
583
|
+
const originalFetch = globalThis.fetch;
|
|
584
|
+
const session = {
|
|
585
|
+
id: 'ses-disabled',
|
|
586
|
+
title: 'New session - 2026-07-10T12:00:00.000Z',
|
|
587
|
+
metadata: {},
|
|
588
|
+
};
|
|
589
|
+
let generationCalls = 0;
|
|
590
|
+
|
|
591
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
592
|
+
if (String(input).includes('/message')) {
|
|
593
|
+
return new Response(JSON.stringify([
|
|
594
|
+
{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'hello' }] },
|
|
595
|
+
{ info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'world' }] },
|
|
596
|
+
]), { status: 200 });
|
|
597
|
+
}
|
|
598
|
+
if (init.method === 'PATCH') {
|
|
599
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
600
|
+
}
|
|
601
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const runtime = createSessionTitleRuntime({
|
|
606
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
607
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
608
|
+
getSmallModelService: async () => ({
|
|
609
|
+
generateSmallModelText: async () => {
|
|
610
|
+
generationCalls += 1;
|
|
611
|
+
return { text: 'Should not run', providerID: 'test', modelID: 'test' };
|
|
612
|
+
},
|
|
613
|
+
}),
|
|
614
|
+
now: () => 1_000,
|
|
615
|
+
isTitleRefreshEnabled: () => false,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
runtime.processPayload({
|
|
619
|
+
type: 'session.created',
|
|
620
|
+
properties: {
|
|
621
|
+
directory: '/repo',
|
|
622
|
+
info: { id: 'ses-disabled', title: session.title, time: { created: 100 } },
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
runtime.processPayload({
|
|
626
|
+
type: 'session.status',
|
|
627
|
+
properties: { sessionID: 'ses-disabled', directory: '/repo', status: { type: 'idle' } },
|
|
628
|
+
});
|
|
629
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
630
|
+
expect(generationCalls).toBe(0);
|
|
631
|
+
|
|
632
|
+
runtime.stop();
|
|
633
|
+
} finally {
|
|
634
|
+
globalThis.fetch = originalFetch;
|
|
635
|
+
}
|
|
636
|
+
});
|
|
406
637
|
});
|
|
@@ -11,6 +11,8 @@ Exposed as:
|
|
|
11
11
|
|
|
12
12
|
- `GET /api/openchamber/sessions/:sessionID/messages`
|
|
13
13
|
- `GET /api/openchamber/sessions/:sessionID/messages/reconcile`
|
|
14
|
+
- `GET /api/openchamber/sessions/:sessionID/changes` (L2 file list / L3 single-file patch)
|
|
15
|
+
- `GET /api/session/:sessionID/message/:messageID` (exact message with L1 summary projection; registered before the generic OpenCode proxy)
|
|
14
16
|
|
|
15
17
|
## Why this exists
|
|
16
18
|
|
|
@@ -69,9 +71,22 @@ Success `200`:
|
|
|
69
71
|
- `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch the trimmed history.
|
|
70
72
|
- `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
|
|
71
73
|
|
|
72
|
-
### Turn-page
|
|
74
|
+
### Turn-page payload projection
|
|
75
|
+
|
|
76
|
+
Every turn-page response summarizes `tool`, `reasoning`, and `file` parts plus message `summary.diffs` so it does not have to carry the bodies of a long tool-heavy turn, inline attachment data URLs, or full file patches. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
|
|
77
|
+
|
|
78
|
+
### Changes Host contract (L1 / L2 / L3)
|
|
79
|
+
|
|
80
|
+
| Layer | Surface | Payload |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| **L1** | Turn pages, reconcile response pages, message/session SSE fan-out, exact `GET /api/session/:sessionID/message/:messageID` | Any `info.summary.diffs` **array** is projected to a **slim file list** `[{ file, status?, additions, deletions }]` plus additive markers `summary.diffCount: number` and `summary.hasDiffs: boolean`. Patch/before/after/from/to and other heavy bodies are stripped. Other lightweight summary fields are preserved. `diffCount` equals the slim array length; `hasDiffs === diffCount > 0`. Consumers may use either the slim array or the markers. |
|
|
83
|
+
| **L2** | `GET /api/openchamber/sessions/:sessionID/changes?messageID=` (no `file`) | Host reads official `session.message` locally, returns only `{ files: [{ file, status?, additions, deletions }] }`. Never returns patch/before/after/from/to or the full message envelope. |
|
|
84
|
+
| **L3** | Same route with `file=` | Host calls official `session.diff({ sessionID, messageID, directory })`, returns `{ diff: SnapshotFileDiff }` for the exact `file` string match (includes that file's patch). No match → HTTP 404 `{ error: 'change file not found', code: 'change_not_found' }`. |
|
|
85
|
+
|
|
86
|
+
Exact message GET keeps the official `{ info, parts }` response shape and only applies L1 summary projection so materialize/recovery never relays raw multi-megabyte patch bodies through the generic proxy. Abort/45s timeout semantics match the turn-page routes. Global `/api` auth still applies.
|
|
87
|
+
|
|
88
|
+
Changes query guards: `sessionID` / `messageID` length `1..512`; optional `file` length `1..4096` and rejects control characters / NUL. Failures: invalid params → 400, abort → 499, upstream/malformed → 502. Logs carry only safe flags and ids — never paths, patch bodies, or tokens.
|
|
73
89
|
|
|
74
|
-
Every turn-page response summarizes `tool`, `reasoning`, and `file` parts so it does not have to carry the bodies of a long tool-heavy turn or inline attachment data URLs. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
|
|
75
90
|
|
|
76
91
|
| Kept | Dropped |
|
|
77
92
|
|---|---|
|
|
@@ -85,6 +100,7 @@ Rules:
|
|
|
85
100
|
- Slim tool input keeps only short locator and task-identity fields (path, pattern, query, command, `subagent_type`, `description`, skill `name` / `id`). Slim metadata keeps `sessionId`, skill `name`, plus edit `additions`/`deletions`. Projection still drops result bodies, task prompts, patches, and large write payloads.
|
|
86
101
|
- Assistant `text` stays intact. User rows are no longer wholesale pass-through: their `file` parts are projected; other user parts stay as-is.
|
|
87
102
|
- `file` projection keeps already-present size/width/height (top-level or `metadata`). `byteSize` is derived from a data URL when present. PNG/GIF/JPEG/WebP headers may fill missing `width`/`height`. Unknown dimensions omit those fields.
|
|
103
|
+
- Message `info.summary.diffs` is L1-projected to a slim file list plus `diffCount` / `hasDiffs` (never patch bodies). Reconcile preserves full parts and applies this same L1 summary projection to response-page byte budgeting and serialization; raw upstream scan budgets retain raw bytes. Clients that need a dedicated file-list envelope or per-file patches use the Changes L2/L3 API.
|
|
88
104
|
- A slim `file` part is a reference, not renderable content. Clients must hydrate the full message (`session.message` by `messageID`) before painting or editing the attachment. Do not treat slim file metadata as a complete part.
|
|
89
105
|
- Records that gain nothing from projection keep their original object reference, so identity-based change detection downstream still works.
|
|
90
106
|
- Projection applies to every turn-page response, including first packet, prepend, and explicit cursors (`before` present). Reconcile routes return full parts, unchanged.
|
|
@@ -242,10 +258,11 @@ When the per-page budget fills before the gap is complete, the host returns `con
|
|
|
242
258
|
|
|
243
259
|
## Files
|
|
244
260
|
|
|
245
|
-
- `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
|
|
261
|
+
- `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `projectMessageDiffSummaries` (L1), `projectSlimParts`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
|
|
246
262
|
- `reconcile.service.js` — `encodeReconcileContinuation`, `decodeReconcileContinuation`, `createSessionReconcileService({ fetchPage, runtimeKey?, page/total budgets?, continuationSecret?, clock?, continuationTtlMs? })`
|
|
247
|
-
- `
|
|
248
|
-
- `
|
|
263
|
+
- `changes.service.js` — `createSessionChangesService({ fetchMessage, fetchDiff })` for L2/L3; injectable for unit tests
|
|
264
|
+
- `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires SDK `session.messages` / `session.message` / `session.diff` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
|
|
265
|
+
- `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` / `changes.service.test.js` — unit contracts
|
|
249
266
|
|
|
250
267
|
## Registration
|
|
251
268
|
|
|
@@ -257,14 +274,17 @@ registerSessionTurnPageRoutes(app, {
|
|
|
257
274
|
});
|
|
258
275
|
```
|
|
259
276
|
|
|
260
|
-
|
|
277
|
+
Inside `registerSessionTurnPageRoutes`, registration order is: reconcile → turn-page messages → exact `GET /api/session/:sessionID/message/:messageID` → Changes. All of these register before the generic OpenCode proxy in `feature-routes-runtime.js`. Electron reuses this same Host composition in-process.
|
|
261
278
|
|
|
262
279
|
## Error HTTP map
|
|
263
280
|
|
|
264
281
|
| Condition | Status |
|
|
265
282
|
|---|---|
|
|
266
283
|
| Invalid turns / scanLimit / sessionID / cursor | 400 |
|
|
284
|
+
| Invalid messageID / file (Changes / exact message) | 400 |
|
|
267
285
|
| Invalid anchor / continuation / reconcile params | 400 |
|
|
286
|
+
| Change file not found (`change_not_found`) | 404 |
|
|
287
|
+
| Exact message not found | 404 |
|
|
268
288
|
| max_scan_pages / max_scan_messages | 413 |
|
|
269
289
|
| Client abort | 499 |
|
|
270
290
|
| Server exception (reconcile) | 500 (stack logged; safe client body) |
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenChamber-owned session Changes API (L2 file list / L3 single-file patch).
|
|
3
|
+
*
|
|
4
|
+
* L2 (no `file`): read official `session.message`, project summary.diffs to
|
|
5
|
+
* `{ files: [{ file, status?, additions, deletions }] }` — never patch bodies
|
|
6
|
+
* or the full message envelope.
|
|
7
|
+
* L3 (with `file`): call official `session.diff({ sessionID, messageID })`,
|
|
8
|
+
* return `{ diff: SnapshotFileDiff }` for the exact file string match.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-injected for unit tests (`fetchMessage`, `fetchDiff`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Project a raw summary.diffs entry to L2 file-list scalars.
|
|
15
|
+
* @param {unknown} entry
|
|
16
|
+
* @returns {{ file: string, status?: string, additions: number, deletions: number } | null}
|
|
17
|
+
*/
|
|
18
|
+
export const projectChangeFileEntry = (entry) => {
|
|
19
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const file = typeof entry.file === 'string' ? entry.file : null;
|
|
23
|
+
if (!file || file.length === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Built from allowlist only — patch/before/after/from/to never copied. */
|
|
28
|
+
const out = { file };
|
|
29
|
+
if (typeof entry.status === 'string') {
|
|
30
|
+
out.status = entry.status;
|
|
31
|
+
}
|
|
32
|
+
out.additions = typeof entry.additions === 'number' && Number.isFinite(entry.additions)
|
|
33
|
+
? entry.additions
|
|
34
|
+
: 0;
|
|
35
|
+
out.deletions = typeof entry.deletions === 'number' && Number.isFinite(entry.deletions)
|
|
36
|
+
? entry.deletions
|
|
37
|
+
: 0;
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract L2 file list from a session.message response shape.
|
|
43
|
+
* Accepts `{ info, parts }`, `{ data: { info, parts } }`, or bare info.
|
|
44
|
+
* @param {unknown} messagePayload
|
|
45
|
+
* @returns {{ files: Array<{ file: string, status?: string, additions: number, deletions: number }> }}
|
|
46
|
+
*/
|
|
47
|
+
export const projectChangeFileList = (messagePayload) => {
|
|
48
|
+
const record = messagePayload && typeof messagePayload === 'object' && !Array.isArray(messagePayload)
|
|
49
|
+
? messagePayload
|
|
50
|
+
: null;
|
|
51
|
+
const info = record?.info && typeof record.info === 'object' && !Array.isArray(record.info)
|
|
52
|
+
? record.info
|
|
53
|
+
: (record?.summary ? record : null);
|
|
54
|
+
const summary = info?.summary && typeof info.summary === 'object' && !Array.isArray(info.summary)
|
|
55
|
+
? info.summary
|
|
56
|
+
: null;
|
|
57
|
+
const diffs = Array.isArray(summary?.diffs) ? summary.diffs : [];
|
|
58
|
+
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const entry of diffs) {
|
|
61
|
+
const projected = projectChangeFileEntry(entry);
|
|
62
|
+
if (projected) files.push(projected);
|
|
63
|
+
}
|
|
64
|
+
return { files };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find exact file match in a session.diff array.
|
|
69
|
+
* @param {unknown} diffs
|
|
70
|
+
* @param {string} file
|
|
71
|
+
* @returns {unknown | null}
|
|
72
|
+
*/
|
|
73
|
+
export const findChangeFileDiff = (diffs, file) => {
|
|
74
|
+
if (!Array.isArray(diffs) || typeof file !== 'string') {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of diffs) {
|
|
78
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.file === file) {
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {{
|
|
87
|
+
* fetchMessage: (args: { sessionID: string, messageID: string, directory?: string, signal?: AbortSignal }) => Promise<unknown>,
|
|
88
|
+
* fetchDiff: (args: { sessionID: string, messageID: string, directory?: string, signal?: AbortSignal }) => Promise<unknown>,
|
|
89
|
+
* logger?: { warn?: Function },
|
|
90
|
+
* }} deps
|
|
91
|
+
*/
|
|
92
|
+
export const createSessionChangesService = (deps) => {
|
|
93
|
+
const {
|
|
94
|
+
fetchMessage,
|
|
95
|
+
fetchDiff,
|
|
96
|
+
logger = console,
|
|
97
|
+
} = deps;
|
|
98
|
+
|
|
99
|
+
if (typeof fetchMessage !== 'function' || typeof fetchDiff !== 'function') {
|
|
100
|
+
throw new Error('createSessionChangesService requires fetchMessage and fetchDiff');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{
|
|
105
|
+
* sessionID: string,
|
|
106
|
+
* messageID: string,
|
|
107
|
+
* directory?: string,
|
|
108
|
+
* file?: string,
|
|
109
|
+
* signal?: AbortSignal,
|
|
110
|
+
* }} input
|
|
111
|
+
*/
|
|
112
|
+
const loadChanges = async (input) => {
|
|
113
|
+
const sessionID = typeof input?.sessionID === 'string' ? input.sessionID : '';
|
|
114
|
+
const messageID = typeof input?.messageID === 'string' ? input.messageID : '';
|
|
115
|
+
const directory = typeof input?.directory === 'string' && input.directory.length > 0
|
|
116
|
+
? input.directory
|
|
117
|
+
: undefined;
|
|
118
|
+
const file = typeof input?.file === 'string' && input.file.length > 0
|
|
119
|
+
? input.file
|
|
120
|
+
: undefined;
|
|
121
|
+
const signal = input?.signal;
|
|
122
|
+
|
|
123
|
+
if (!sessionID || !messageID) {
|
|
124
|
+
return { ok: false, error: 'invalid_params' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
if (!file) {
|
|
129
|
+
const payload = await fetchMessage({ sessionID, messageID, directory, signal });
|
|
130
|
+
return { ok: true, body: projectChangeFileList(payload) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const diffs = await fetchDiff({ sessionID, messageID, directory, signal });
|
|
134
|
+
if (!Array.isArray(diffs)) {
|
|
135
|
+
logger?.warn?.('[session-changes] session.diff malformed payload');
|
|
136
|
+
return { ok: false, error: 'upstream' };
|
|
137
|
+
}
|
|
138
|
+
const match = findChangeFileDiff(diffs, file);
|
|
139
|
+
if (!match) {
|
|
140
|
+
return { ok: false, error: 'change_not_found' };
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, body: { diff: match } };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error?.name === 'AbortError' || error?.code === 'aborted') {
|
|
145
|
+
return { ok: false, error: 'aborted' };
|
|
146
|
+
}
|
|
147
|
+
if (typeof error?.code === 'string' && error.code.length > 0) {
|
|
148
|
+
return { ok: false, error: error.code };
|
|
149
|
+
}
|
|
150
|
+
logger?.warn?.('[session-changes] upstream failure');
|
|
151
|
+
return { ok: false, error: 'upstream' };
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { loadChanges };
|
|
156
|
+
};
|