@openchambery/web 1.19.7-beta.2 → 1.19.7-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.
Files changed (187) hide show
  1. package/dist/assets/AgentAvatar-DI2zs2WH.js +1 -0
  2. package/dist/assets/AssistantView-sV1PVQiM.js +2 -0
  3. package/dist/assets/DiagramView-D2hqP5VV.js +1 -0
  4. package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
  5. package/dist/assets/Icon-4My8YZwt.js +1 -0
  6. package/dist/assets/MarkdownLoadingSkeleton-Vezjvk9B.js +3 -0
  7. package/dist/assets/MarkdownRenderer-x091Z--3.js +2 -0
  8. package/dist/assets/MarkdownRendererImpl-DML8BSro.js +7 -0
  9. package/dist/assets/MarkstreamRendererImpl-BK-HM2k3.js +1 -0
  10. package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
  11. package/dist/assets/MobileDetailNavigation-DcxkWiks.js +1 -0
  12. package/dist/assets/MobileShareBridge-TAGcOiyb.js +4 -0
  13. package/dist/assets/MobileSurface-zqlZzEFQ.js +2 -0
  14. package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-iH1d9S14.js} +1 -1
  15. package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
  16. package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
  17. package/dist/assets/SettingsView-CzW23DZA.js +25 -0
  18. package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-DZXCWZid.js} +1 -1
  19. package/dist/assets/TerminalView-pU0wpD8s.js +8 -0
  20. package/dist/assets/ToolOutputDialog-Bt7JChFG.js +10 -0
  21. package/dist/assets/appThemeRegistry-BI7WR2Wo.js +1 -0
  22. package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
  23. package/dist/assets/clipboard-BXxLshVq.js +1 -0
  24. package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
  25. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  26. package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
  27. package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
  28. package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
  29. package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
  30. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  31. package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
  32. package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
  33. package/dist/assets/fileReferenceActions-Di_DoDex.js +1 -0
  34. package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
  35. package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-CeFszLvk.js} +3 -3
  36. package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
  37. package/dist/assets/insertionBoundaries-DDJNusn2.js +4 -0
  38. package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
  39. package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
  40. package/dist/assets/main-CjnOsKDL.js +169 -0
  41. package/dist/assets/main-j-Ij13jc.js +2 -0
  42. package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
  43. package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
  44. package/dist/assets/miniChat-DgxavREE.js +2 -0
  45. package/dist/assets/mobile-C0rwPMPM.js +2 -0
  46. package/dist/assets/multirun-ot7-bKg7.js +4 -0
  47. package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
  48. package/dist/assets/path-utils-jGozTg9d.js +1 -0
  49. package/dist/assets/persistence-CSZp1CNB.js +3 -0
  50. package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
  51. package/dist/assets/platform-CenlWqxu.js +2 -0
  52. package/dist/assets/projectMeta-3yOEc0oI.js +3 -0
  53. package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-BPNvFA_N.js +2 -0
  55. package/dist/assets/renderMobileApp-CNog29Xy.js +28 -0
  56. package/dist/assets/runtimeConfig-D28kNEYO.js +2 -0
  57. package/dist/assets/runtimeEndpointReset-CorvFIRk.js +147 -0
  58. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  59. package/dist/assets/sessionLookup-CbatbQf7.js +1 -0
  60. package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
  61. package/dist/assets/src-Af9cMFKF.css +2 -0
  62. package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
  63. package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
  64. package/dist/assets/textarea-CIO34TZz.js +1 -0
  65. package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
  66. package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
  67. package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
  68. package/dist/assets/url-CE_gAfNa.js +1 -0
  69. package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-CDpeZWK4.js} +44 -39
  70. package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
  71. package/dist/assets/useDesktopWindowControlsLayout-D-xPByG0.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-Dp-tez_r.js +1 -0
  73. package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
  74. package/dist/assets/useMobileNavigationStore-DE1Pe9A9.js +412 -0
  75. package/dist/assets/useSessionAutoCleanup-jNJ_WC38.js +1 -0
  76. package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
  77. package/dist/assets/useWorkerHighlightedLines-MDnGj3yv.js +7 -0
  78. package/dist/assets/utils-B0mQXN9L.js +1 -0
  79. package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
  80. package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
  81. package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
  82. package/dist/index.html +9 -11
  83. package/dist/mini-chat.html +9 -11
  84. package/dist/mobile.html +15 -17
  85. package/package.json +2 -1
  86. package/server/index.js +2 -0
  87. package/server/lib/assistant-delivery-parts.js +3 -1
  88. package/server/lib/assistant-delivery-parts.test.js +11 -0
  89. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  90. package/server/lib/assistants/assign.js +225 -0
  91. package/server/lib/assistants/assign.test.js +118 -0
  92. package/server/lib/assistants/bubbles.js +45 -0
  93. package/server/lib/assistants/bubbles.test.js +36 -0
  94. package/server/lib/assistants/cards.js +148 -0
  95. package/server/lib/assistants/cards.test.js +116 -0
  96. package/server/lib/assistants/contact-store.js +284 -0
  97. package/server/lib/assistants/contact-store.test.js +131 -0
  98. package/server/lib/assistants/contact-tools.js +761 -0
  99. package/server/lib/assistants/contact-tools.test.js +328 -0
  100. package/server/lib/assistants/contracts.js +20 -1
  101. package/server/lib/assistants/harness.js +551 -0
  102. package/server/lib/assistants/harness.test.js +607 -0
  103. package/server/lib/assistants/routes.js +44 -4
  104. package/server/lib/assistants/service.js +930 -17
  105. package/server/lib/assistants/service.test.js +1163 -45
  106. package/server/lib/llm/DOCUMENTATION.md +84 -0
  107. package/server/lib/llm/catalog.js +100 -0
  108. package/server/lib/llm/catalog.test.js +78 -0
  109. package/server/lib/llm/completions.js +151 -0
  110. package/server/lib/llm/completions.test.js +127 -0
  111. package/server/lib/llm/generate.js +570 -0
  112. package/server/lib/llm/generate.test.js +459 -0
  113. package/server/lib/llm/routes.js +47 -0
  114. package/server/lib/llm/temp-directory.js +37 -0
  115. package/server/lib/notifications/APNS.md +23 -17
  116. package/server/lib/notifications/DOCUMENTATION.md +5 -4
  117. package/server/lib/notifications/apns-runtime.js +139 -20
  118. package/server/lib/notifications/apns-runtime.test.js +66 -7
  119. package/server/lib/notifications/apns-titles.js +62 -62
  120. package/server/lib/notifications/apns-titles.test.js +3 -1
  121. package/server/lib/notifications/routes.js +1 -1
  122. package/server/lib/notifications/runtime.js +177 -86
  123. package/server/lib/notifications/runtime.test.js +122 -0
  124. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  125. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  126. package/server/lib/opencode/settings-runtime.js +18 -2
  127. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  128. package/server/lib/session-goal/runtime.js +20 -0
  129. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  130. package/server/lib/session-index/service.js +10 -4
  131. package/server/lib/session-index/service.test.js +37 -0
  132. package/server/lib/session-title/runtime.js +1 -0
  133. package/server/lib/session-title/runtime.test.js +11 -0
  134. package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
  135. package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
  136. package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
  137. package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
  138. package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
  139. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  140. package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
  141. package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
  142. package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
  143. package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
  144. package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
  145. package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
  146. package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
  147. package/dist/assets/SettingsView-DyCZehFG.js +0 -25
  148. package/dist/assets/TerminalView-XSALfUaH.js +0 -8
  149. package/dist/assets/ToolOutputDialog-BfuXazW9.js +0 -10
  150. package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
  151. package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
  152. package/dist/assets/desktop-CfLlmafK.js +0 -1
  153. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  154. package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
  155. package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
  156. package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
  157. package/dist/assets/main-B9HVtgXE.js +0 -2
  158. package/dist/assets/main-ZNyIs3Sn.js +0 -29
  159. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  160. package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
  161. package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
  162. package/dist/assets/mobile-DEduvLJj.js +0 -2
  163. package/dist/assets/multirun-DU-wrxlG.js +0 -4
  164. package/dist/assets/persistence-CmjKImz2.js +0 -3
  165. package/dist/assets/platform-skqcMARt.js +0 -2
  166. package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
  167. package/dist/assets/radio-BEonIX1k.js +0 -1
  168. package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
  169. package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
  170. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  171. package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
  172. package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
  173. package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
  174. package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
  175. package/dist/assets/src-2eFAiBLq.css +0 -2
  176. package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
  177. package/dist/assets/url-KeihbE0d.js +0 -1
  178. package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
  179. package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
  180. package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
  181. package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
  182. package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
  183. package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
  184. package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
  185. package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
  186. package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
  187. package/dist/assets/utils-CTL16t2f.js +0 -1
@@ -47,9 +47,20 @@ export const createNotificationTriggerRuntime = (deps) => {
47
47
  return pendingPushTags.size;
48
48
  };
49
49
 
50
+ const stringifyApnsData = (data) => {
51
+ if (!data || typeof data !== 'object') return undefined;
52
+ const next = {};
53
+ for (const [key, value] of Object.entries(data)) {
54
+ if (typeof value !== 'string' || value.length === 0) continue;
55
+ next[key] = value;
56
+ }
57
+ return Object.keys(next).length > 0 ? next : undefined;
58
+ };
59
+
50
60
  // Generic notification for native push (per the mobile design): a fixed, scenario-based
51
61
  // title + the session name as the body. No model/project/message content crosses the relay.
52
62
  // Title strings are localized per device token locale inside sendApnsToAllUiSessions.
63
+ // Contact turns opt out via preserveAlert — those keep nickname + spoken text.
53
64
  const toApnsGenericPayload = (payload) => {
54
65
  const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
55
66
  const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
@@ -66,15 +77,39 @@ export const createNotificationTriggerRuntime = (deps) => {
66
77
  };
67
78
  };
68
79
 
80
+ const toApnsContactPayload = (payload) => {
81
+ const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
82
+ const title = typeof payload?.title === 'string' && payload.title.trim()
83
+ ? payload.title.trim()
84
+ : 'Assistant';
85
+ const body = typeof payload?.body === 'string' ? payload.body : '';
86
+ return {
87
+ title,
88
+ body,
89
+ badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
90
+ tag: payload?.tag,
91
+ data: stringifyApnsData({
92
+ assistantID: data.assistantID,
93
+ url: typeof data.assistantID === 'string' && data.assistantID.trim()
94
+ ? `openchamber://assistant/${encodeURIComponent(data.assistantID.trim())}`
95
+ : (typeof data.url === 'string' ? data.url : undefined),
96
+ }),
97
+ };
98
+ };
99
+
69
100
  // Fan a notification out to every delivery channel: browser web-push (full templated
70
- // payload) and native iOS APNs (generic model-based text). Both share the dedup tag;
71
- // a failure in one channel must not block the other. Visibility on any other client
72
- // never suppresses delivery — every subscribed surface gets the push.
73
- const fanoutPush = (payload, options) => Promise.all([
101
+ // payload) and native iOS APNs (generic model-based text, unless preserveAlert).
102
+ // Both share the dedup tag; a failure in one channel must not block the other.
103
+ // Visibility on any other client never suppresses delivery — every subscribed
104
+ // surface gets the push.
105
+ const fanoutPush = (payload, options = {}) => Promise.all([
74
106
  Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => {
75
107
  console.warn('[Push] web-push fanout failed:', error?.message ?? error);
76
108
  }),
77
- Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => {
109
+ Promise.resolve(sendApnsToAllUiSessions?.(
110
+ options.preserveAlert === true ? toApnsContactPayload(payload) : toApnsGenericPayload(payload),
111
+ options,
112
+ )).catch((error) => {
78
113
  console.warn('[APNs] fanout failed:', error?.message ?? error);
79
114
  }),
80
115
  ]);
@@ -97,6 +132,8 @@ export const createNotificationTriggerRuntime = (deps) => {
97
132
 
98
133
  const sessionMetaCache = new Map();
99
134
  const SESSION_META_CACHE_TTL_MS = 60 * 1000;
135
+ const HIDDEN_SESSION_TITLES = new Set(['smartfetch-secondary']);
136
+ const nonEmptySystemID = (value) => typeof value === 'string' && value.length > 0;
100
137
 
101
138
  // Sessions where the client has enabled Permission Auto-Accept. Mirrored
102
139
  // from the client-side permissionStore via POST /api/notifications/auto-accept
@@ -148,32 +185,47 @@ export const createNotificationTriggerRuntime = (deps) => {
148
185
  smallModelPurpose: meta.smallModelPurpose !== undefined
149
186
  ? meta.smallModelPurpose
150
187
  : (previous?.smallModelPurpose ?? null),
188
+ assistantID: meta.assistantID !== undefined ? meta.assistantID : (previous?.assistantID ?? null),
189
+ assignedFrom: meta.assignedFrom !== undefined ? meta.assignedFrom : (previous?.assignedFrom ?? null),
190
+ scheduledTaskID: meta.scheduledTaskID !== undefined
191
+ ? meta.scheduledTaskID
192
+ : (previous?.scheduledTaskID ?? null),
193
+ title: meta.title !== undefined ? meta.title : (previous?.title ?? null),
194
+ complete: meta.complete === true || previous?.complete === true,
151
195
  at: Date.now(),
152
196
  });
153
197
  };
154
198
 
155
- const getParentIdFromPayload = (payload) => {
156
- if (!payload || typeof payload !== 'object') return undefined;
157
- if (payload.type !== 'session.created' && payload.type !== 'session.updated') return undefined;
158
- return normalizeParentID(payload.properties?.info?.parentID);
159
- };
160
-
161
- const getSmallModelPurposeFromPayload = (payload) => {
162
- if (!payload || typeof payload !== 'object') return undefined;
163
- if (payload.type !== 'session.created' && payload.type !== 'session.updated') return undefined;
164
- return normalizeSmallModelPurpose(payload.properties?.info?.metadata?.openchamber?.smallModel?.purpose);
199
+ const metaFromOpenchamber = (openchamber, extras = {}) => {
200
+ const assignedFrom = typeof openchamber?.assigned?.from === 'string'
201
+ ? openchamber.assigned.from
202
+ : null;
203
+ return {
204
+ smallModelPurpose: normalizeSmallModelPurpose(openchamber?.smallModel?.purpose),
205
+ assistantID: nonEmptySystemID(openchamber?.assistant?.assistantID)
206
+ ? openchamber.assistant.assistantID
207
+ : null,
208
+ assignedFrom,
209
+ scheduledTaskID: nonEmptySystemID(openchamber?.scheduledTask?.taskID)
210
+ ? openchamber.scheduledTask.taskID
211
+ : null,
212
+ ...extras,
213
+ };
165
214
  };
166
215
 
167
216
  const maybeCacheSessionMetaFromPayload = (payload) => {
217
+ if (!payload || typeof payload !== 'object') return;
218
+ if (payload.type !== 'session.created' && payload.type !== 'session.updated') return;
219
+ const info = payload.properties?.info;
220
+ if (!info || typeof info !== 'object') return;
168
221
  const sessionId = extractSessionIdFromPayload(payload);
169
222
  if (typeof sessionId !== 'string' || sessionId.length === 0) return;
170
223
  const directory = extractDirectoryFromPayload(payload);
171
- const parentID = getParentIdFromPayload(payload);
172
- const smallModelPurpose = getSmallModelPurposeFromPayload(payload);
173
- if (parentID === undefined && smallModelPurpose === undefined) return;
174
224
  setCachedSessionMeta(sessionId, directory, {
175
- ...(parentID !== undefined ? { parentID } : {}),
176
- ...(smallModelPurpose !== undefined ? { smallModelPurpose } : {}),
225
+ parentID: normalizeParentID(info.parentID),
226
+ title: typeof info.title === 'string' ? info.title : null,
227
+ complete: true,
228
+ ...metaFromOpenchamber(info.metadata?.openchamber),
177
229
  });
178
230
  };
179
231
 
@@ -181,7 +233,7 @@ export const createNotificationTriggerRuntime = (deps) => {
181
233
  if (!sessionId) return undefined;
182
234
 
183
235
  const cached = getCachedSessionMeta(sessionId, directory);
184
- if (cached !== undefined) return cached;
236
+ if (cached?.complete) return cached;
185
237
 
186
238
  try {
187
239
  const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
@@ -195,21 +247,23 @@ export const createNotificationTriggerRuntime = (deps) => {
195
247
  signal: AbortSignal.timeout(2000),
196
248
  });
197
249
  if (!response.ok) {
198
- return undefined;
250
+ return cached;
199
251
  }
200
252
  const session = await response.json().catch(() => null);
201
253
  if (!session || typeof session !== 'object') {
202
- return undefined;
254
+ return cached;
203
255
  }
204
256
 
205
257
  const meta = {
206
258
  parentID: normalizeParentID(session.parentID),
207
- smallModelPurpose: normalizeSmallModelPurpose(session.metadata?.openchamber?.smallModel?.purpose),
259
+ title: typeof session.title === 'string' ? session.title : null,
260
+ complete: true,
261
+ ...metaFromOpenchamber(session.metadata?.openchamber),
208
262
  };
209
263
  setCachedSessionMeta(sessionId, directory, meta);
210
- return meta;
264
+ return getCachedSessionMeta(sessionId, directory) ?? meta;
211
265
  } catch {
212
- return undefined;
266
+ return cached;
213
267
  }
214
268
  };
215
269
 
@@ -234,20 +288,25 @@ export const createNotificationTriggerRuntime = (deps) => {
234
288
  return false;
235
289
  };
236
290
 
237
- const isSubtaskSession = async (sessionId, directory) => {
238
- const parentID = await fetchSessionParentId(sessionId, directory);
239
- return typeof parentID === 'string' && parentID.length > 0;
240
- };
241
-
242
- const isSmallModelSession = async (sessionId, directory) => {
243
- const meta = await fetchSessionMeta(sessionId, directory);
244
- return typeof meta?.smallModelPurpose === 'string' && meta.smallModelPurpose.length > 0;
291
+ const isHiddenFromNavSession = (meta) => {
292
+ if (!meta) return false;
293
+ if (typeof meta.parentID === 'string' && meta.parentID.length > 0) return true;
294
+ if (typeof meta.smallModelPurpose === 'string' && meta.smallModelPurpose.length > 0) return true;
295
+ if (typeof meta.assistantID === 'string' && meta.assistantID.length > 0 && meta.assignedFrom !== 'contact') {
296
+ return true;
297
+ }
298
+ if (typeof meta.scheduledTaskID === 'string' && meta.scheduledTaskID.length > 0) return true;
299
+ if (typeof meta.title === 'string' && HIDDEN_SESSION_TITLES.has(meta.title)) return true;
300
+ return false;
245
301
  };
246
302
 
303
+ // Only sessions that can appear as sidebar roots get ordinary push. Child /
304
+ // subagent sessions, archived Assistant bindings, scheduled-task sessions,
305
+ // small-model system sessions, and SmartFetch secondaries are excluded.
306
+ // Contact-assigned worker sessions stay visible and still notify.
247
307
  const shouldSkipSystemSessionNotification = async (sessionId, directory) => {
248
- if (await isSubtaskSession(sessionId, directory)) return true;
249
- if (await isSmallModelSession(sessionId, directory)) return true;
250
- return false;
308
+ const meta = await fetchSessionMeta(sessionId, directory);
309
+ return isHiddenFromNavSession(meta);
251
310
  };
252
311
 
253
312
  const extractSessionIdFromPayload = (payload) => {
@@ -273,40 +332,6 @@ export const createNotificationTriggerRuntime = (deps) => {
273
332
  return trimmed.length > 0 ? trimmed : undefined;
274
333
  };
275
334
 
276
- const formatMode = (raw) => {
277
- const value = typeof raw === 'string' ? raw.trim() : '';
278
- const normalized = value.length > 0 ? value : 'agent';
279
- return normalized
280
- .split(/[-_\s]+/)
281
- .filter(Boolean)
282
- .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
283
- .join(' ');
284
- };
285
-
286
- const formatModelId = (raw) => {
287
- const value = typeof raw === 'string' ? raw.trim() : '';
288
- if (!value) {
289
- return 'Assistant';
290
- }
291
-
292
- const tokens = value.split(/[-_]+/).filter(Boolean);
293
- const result = [];
294
- for (let i = 0; i < tokens.length; i += 1) {
295
- const current = tokens[i];
296
- const next = tokens[i + 1];
297
- if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
298
- result.push(`${current}.${next}`);
299
- i += 1;
300
- continue;
301
- }
302
- result.push(current);
303
- }
304
-
305
- return result
306
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
307
- .join(' ');
308
- };
309
-
310
335
  // A session with an ACTIVE goal suppresses per-turn ready notifications;
311
336
  // the session-goal runtime sends its own notification when the goal
312
337
  // settles. Fetch failures fall through to normal notification behavior.
@@ -338,6 +363,20 @@ export const createNotificationTriggerRuntime = (deps) => {
338
363
 
339
364
  const sessionId = extractSessionIdFromPayload(payload);
340
365
  const notificationDirectory = extractDirectoryFromPayload(payload);
366
+ if ((payload.type === 'session.updated' || payload.type === 'session.created') && sessionId) {
367
+ const title = payload.properties?.info?.title;
368
+ if (
369
+ typeof title === 'string'
370
+ && title.trim()
371
+ && !(await shouldSkipSystemSessionNotification(sessionId, notificationDirectory))
372
+ ) {
373
+ try {
374
+ await sendLiveActivityEnd?.({ sessionId, title: title.trim() });
375
+ } catch (error) {
376
+ console.warn('[Live Activity] title update failed:', error?.message ?? error);
377
+ }
378
+ }
379
+ }
341
380
  if ((payload.type === 'session.idle' || payload.type === 'session.error') && sessionId) {
342
381
  const error = payload.properties?.error;
343
382
  const errorText = typeof error?.message === 'string'
@@ -404,13 +443,13 @@ export const createNotificationTriggerRuntime = (deps) => {
404
443
  }
405
444
  lastReadyNotificationAt.set(sessionId, now);
406
445
 
407
- let title = `${formatMode(info?.mode)} agent is ready`;
408
- let body = `${formatModelId(info?.modelID)} completed the task`;
446
+ let title = 'Task completed';
447
+ let body = '';
409
448
  let sessionName = '';
410
449
 
411
450
  try {
412
451
  const templates = settings.notificationTemplates || {};
413
- const completionTemplate = templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' };
452
+ const completionTemplate = templates.completion || { title: 'Task completed', message: '{session_name}' };
414
453
 
415
454
  const variables = await buildTemplateVariables(payload, sessionId);
416
455
  sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
@@ -430,8 +469,10 @@ export const createNotificationTriggerRuntime = (deps) => {
430
469
  const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
431
470
  if (resolvedTitle) title = resolvedTitle;
432
471
  if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody;
472
+ if (!body) body = sessionName || resolvedBody || 'Session';
433
473
  } catch (error) {
434
474
  console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error);
475
+ if (!body) body = sessionName || 'Session';
435
476
  }
436
477
 
437
478
  if (settings.nativeNotificationsEnabled) {
@@ -492,12 +533,8 @@ export const createNotificationTriggerRuntime = (deps) => {
492
533
  const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
493
534
  const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
494
535
 
495
- let title = /plan\s*mode/i.test(header)
496
- ? 'Switch to plan mode'
497
- : /build\s*agent/i.test(header)
498
- ? 'Switch to build mode'
499
- : header || 'Input needed';
500
- let body = questionText || 'Agent is waiting for your response';
536
+ let title = 'Needs your answer';
537
+ let body = '';
501
538
  let sessionName = '';
502
539
 
503
540
  try {
@@ -506,14 +543,16 @@ export const createNotificationTriggerRuntime = (deps) => {
506
543
  variables.last_message = questionText || header || '';
507
544
 
508
545
  const templates = settings.notificationTemplates || {};
509
- const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
546
+ const questionTemplate = templates.question || { title: 'Needs your answer', message: '{session_name}' };
510
547
 
511
548
  const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
512
549
  const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
513
550
  if (resolvedTitle) title = resolvedTitle;
514
551
  if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
552
+ if (!body) body = sessionName || resolvedBody || 'Session';
515
553
  } catch (error) {
516
554
  console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error);
555
+ if (!body) body = sessionName || 'Session';
517
556
  }
518
557
 
519
558
  if (settings.nativeNotificationsEnabled) {
@@ -618,8 +657,8 @@ export const createNotificationTriggerRuntime = (deps) => {
618
657
  ? sessionTitle.trim()
619
658
  : permissionText || 'Agent is waiting for your approval';
620
659
 
621
- let title = 'Permission required';
622
- let body = fallbackMessage;
660
+ let title = 'Needs permission';
661
+ let body = '';
623
662
  let sessionName = '';
624
663
 
625
664
  try {
@@ -628,14 +667,14 @@ export const createNotificationTriggerRuntime = (deps) => {
628
667
  variables.last_message = fallbackMessage;
629
668
 
630
669
  const templates = settings.notificationTemplates || {};
631
- const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
670
+ const questionTemplate = templates.question || { title: 'Needs permission', message: '{session_name}' };
632
671
 
633
- const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
634
672
  const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
635
- if (resolvedTitle) title = resolvedTitle;
636
673
  if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
674
+ if (!body) body = sessionName || fallbackMessage || 'Session';
637
675
  } catch (error) {
638
676
  console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error);
677
+ if (!body) body = sessionName || fallbackMessage || 'Session';
639
678
  }
640
679
 
641
680
  if (settings.nativeNotificationsEnabled) {
@@ -713,6 +752,57 @@ export const createNotificationTriggerRuntime = (deps) => {
713
752
  );
714
753
  };
715
754
 
755
+ const contactNotificationTitle = (name) => {
756
+ if (typeof name !== 'string' || !name.trim()) return 'Assistant';
757
+ const stripped = name.replace(/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\uFE0F\u200D])+\s*/u, '').trim();
758
+ return stripped || name.trim();
759
+ };
760
+
761
+ const sendContactTurnNotification = async ({ assistantID, name, body, status } = {}) => {
762
+ if (typeof assistantID !== 'string' || !assistantID.trim()) return;
763
+ const settings = await readSettingsFromDisk();
764
+ if (settings.notifyOnCompletion === false) return;
765
+
766
+ const title = contactNotificationTitle(name);
767
+ const rawBody = typeof body === 'string' ? body : '';
768
+ let text = '';
769
+ try {
770
+ text = await prepareNotificationLastMessage({ message: rawBody, settings });
771
+ } catch {
772
+ text = '';
773
+ }
774
+ if (!text) text = status === 'error' ? 'Something went wrong' : 'New message';
775
+ const tag = `contact-${assistantID.trim()}`;
776
+
777
+ if (settings.nativeNotificationsEnabled) {
778
+ const notificationPayload = {
779
+ title,
780
+ body: text,
781
+ tag,
782
+ kind: 'contact',
783
+ assistantID: assistantID.trim(),
784
+ requireHidden: false,
785
+ };
786
+ const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
787
+ broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
788
+ }
789
+
790
+ const assistantPath = `/assistant/${encodeURIComponent(assistantID.trim())}`;
791
+ await fanoutPush(
792
+ {
793
+ title,
794
+ body: text,
795
+ tag,
796
+ data: {
797
+ url: assistantPath,
798
+ assistantID: assistantID.trim(),
799
+ type: 'contact',
800
+ },
801
+ },
802
+ { requireNoSse: true, preserveAlert: true },
803
+ );
804
+ };
805
+
716
806
  return {
717
807
  maybeSendPushForTrigger,
718
808
  setAutoAcceptSession,
@@ -720,5 +810,6 @@ export const createNotificationTriggerRuntime = (deps) => {
720
810
  setGetIsSessionAutoAccepting,
721
811
  clearPendingPushBadge,
722
812
  sendGoalSettlePush,
813
+ sendContactTurnNotification,
723
814
  };
724
815
  };
@@ -175,6 +175,58 @@ describe('notification trigger runtime smallModel suppression', () => {
175
175
  expect(emitDesktopNotification).toHaveBeenCalledTimes(1);
176
176
  expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
177
177
  });
178
+
179
+ it('skips ready notifications for child sessions', async () => {
180
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
181
+ id: 'ses_child',
182
+ parentID: 'ses_parent',
183
+ title: 'Fixer',
184
+ metadata: {},
185
+ })));
186
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
187
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_child'));
188
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
189
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it('skips ready notifications for Assistant binding sessions', async () => {
193
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
194
+ id: 'ses_binding',
195
+ parentID: null,
196
+ title: '大小白',
197
+ metadata: { openchamber: { assistant: { assistantID: 'asst_1', name: '大小白' } } },
198
+ })));
199
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
200
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_binding'));
201
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
202
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
203
+ });
204
+
205
+ it('skips ready notifications for scheduled-task sessions', async () => {
206
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
207
+ id: 'ses_sched',
208
+ parentID: null,
209
+ title: 'Nightly',
210
+ metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
211
+ })));
212
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
213
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_sched'));
214
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
215
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it('still notifies contact-assigned worker sessions that appear in the sidebar', async () => {
219
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
220
+ id: 'ses_worker',
221
+ parentID: null,
222
+ title: 'Assigned work',
223
+ metadata: { openchamber: { assigned: { from: 'contact', assistantID: 'asst_1', name: '大小白' } } },
224
+ })));
225
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime();
226
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_worker'));
227
+ expect(emitDesktopNotification).toHaveBeenCalledTimes(1);
228
+ expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
229
+ });
178
230
  });
179
231
 
180
232
  const rootSessionResponse = () => jsonResponse({
@@ -199,6 +251,54 @@ const completionPayload = (sessionId = 'ses_root', finish = 'stop') => ({
199
251
  },
200
252
  });
201
253
 
254
+ describe('contact turn notifications', () => {
255
+ it('sends an SMS-style title and body when a contact turn completes', async () => {
256
+ const { runtime, emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions, sendApnsToAllUiSessions } = createRuntime();
257
+ await runtime.sendContactTurnNotification({
258
+ assistantID: 'asst_1',
259
+ name: '大小白',
260
+ body: '我去找一下',
261
+ status: 'complete',
262
+ });
263
+ expect(emitDesktopNotification).toHaveBeenCalledWith(expect.objectContaining({
264
+ title: '大小白',
265
+ body: '我去找一下',
266
+ tag: 'contact-asst_1',
267
+ kind: 'contact',
268
+ assistantID: 'asst_1',
269
+ }));
270
+ expect(broadcastUiNotification).toHaveBeenCalled();
271
+ expect(sendPushToAllUiSessions).toHaveBeenCalledWith(expect.objectContaining({
272
+ title: '大小白',
273
+ body: '我去找一下',
274
+ data: expect.objectContaining({ assistantID: 'asst_1', type: 'contact', url: '/assistant/asst_1' }),
275
+ }), expect.objectContaining({ requireNoSse: true, preserveAlert: true }));
276
+ expect(sendApnsToAllUiSessions).toHaveBeenCalledWith(expect.objectContaining({
277
+ title: '大小白',
278
+ body: '我去找一下',
279
+ data: expect.objectContaining({
280
+ assistantID: 'asst_1',
281
+ url: 'openchamber://assistant/asst_1',
282
+ }),
283
+ }), expect.objectContaining({ preserveAlert: true }));
284
+ expect(sendApnsToAllUiSessions.mock.calls[0][0].type).toBeUndefined();
285
+ });
286
+
287
+ it('skips contact notifications when completion notices are disabled', async () => {
288
+ const { runtime, emitDesktopNotification, sendPushToAllUiSessions } = createRuntime({
289
+ readSettingsFromDisk: vi.fn(async () => ({ ...defaultSettings, notifyOnCompletion: false })),
290
+ });
291
+ await runtime.sendContactTurnNotification({
292
+ assistantID: 'asst_1',
293
+ name: '大小白',
294
+ body: 'hi',
295
+ status: 'complete',
296
+ });
297
+ expect(emitDesktopNotification).not.toHaveBeenCalled();
298
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
299
+ });
300
+ });
301
+
202
302
  describe('notification trigger live activity end', () => {
203
303
  it('ends the live activity on top-level completion and error', async () => {
204
304
  vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));
@@ -232,6 +332,28 @@ describe('notification trigger live activity end', () => {
232
332
  expect(sendApnsToAllUiSessions).toHaveBeenCalledTimes(1);
233
333
  });
234
334
 
335
+ it('does not patch live activity titles for child sessions', async () => {
336
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
337
+ id: 'ses_child',
338
+ parentID: 'ses_parent',
339
+ metadata: {},
340
+ })));
341
+ const { runtime, sendLiveActivityEnd } = createRuntime();
342
+ await runtime.maybeSendPushForTrigger({
343
+ type: 'session.updated',
344
+ properties: {
345
+ directory: '/repo',
346
+ info: {
347
+ id: 'ses_child',
348
+ sessionID: 'ses_child',
349
+ parentID: 'ses_parent',
350
+ title: 'Fixer',
351
+ },
352
+ },
353
+ });
354
+ expect(sendLiveActivityEnd).not.toHaveBeenCalled();
355
+ });
356
+
235
357
  it('suppresses live activity end for child and small-model sessions', async () => {
236
358
  const fetchMock = vi.fn(async (url) => {
237
359
  if (String(url).includes('ses_child')) {
@@ -15,6 +15,7 @@ import { registerScheduledTaskToolRoute } from '../scheduled-tasks/managed-tool-
15
15
  import { registerConversationRoutes } from '../conversations/routes.js';
16
16
  import { registerSessionTurnPageRoutes } from '../session-turn-pages/routes.js';
17
17
  import { registerAssistantRoutes } from '../assistants/routes.js';
18
+ import { registerLlmRoutes } from '../llm/routes.js';
18
19
  import { registerMessageQueueRoutes } from '../message-queue/routes.js';
19
20
  import { registerSkillRoutes } from './skill-routes.js';
20
21
  import { registerPluginRoutes } from './plugin-routes.js';
@@ -126,6 +127,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
126
127
  broadcastGlobalUiEvent,
127
128
  globalMessageStreamHub,
128
129
  getServerId,
130
+ sessionIndexService,
131
+ notifyContactTurnComplete,
129
132
  } = routeDependencies;
130
133
 
131
134
  registerSettingsUtilityRoutes(app, {
@@ -215,6 +218,15 @@ export const createFeatureRoutesRuntime = (dependencies) => {
215
218
  getOpenChamberEventClients,
216
219
  writeSseEvent,
217
220
  });
221
+ // Contact turn lifecycle / bubble deltas — same SSE bus, not revision watermark.
222
+ const broadcastContactTurnEvent = createOpenChamberEventBroadcaster({
223
+ getOpenChamberEventClients,
224
+ writeSseEvent,
225
+ });
226
+ registerLlmRoutes(app, {
227
+ buildOpenCodeUrl,
228
+ getOpenCodeAuthHeaders,
229
+ });
218
230
  assistantRoutesRuntime = registerAssistantRoutes(app, {
219
231
  openchamberDataDir,
220
232
  dbPath: path.join(openchamberDataDir, 'assistants.sqlite'),
@@ -222,12 +234,30 @@ export const createFeatureRoutesRuntime = (dependencies) => {
222
234
  getOpenCodeAuthHeaders,
223
235
  getServerId,
224
236
  getAllowedRoots: () => assistantAllowedRoots,
237
+ listProjects: async () => {
238
+ const settings = await readSettingsFromDiskMigrated();
239
+ return sanitizeProjects(settings?.projects ?? [])
240
+ .filter((project) => project && typeof project.id === 'string' && typeof project.path === 'string')
241
+ .map((project) => ({
242
+ id: project.id,
243
+ path: project.path,
244
+ ...(typeof project.label === 'string' && project.label.trim()
245
+ ? { label: project.label.trim() }
246
+ : {}),
247
+ }));
248
+ },
249
+ listScheduledTasks: (projectID) => projectConfigRuntime.listScheduledTasks(projectID),
250
+ sessionIndexService,
251
+ upsertScheduledTask: (projectID, task) => projectConfigRuntime.upsertScheduledTask(projectID, task),
252
+ syncScheduledTaskProject: (projectID) => scheduledTasksRuntime.syncProject(projectID),
225
253
  refreshAllowedRoots: refreshAssistantAllowedRoots,
226
254
  globalEventHub: globalMessageStreamHub,
227
255
  onRevisionTip: (tip) => broadcastAssistantRevisionTip({
228
256
  type: 'openchamber:assistants-changed',
229
257
  properties: tip,
230
258
  }),
259
+ onContactTurnEvent: (event) => broadcastContactTurnEvent(event),
260
+ onContactTurnComplete: (event) => notifyContactTurnComplete?.(event),
231
261
  });
232
262
  messageQueueRuntime?.setAssistantDeliveryService?.(assistantRoutesRuntime.service);
233
263
 
@@ -3,6 +3,26 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import { createWorktreeTopologyBroadcaster } from './feature-routes-runtime.js';
4
4
 
5
5
  describe('feature routes runtime composition', () => {
6
+ it('wires scheduled-task upsert into assistant contact tools', async () => {
7
+ const source = await fs.readFile(new URL('./feature-routes-runtime.js', import.meta.url), 'utf8');
8
+ expect(source).toMatch(/registerAssistantRoutes\(app, \{[\s\S]*upsertScheduledTask:[\s\S]*projectConfigRuntime\.upsertScheduledTask/);
9
+ expect(source).toMatch(/registerAssistantRoutes\(app, \{[\s\S]*syncScheduledTaskProject:[\s\S]*scheduledTasksRuntime\.syncProject/);
10
+ expect(source).toMatch(/registerAssistantRoutes\(app, \{[\s\S]*listProjects:/);
11
+ expect(source).toMatch(/registerAssistantRoutes\(app, \{[\s\S]*listScheduledTasks:[\s\S]*projectConfigRuntime\.listScheduledTasks/);
12
+ expect(source).toMatch(/registerAssistantRoutes\(app, \{[\s\S]*sessionIndexService/);
13
+ expect(source).toMatch(/label: project\.label\.trim\(\)/);
14
+ });
15
+
16
+ it('fans contact turn events out on the OpenChamber SSE bus separately from revision tips', async () => {
17
+ const source = await fs.readFile(new URL('./feature-routes-runtime.js', import.meta.url), 'utf8');
18
+ expect(source).toMatch(/const broadcastContactTurnEvent = createOpenChamberEventBroadcaster\(/);
19
+ expect(source).toMatch(/onContactTurnEvent: \(event\) => broadcastContactTurnEvent\(event\)/);
20
+ expect(source).toMatch(/onContactTurnComplete: \(event\) => notifyContactTurnComplete\?\.\(event\)/);
21
+ expect(source).toMatch(/onRevisionTip: \(tip\) => broadcastAssistantRevisionTip\(\{\s*type: 'openchamber:assistants-changed'/);
22
+ // Contact turn envelopes stay off the assistants-changed revision watermark path.
23
+ expect(source).not.toMatch(/onRevisionTip:[\s\S]*contact-turn/);
24
+ });
25
+
6
26
  it('registers the managed scheduled-task tool route with its required dependencies', async () => {
7
27
  const source = await fs.readFile(new URL('./feature-routes-runtime.js', import.meta.url), 'utf8');
8
28
  expect(source).toContain("import { registerScheduledTaskToolRoute } from '../scheduled-tasks/managed-tool-route.js';");