@openchambery/web 1.18.2-beta.16 → 1.18.2-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/assets/{AssistantView-5b0FsCQa.js → AssistantView-BjECeCP-.js} +1 -1
  2. package/dist/assets/{ChatContainer-QtcnvWoA.js → ChatContainer-BYOJGW70.js} +4 -4
  3. package/dist/assets/{DiagramView-BoWXd8X4.js → DiagramView-y5N3UVYC.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-0Gq_Uqs_.js → MarkdownRendererImpl-C_42Xy0b.js} +1 -1
  5. package/dist/assets/{MobileDetailNavigation-CBWFnBIu.js → MobileDetailNavigation-CESuP4mM.js} +1 -1
  6. package/dist/assets/{MobileShareBridge-CTg5aHEn.js → MobileShareBridge-DteabHid.js} +1 -1
  7. package/dist/assets/{MobileSurface-Dw6Ina7A.js → MobileSurface-U-yc1m0-.js} +1 -1
  8. package/dist/assets/{MultiRunWindow-De0C4bCt.js → MultiRunWindow-CCF0PXlx.js} +1 -1
  9. package/dist/assets/{OnboardingScreen-7mFH7iBm.js → OnboardingScreen-CYeRZznl.js} +1 -1
  10. package/dist/assets/{OverlayScrollbar-DzX65Ew5.js → OverlayScrollbar-C5kI44g7.js} +1 -1
  11. package/dist/assets/{SettingsView-Bs2kVAA1.js → SettingsView-DZb1RZlV.js} +1 -1
  12. package/dist/assets/{SettingsWindow-axr7ewCq.js → SettingsWindow-DRQK5GqM.js} +1 -1
  13. package/dist/assets/{TerminalView-B_5ml6J4.js → TerminalView-Ynxbvy5-.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-CR5Ifk2Z.js → ToolOutputDialog-Cav2sifO.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-ih8IT_2E.js → appThemeRegistry-C_UnqMeo.js} +2 -2
  16. package/dist/assets/{clipboard-Cm4VHQ3n.js → clipboard-BnElHSnO.js} +1 -1
  17. package/dist/assets/{context-menu-DyfEPvnE.js → context-menu-D00De8AC.js} +1 -1
  18. package/dist/assets/{dialog-ruyemRYh.js → dialog-COrLBm9O.js} +1 -1
  19. package/dist/assets/{es-W_z2kSYf.js → es-DIVYdoxi.js} +1 -1
  20. package/dist/assets/{fr-MYuf2QNQ.js → fr-CNTlVypq.js} +1 -1
  21. package/dist/assets/{gitApi-BrvHOAWU.js → gitApi-BSovla63.js} +1 -1
  22. package/dist/assets/{insertionBoundaries-BW7YHXi6.js → insertionBoundaries-BzxCFqY7.js} +1 -1
  23. package/dist/assets/{ja-DBkl4NXl.js → ja-C_eW5ZM8.js} +1 -1
  24. package/dist/assets/{ko-DwSsHsxa.js → ko-CE3cOv7v.js} +1 -1
  25. package/dist/assets/{main-BBKtt1Y9.js → main-BaqqdTzl.js} +4 -4
  26. package/dist/assets/{main-fdHtRqqY.js → main-yYt_TrZD.js} +2 -2
  27. package/dist/assets/{miniChat-AFFBcZuq.js → miniChat-DeS-YbIL.js} +2 -2
  28. package/dist/assets/{mobile-BjoYS6yy.js → mobile-Cg1Lw3I_.js} +2 -2
  29. package/dist/assets/{multirun-BUCHfy2k.js → multirun-CjmiAnFl.js} +1 -1
  30. package/dist/assets/{pl-BGLpnSSE.js → pl-BZKZOE99.js} +1 -1
  31. package/dist/assets/{projectMeta-GWaQA3Nh.js → projectMeta-BQJHLcsI.js} +1 -1
  32. package/dist/assets/{pt-BR-DuEh49lp.js → pt-BR-DHDgp8S3.js} +1 -1
  33. package/dist/assets/{radio-DLT8WkB1.js → radio-BpKBzfEa.js} +1 -1
  34. package/dist/assets/{renderElectronMiniChatApp-mk15OpZl.js → renderElectronMiniChatApp-DNRlOH_8.js} +2 -2
  35. package/dist/assets/renderMobileApp-zMbsHo-j.js +23 -0
  36. package/dist/assets/{runtimeEndpointReset-C-zHA9mH.js → runtimeEndpointReset-_cbs-WPN.js} +1 -1
  37. package/dist/assets/{sortable-tabs-strip-B8ZHvoDm.js → sortable-tabs-strip-CQ6Jhk4W.js} +1 -1
  38. package/dist/assets/src-DcPZ_rWS.css +2 -0
  39. package/dist/assets/{store-Cb8Q6EtD.js → store-CUWqzRt7.js} +2 -2
  40. package/dist/assets/{uk-CUoSz-Q8.js → uk-CfQK6cz1.js} +1 -1
  41. package/dist/assets/{useAppFontEffects-BmoxCfbH.js → useAppFontEffects-CvGJXvOL.js} +2 -2
  42. package/dist/assets/{useDesktopWindowControlsLayout-BLOLzV-B.js → useDesktopWindowControlsLayout-BztJfpeS.js} +1 -1
  43. package/dist/assets/{useEffectiveDirectory-DYoU9MD8.js → useEffectiveDirectory-BNxfJJ4B.js} +1 -1
  44. package/dist/assets/{useMobileNavigationStore-DpFAK6sE.js → useMobileNavigationStore-te_AdqSf.js} +5 -5
  45. package/dist/assets/{useSessionAutoCleanup-B5wYiGWJ.js → useSessionAutoCleanup-DDcW7p4V.js} +1 -1
  46. package/dist/assets/{useWorkerHighlightedLines-DkDOqMcg.js → useWorkerHighlightedLines-HT_ad5F8.js} +1 -1
  47. package/dist/assets/{zh-CN-CJP5FWCK.js → zh-CN-DvY2fKWp.js} +1 -1
  48. package/dist/assets/{zh-TW-B_2h1yuo.js → zh-TW-eWe0lm3-.js} +1 -1
  49. package/dist/index.html +2 -2
  50. package/dist/mini-chat.html +2 -2
  51. package/dist/mobile.html +2 -2
  52. package/package.json +1 -1
  53. package/server/index.js +7 -0
  54. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  55. package/server/lib/notifications/runtime.js +68 -27
  56. package/server/lib/notifications/runtime.test.js +176 -0
  57. package/server/lib/session-index/DOCUMENTATION.md +6 -5
  58. package/server/lib/session-index/service.js +1 -0
  59. package/server/lib/session-index/service.test.js +7 -2
  60. package/server/lib/session-title/DOCUMENTATION.md +3 -2
  61. package/server/lib/session-title/runtime.js +1 -0
  62. package/server/lib/session-title/runtime.test.js +6 -0
  63. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  64. package/server/lib/small-model/catalog.js +10 -2
  65. package/server/lib/small-model/catalog.test.js +3 -1
  66. package/server/lib/small-model/index.js +52 -28
  67. package/server/lib/small-model/opencode-session.js +339 -0
  68. package/server/lib/small-model/opencode-session.test.js +177 -0
  69. package/server/lib/small-model/resolve.js +116 -5
  70. package/server/lib/small-model/resolve.test.js +76 -10
  71. package/server/lib/small-model/summary-settings.test.js +60 -3
  72. package/dist/assets/renderMobileApp-1_-KjQ6K.js +0 -23
  73. package/dist/assets/src-C8jn0x69.css +0 -2
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-fdHtRqqY.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-yYt_TrZD.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -568,7 +568,7 @@
568
568
  <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-BxGAFLTf.js">
569
569
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-DpiAJlil.js">
570
570
  <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BXCt7ZsQ.js">
571
- <link rel="stylesheet" crossorigin href="/assets/src-C8jn0x69.css">
571
+ <link rel="stylesheet" crossorigin href="/assets/src-DcPZ_rWS.css">
572
572
  </head>
573
573
  <body class="h-full bg-background text-foreground">
574
574
  <!-- Keep the startup layer outside #root. React replaces every child of
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-AFFBcZuq.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-DeS-YbIL.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -90,7 +90,7 @@
90
90
  <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-BxGAFLTf.js">
91
91
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-DpiAJlil.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BXCt7ZsQ.js">
93
- <link rel="stylesheet" crossorigin href="/assets/src-C8jn0x69.css">
93
+ <link rel="stylesheet" crossorigin href="/assets/src-DcPZ_rWS.css">
94
94
  </head>
95
95
  <body class="h-full bg-background text-foreground">
96
96
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-BjoYS6yy.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-Cg1Lw3I_.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -79,7 +79,7 @@
79
79
  <link rel="modulepreload" crossorigin href="/assets/gitApiHttp-BxGAFLTf.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-DpiAJlil.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BXCt7ZsQ.js">
82
- <link rel="stylesheet" crossorigin href="/assets/src-C8jn0x69.css">
82
+ <link rel="stylesheet" crossorigin href="/assets/src-DcPZ_rWS.css">
83
83
  </head>
84
84
  <body class="h-full bg-background text-foreground">
85
85
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.18.2-beta.16",
3
+ "version": "1.18.2-beta.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.js CHANGED
@@ -1714,6 +1714,13 @@ async function main(options = {}) {
1714
1714
  } catch {
1715
1715
  // best-effort shutdown of the dictation worker
1716
1716
  }
1717
+ try {
1718
+ if (smallModelServiceInstance?.stop) {
1719
+ await smallModelServiceInstance.stop();
1720
+ }
1721
+ } catch {
1722
+ // best-effort cleanup of the small-model temp OpenCode directory
1723
+ }
1717
1724
  return gracefulShutdown({
1718
1725
  exitProcess: shutdownOptions.exitProcess ?? false,
1719
1726
  forceCloseConnections: shutdownOptions.forceCloseConnections === true,
@@ -45,8 +45,8 @@ This module provides notification message preparation utilities for the web serv
45
45
  - Returned API:
46
46
  - `maybeSendPushForTrigger(payload)`
47
47
  - Owns:
48
- - top-level completion/question/permission trigger routing; completion is the sole task-status notification and child sessions are suppressed
49
- - session parent cache for child-session suppression
48
+ - top-level completion/question/permission trigger routing; completion is the sole task-status notification and child sessions plus small-model system sessions (`metadata.openchamber.smallModel.purpose`) are suppressed
49
+ - session meta cache for child-session and small-model suppression
50
50
  - template resolution and fallback behavior
51
51
  - native notification fanout and web push payload fanout
52
52
  - push suppression while any fresh UI visibility heartbeat reports a focused client
@@ -111,8 +111,8 @@ export const createNotificationTriggerRuntime = (deps) => {
111
111
  const notifiedPermissionRequests = new Set();
112
112
  const lastReadyNotificationAt = new Map();
113
113
 
114
- const sessionParentIdCache = new Map();
115
- const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
114
+ const sessionMetaCache = new Map();
115
+ const SESSION_META_CACHE_TTL_MS = 60 * 1000;
116
116
 
117
117
  // Sessions where the client has enabled Permission Auto-Accept. Mirrored
118
118
  // from the client-side permissionStore via POST /api/notifications/auto-accept
@@ -136,43 +136,67 @@ export const createNotificationTriggerRuntime = (deps) => {
136
136
  return `/?session=${encodeURIComponent(sessionId)}`;
137
137
  };
138
138
 
139
- const getSessionParentCacheKey = (sessionId, directory) => `${directory || ''}\0${sessionId}`;
139
+ const getSessionMetaCacheKey = (sessionId, directory) => `${directory || ''}\0${sessionId}`;
140
140
 
141
- const getCachedSessionParentId = (sessionId, directory) => {
142
- const cacheKey = getSessionParentCacheKey(sessionId, directory);
143
- const entry = sessionParentIdCache.get(cacheKey);
141
+ const normalizeParentID = (value) => (
142
+ typeof value === 'string' && value.length > 0 ? value : null
143
+ );
144
+
145
+ const normalizeSmallModelPurpose = (value) => (
146
+ typeof value === 'string' && value.length > 0 ? value : null
147
+ );
148
+
149
+ const getCachedSessionMeta = (sessionId, directory) => {
150
+ const cacheKey = getSessionMetaCacheKey(sessionId, directory);
151
+ const entry = sessionMetaCache.get(cacheKey);
144
152
  if (!entry) return undefined;
145
- if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
146
- sessionParentIdCache.delete(cacheKey);
153
+ if (Date.now() - entry.at > SESSION_META_CACHE_TTL_MS) {
154
+ sessionMetaCache.delete(cacheKey);
147
155
  return undefined;
148
156
  }
149
- return entry.parentID;
157
+ return entry;
150
158
  };
151
159
 
152
- const setCachedSessionParentId = (sessionId, directory, parentID) => {
153
- sessionParentIdCache.set(getSessionParentCacheKey(sessionId, directory), { parentID: parentID ?? null, at: Date.now() });
160
+ const setCachedSessionMeta = (sessionId, directory, meta) => {
161
+ const previous = getCachedSessionMeta(sessionId, directory);
162
+ sessionMetaCache.set(getSessionMetaCacheKey(sessionId, directory), {
163
+ parentID: meta.parentID !== undefined ? meta.parentID : (previous?.parentID ?? null),
164
+ smallModelPurpose: meta.smallModelPurpose !== undefined
165
+ ? meta.smallModelPurpose
166
+ : (previous?.smallModelPurpose ?? null),
167
+ at: Date.now(),
168
+ });
154
169
  };
155
170
 
156
171
  const getParentIdFromPayload = (payload) => {
157
172
  if (!payload || typeof payload !== 'object') return undefined;
158
173
  if (payload.type !== 'session.created' && payload.type !== 'session.updated') return undefined;
159
- const parentID = payload.properties?.info?.parentID ?? null;
160
- return typeof parentID === 'string' && parentID.length > 0 ? parentID : null;
174
+ return normalizeParentID(payload.properties?.info?.parentID);
175
+ };
176
+
177
+ const getSmallModelPurposeFromPayload = (payload) => {
178
+ if (!payload || typeof payload !== 'object') return undefined;
179
+ if (payload.type !== 'session.created' && payload.type !== 'session.updated') return undefined;
180
+ return normalizeSmallModelPurpose(payload.properties?.info?.metadata?.openchamber?.smallModel?.purpose);
161
181
  };
162
182
 
163
- const maybeCacheSessionParentFromPayload = (payload) => {
183
+ const maybeCacheSessionMetaFromPayload = (payload) => {
164
184
  const sessionId = extractSessionIdFromPayload(payload);
165
185
  if (typeof sessionId !== 'string' || sessionId.length === 0) return;
166
186
  const directory = extractDirectoryFromPayload(payload);
167
187
  const parentID = getParentIdFromPayload(payload);
168
- if (parentID === undefined) return;
169
- setCachedSessionParentId(sessionId, directory, parentID);
188
+ const smallModelPurpose = getSmallModelPurposeFromPayload(payload);
189
+ if (parentID === undefined && smallModelPurpose === undefined) return;
190
+ setCachedSessionMeta(sessionId, directory, {
191
+ ...(parentID !== undefined ? { parentID } : {}),
192
+ ...(smallModelPurpose !== undefined ? { smallModelPurpose } : {}),
193
+ });
170
194
  };
171
195
 
172
- const fetchSessionParentId = async (sessionId, directory) => {
196
+ const fetchSessionMeta = async (sessionId, directory) => {
173
197
  if (!sessionId) return undefined;
174
198
 
175
- const cached = getCachedSessionParentId(sessionId, directory);
199
+ const cached = getCachedSessionMeta(sessionId, directory);
176
200
  if (cached !== undefined) return cached;
177
201
 
178
202
  try {
@@ -194,16 +218,22 @@ export const createNotificationTriggerRuntime = (deps) => {
194
218
  return undefined;
195
219
  }
196
220
 
197
- const parentID = typeof session.parentID === 'string' && session.parentID.length > 0
198
- ? session.parentID
199
- : null;
200
- setCachedSessionParentId(sessionId, directory, parentID);
201
- return parentID;
221
+ const meta = {
222
+ parentID: normalizeParentID(session.parentID),
223
+ smallModelPurpose: normalizeSmallModelPurpose(session.metadata?.openchamber?.smallModel?.purpose),
224
+ };
225
+ setCachedSessionMeta(sessionId, directory, meta);
226
+ return meta;
202
227
  } catch {
203
228
  return undefined;
204
229
  }
205
230
  };
206
231
 
232
+ const fetchSessionParentId = async (sessionId, directory) => {
233
+ const meta = await fetchSessionMeta(sessionId, directory);
234
+ return meta?.parentID;
235
+ };
236
+
207
237
  // Mirrors client-side autoRespondsPermission: a session auto-accepts if it
208
238
  // OR any ancestor is flagged. Walks the parent chain via fetchSessionParentId.
209
239
  const isSessionAutoAccepting = async (sessionId, directory) => {
@@ -225,6 +255,17 @@ export const createNotificationTriggerRuntime = (deps) => {
225
255
  return typeof parentID === 'string' && parentID.length > 0;
226
256
  };
227
257
 
258
+ const isSmallModelSession = async (sessionId, directory) => {
259
+ const meta = await fetchSessionMeta(sessionId, directory);
260
+ return typeof meta?.smallModelPurpose === 'string' && meta.smallModelPurpose.length > 0;
261
+ };
262
+
263
+ const shouldSkipSystemSessionNotification = async (sessionId, directory) => {
264
+ if (await isSubtaskSession(sessionId, directory)) return true;
265
+ if (await isSmallModelSession(sessionId, directory)) return true;
266
+ return false;
267
+ };
268
+
228
269
  const extractSessionIdFromPayload = (payload) => {
229
270
  if (!payload || typeof payload !== 'object') return null;
230
271
  const props = payload.properties;
@@ -309,7 +350,7 @@ export const createNotificationTriggerRuntime = (deps) => {
309
350
  return;
310
351
  }
311
352
 
312
- maybeCacheSessionParentFromPayload(payload);
353
+ maybeCacheSessionMetaFromPayload(payload);
313
354
 
314
355
  const sessionId = extractSessionIdFromPayload(payload);
315
356
  const notificationDirectory = extractDirectoryFromPayload(payload);
@@ -340,7 +381,7 @@ export const createNotificationTriggerRuntime = (deps) => {
340
381
  sessionId
341
382
  && info?.role === 'assistant'
342
383
  && (info?.finish === 'stop' || info?.finish === 'error')
343
- && await isSubtaskSession(sessionId, notificationDirectory)
384
+ && await shouldSkipSystemSessionNotification(sessionId, notificationDirectory)
344
385
  ) {
345
386
  return;
346
387
  }
@@ -433,7 +474,7 @@ export const createNotificationTriggerRuntime = (deps) => {
433
474
  }
434
475
 
435
476
  if (payload.type === 'question.asked' && sessionId) {
436
- if (await isSubtaskSession(sessionId, notificationDirectory)) {
477
+ if (await shouldSkipSystemSessionNotification(sessionId, notificationDirectory)) {
437
478
  return;
438
479
  }
439
480
  const existingTimer = pushQuestionDebounceTimers.get(sessionId);
@@ -534,7 +575,7 @@ export const createNotificationTriggerRuntime = (deps) => {
534
575
  }
535
576
 
536
577
  if (payload.type === 'permission.asked' && sessionId) {
537
- if (await isSubtaskSession(sessionId, notificationDirectory)) {
578
+ if (await shouldSkipSystemSessionNotification(sessionId, notificationDirectory)) {
538
579
  return;
539
580
  }
540
581
  const requestId = payload.properties?.id ?? payload.properties?.requestID ?? payload.properties?.requestId;
@@ -0,0 +1,176 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createNotificationTriggerRuntime } from './runtime.js';
4
+
5
+ const defaultSettings = {
6
+ notifyOnCompletion: true,
7
+ notifyOnQuestion: true,
8
+ notificationMode: 'always',
9
+ nativeNotificationsEnabled: true,
10
+ };
11
+
12
+ const createRuntime = (overrides = {}) => {
13
+ const emitDesktopNotification = vi.fn(() => true);
14
+ const broadcastUiNotification = vi.fn();
15
+ const sendPushToAllUiSessions = vi.fn(async () => {});
16
+ const sendApnsToAllUiSessions = vi.fn(async () => {});
17
+ const runtime = createNotificationTriggerRuntime({
18
+ readSettingsFromDisk: vi.fn(async () => defaultSettings),
19
+ prepareNotificationLastMessage: vi.fn(async ({ message }) => message || ''),
20
+ buildTemplateVariables: vi.fn(async () => ({ session_name: 'Session' })),
21
+ extractLastMessageText: vi.fn(() => ''),
22
+ fetchLastAssistantMessageText: vi.fn(async () => ''),
23
+ resolveNotificationTemplate: vi.fn((template) => template),
24
+ shouldApplyResolvedTemplateMessage: vi.fn(() => true),
25
+ emitDesktopNotification,
26
+ broadcastUiNotification,
27
+ sendPushToAllUiSessions,
28
+ sendApnsToAllUiSessions,
29
+ isAnyInteractiveClientVisible: () => false,
30
+ buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
31
+ getOpenCodeAuthHeaders: () => ({}),
32
+ getIsWindowFocused: () => false,
33
+ ...overrides,
34
+ });
35
+ return {
36
+ runtime,
37
+ emitDesktopNotification,
38
+ broadcastUiNotification,
39
+ sendPushToAllUiSessions,
40
+ sendApnsToAllUiSessions,
41
+ };
42
+ };
43
+
44
+ const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
45
+ status,
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.unstubAllGlobals();
51
+ vi.useRealTimers();
52
+ });
53
+
54
+ describe('notification trigger runtime smallModel suppression', () => {
55
+ it('skips ready notifications for smallModel sessions', async () => {
56
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
57
+ id: 'ses_small',
58
+ parentID: null,
59
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
60
+ })));
61
+
62
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
63
+ await runtime.maybeSendPushForTrigger({
64
+ type: 'message.updated',
65
+ properties: {
66
+ directory: '/repo',
67
+ info: {
68
+ id: 'msg_1',
69
+ sessionID: 'ses_small',
70
+ role: 'assistant',
71
+ finish: 'stop',
72
+ mode: 'build',
73
+ modelID: 'small',
74
+ },
75
+ },
76
+ });
77
+
78
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
79
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('skips ready notifications synthesized from session.idle for smallModel sessions', async () => {
83
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
84
+ id: 'ses_small',
85
+ parentID: null,
86
+ metadata: { openchamber: { smallModel: { purpose: 'commit' } } },
87
+ })));
88
+
89
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
90
+ await runtime.maybeSendPushForTrigger({
91
+ type: 'session.idle',
92
+ properties: {
93
+ directory: '/repo',
94
+ sessionID: 'ses_small',
95
+ },
96
+ });
97
+
98
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
99
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('skips question notifications for smallModel sessions', async () => {
103
+ vi.useFakeTimers();
104
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
105
+ id: 'ses_small',
106
+ parentID: null,
107
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
108
+ })));
109
+
110
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
111
+ await runtime.maybeSendPushForTrigger({
112
+ type: 'question.asked',
113
+ properties: {
114
+ directory: '/repo',
115
+ sessionID: 'ses_small',
116
+ questions: [{ header: 'Input needed', question: 'Continue?' }],
117
+ },
118
+ });
119
+ await vi.runAllTimersAsync();
120
+
121
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
122
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('skips permission notifications for smallModel sessions', async () => {
126
+ vi.useFakeTimers();
127
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
128
+ id: 'ses_small',
129
+ parentID: null,
130
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
131
+ })));
132
+
133
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
134
+ await runtime.maybeSendPushForTrigger({
135
+ type: 'permission.asked',
136
+ properties: {
137
+ directory: '/repo',
138
+ sessionID: 'ses_small',
139
+ id: 'perm_1',
140
+ permission: 'edit',
141
+ },
142
+ });
143
+ await vi.runAllTimersAsync();
144
+
145
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
146
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('still notifies ordinary root sessions', async () => {
150
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
151
+ id: 'ses_root',
152
+ parentID: null,
153
+ title: 'Ordinary',
154
+ metadata: {},
155
+ })));
156
+
157
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
158
+ await runtime.maybeSendPushForTrigger({
159
+ type: 'message.updated',
160
+ properties: {
161
+ directory: '/repo',
162
+ info: {
163
+ id: 'msg_2',
164
+ sessionID: 'ses_root',
165
+ role: 'assistant',
166
+ finish: 'stop',
167
+ mode: 'build',
168
+ modelID: 'gpt',
169
+ },
170
+ },
171
+ });
172
+
173
+ expect(emitDesktopNotification).toHaveBeenCalledTimes(1);
174
+ expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
175
+ });
176
+ });
@@ -13,11 +13,12 @@ newest 20 root-session summaries per runtime and directory. It never stores
13
13
  messages, attachments, permissions, provider data, or model metadata.
14
14
  Sessions titled `smartfetch-secondary` are temporary SmartFetch model calls;
15
15
  the index excludes them from every snapshot and clears prior summaries when a
16
- matching session update arrives. System-owned sessions are excluded by
17
- authoritative metadata only: a non-empty
18
- `metadata.openchamber.assistant.assistantID` or
19
- `metadata.openchamber.scheduledTask.taskID`. Title prefixes are human labels and
20
- never participate in this filter. Metadata is the ownership/isolation signal;
16
+ matching session update arrives. System-owned sessions are excluded by
17
+ authoritative metadata only: a non-empty
18
+ `metadata.openchamber.assistant.assistantID`,
19
+ `metadata.openchamber.scheduledTask.taskID`, or
20
+ `metadata.openchamber.smallModel.purpose`. Title prefixes are human labels and
21
+ never participate in this filter. Metadata is the ownership/isolation signal;
21
22
  `time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
22
23
  (persisted as `pinned_at`, rescued across directory rebuilds, never overwritten
23
24
  by live OpenCode upserts); titles are for recognition. The index does
@@ -15,6 +15,7 @@ const isSystemSession = (session) => {
15
15
  if (!openchamber || typeof openchamber !== 'object') return false;
16
16
  if (nonEmptySystemID(openchamber.assistant?.assistantID)) return true;
17
17
  if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
18
+ if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
18
19
  return false;
19
20
  };
20
21
 
@@ -68,7 +68,7 @@ describe('Electron session index', () => {
68
68
  service.close();
69
69
  });
70
70
 
71
- it('excludes Assistant and Scheduled system sessions from ordinary sidebar summaries', () => {
71
+ it('excludes Assistant, Scheduled, and smallModel system sessions from ordinary sidebar summaries', () => {
72
72
  const runtimeRef = { value: 'http://runtime-a.test' };
73
73
  const service = createService(runtimeRef);
74
74
  const assistantSession = {
@@ -79,11 +79,15 @@ describe('Electron session index', () => {
79
79
  ...session('ses_scheduled', 99),
80
80
  metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
81
81
  };
82
+ const smallModelSession = {
83
+ ...session('ses_small_model', 97),
84
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
85
+ };
82
86
  const ordinary = session('ses_ordinary', 98);
83
87
 
84
88
  service.replaceDirectory({
85
89
  directory: '/repo',
86
- sessions: [assistantSession, scheduledSession, ordinary],
90
+ sessions: [assistantSession, scheduledSession, smallModelSession, ordinary],
87
91
  cursor: null,
88
92
  hasMore: false,
89
93
  });
@@ -91,6 +95,7 @@ describe('Electron session index', () => {
91
95
 
92
96
  service.upsert(assistantSession);
93
97
  service.upsert(scheduledSession);
98
+ service.upsert(smallModelSession);
94
99
  expect(service.snapshot().directories[0].sessions.map((item) => item.id)).toEqual(['ses_ordinary']);
95
100
  service.close();
96
101
  });
@@ -31,8 +31,9 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
31
31
  3. On fire:
32
32
  - Skip when Settings → Chat → session title refresh is off.
33
33
  - Skip system-owned sessions whose metadata carries a non-empty
34
- `openchamber.assistant.assistantID` or `openchamber.scheduledTask.taskID`
35
- (fixed Assistant / scheduled titles must not be rewritten). Early
34
+ `openchamber.assistant.assistantID`, `openchamber.scheduledTask.taskID`, or
35
+ `openchamber.smallModel.purpose`
36
+ (fixed Assistant / scheduled / small-model titles must not be rewritten). Early
36
37
  `session.created` and smart-title request extraction skip the same
37
38
  metadata when present so timers are not armed.
38
39
  - Skip sub-agent sessions (`parentID`), multi-run/fusion structural titles,
@@ -165,6 +165,7 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
165
165
  if (!openchamber || typeof openchamber !== 'object') return false;
166
166
  if (typeof openchamber.assistant?.assistantID === 'string' && openchamber.assistant.assistantID) return true;
167
167
  if (typeof openchamber.scheduledTask?.taskID === 'string' && openchamber.scheduledTask.taskID) return true;
168
+ if (typeof openchamber.smallModel?.purpose === 'string' && openchamber.smallModel.purpose) return true;
168
169
  return false;
169
170
  };
170
171
 
@@ -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